refactor(learning): split answer idempotency registry

This commit is contained in:
2026-08-05 14:35:08 +08:00
parent 269af73469
commit fa2c502a26
9 changed files with 22878 additions and 31 deletions

View File

@@ -102,6 +102,18 @@ public sealed class CurrentAnswer : TenantEntity
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class AnswerSubmissionIdempotency : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public Guid PracticeSessionId { get; set; }
public string IdempotencyKey { get; set; } = string.Empty;
public string RequestHash { get; set; } = string.Empty;
public Guid AnswerRecordId { get; set; }
public JsonElement ResponseSnapshot { get; set; } = JsonDefaults.Object();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public enum AnswerGradingStatus
{
PendingReview,

View File

@@ -21,6 +21,7 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
throw new LearningValidationException("selected_option_index_invalid",
"Selected option indices must be zero-based non-negative values.");
var idempotencyKey = command.IdempotencyKey.Trim();
var now = DateTimeOffset.UtcNow;
var sessionQuestion = await learningPersistence.PracticeSessionQuestions.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken);
@@ -40,18 +41,20 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
await learningAccessService.EnsureStrongRevocationVersionAsync(
actor, session.StrongRevocationVersion, cancellationToken);
var deliveryVersion = await LoadDeliveryVersionAsync(actor.TenantId, sessionQuestion, cancellationToken);
var revealedSolution = DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion);
var requestHash = HashAnswer(command);
var existingAnswer = await learningPersistence.AnswerRecords.AsNoTracking().SingleOrDefaultAsync(
var existingSubmission = await learningPersistence.AnswerSubmissionIdempotencies.AsNoTracking()
.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.PracticeSessionId == session.Id &&
item.IdempotencyKey == command.IdempotencyKey,
item.IdempotencyKey == idempotencyKey,
cancellationToken);
if (existingAnswer is not null)
if (existingSubmission is not null)
{
if (!string.Equals(existingAnswer.RequestHash, requestHash, StringComparison.Ordinal))
if (!string.Equals(existingSubmission.RequestHash, requestHash, StringComparison.Ordinal))
{
AnswerConflicts.Add(1);
throw new LearningValidationException("idempotency_conflict",
@@ -59,9 +62,7 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
}
IdempotencyReplays.Add(1);
return ToItem(
existingAnswer,
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
return await ReplayAsync(existingSubmission, revealedSolution, cancellationToken);
}
if (session.ExpiresAt.HasValue && session.ExpiresAt <= now)
@@ -120,7 +121,7 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
AwardedScore = grading.AwardedScore,
Revision = (current?.Revision ?? 0) + 1,
ClientSequence = command.ClientSequence,
IdempotencyKey = command.IdempotencyKey.Trim(),
IdempotencyKey = idempotencyKey,
RequestHash = requestHash,
AnsweredAt = now,
CreatedAt = now
@@ -149,9 +150,18 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
current.Version++;
current.UpdatedAt = now;
}
var response = ToItem(
record,
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
var response = ToItem(record, revealedSolution);
learningPersistence.AnswerSubmissionIdempotencies.Add(new AnswerSubmissionIdempotency
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = session.Id,
IdempotencyKey = idempotencyKey,
RequestHash = requestHash,
AnswerRecordId = record.Id,
ResponseSnapshot = JsonSerializer.SerializeToElement(response),
CreatedAt = now
});
try
{
await unitOfWork.SaveChangesAsync(cancellationToken);
@@ -166,17 +176,16 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
postgresException.SqlState == PostgresErrorCodes.UniqueViolation)
{
unitOfWork.ChangeTracker.Clear();
var replay = await learningPersistence.AnswerRecords.AsNoTracking().SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.PracticeSessionId == session.Id &&
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
var replay = await learningPersistence.AnswerSubmissionIdempotencies.AsNoTracking()
.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.PracticeSessionId == session.Id &&
item.IdempotencyKey == idempotencyKey, cancellationToken);
if (replay is not null && string.Equals(replay.RequestHash, requestHash, StringComparison.Ordinal))
{
IdempotencyReplays.Add(1);
return ToItem(
replay,
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
return await ReplayAsync(replay, revealedSolution, cancellationToken);
}
AnswerConflicts.Add(1);
@@ -186,4 +195,23 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
return response;
}
private async Task<AnswerRecordItem> ReplayAsync(
AnswerSubmissionIdempotency submission,
QuestionSolutionItem? revealedSolution,
CancellationToken cancellationToken)
{
if (submission.ResponseSnapshot.ValueKind == JsonValueKind.Object &&
(submission.ResponseSnapshot.TryGetProperty(nameof(AnswerRecordItem.Id), out _) ||
submission.ResponseSnapshot.TryGetProperty("id", out _)))
return submission.ResponseSnapshot.Deserialize<AnswerRecordItem>()
?? throw new InvalidOperationException("The stored answer response is invalid.");
var historicalRecord = await learningPersistence.AnswerRecords.AsNoTracking().SingleOrDefaultAsync(
item => item.TenantId == submission.TenantId && item.Id == submission.AnswerRecordId,
cancellationToken);
return historicalRecord is null
? throw new InvalidOperationException("The idempotent answer record no longer exists.")
: ToItem(historicalRecord, revealedSolution);
}
}

View File

@@ -134,13 +134,6 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
entity.ClientSequence
}).IsUnique();
builder.HasIndex(entity => new
{
entity.TenantId,
entity.UserId,
entity.PracticeSessionId,
entity.IdempotencyKey
}).IsUnique().HasDatabaseName("ux_answer_records_idempotency");
builder.HasIndex(entity => new
{
entity.TenantId,
entity.UserId,
@@ -183,6 +176,45 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
}
}
internal sealed class AnswerSubmissionIdempotencyConfiguration : IEntityTypeConfiguration<AnswerSubmissionIdempotency>
{
public void Configure(EntityTypeBuilder<AnswerSubmissionIdempotency> builder)
{
builder.ConfigureEntity("answer_submission_idempotencies");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200);
builder.Property(entity => entity.RequestHash).HasMaxLength(64);
builder.Property(entity => entity.ResponseSnapshot).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new
{
entity.TenantId,
entity.UserId,
entity.PracticeSessionId,
entity.IdempotencyKey
}).IsUnique().HasDatabaseName("ux_answer_submission_idempotency");
builder.HasIndex(entity => new { entity.TenantId, entity.AnswerRecordId });
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<PracticeSession>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.UserId,
entity.PracticeSessionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.UserId,
entity.Id
})
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class CurrentAnswerConfiguration : IEntityTypeConfiguration<CurrentAnswer>
{
public void Configure(EntityTypeBuilder<CurrentAnswer> builder)

View File

@@ -0,0 +1,93 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class SplitAnswerSubmissionIdempotency : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "answer_submission_idempotencies",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
practice_session_id = table.Column<Guid>(type: "uuid", nullable: false),
idempotency_key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
request_hash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
answer_record_id = table.Column<Guid>(type: "uuid", nullable: false),
response_snapshot = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_answer_submission_idempotencies", x => x.id);
table.UniqueConstraint("ak_answer_submission_idempotencies_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_answer_submission_idempotencies_practice_sessions_tenant_id~",
columns: x => new { x.tenant_id, x.user_id, x.practice_session_id },
principalTable: "practice_sessions",
principalColumns: new[] { "tenant_id", "user_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_answer_submission_idempotencies_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_answer_submission_idempotencies_tenant_id_answer_record_id",
table: "answer_submission_idempotencies",
columns: new[] { "tenant_id", "answer_record_id" });
migrationBuilder.CreateIndex(
name: "ix_answer_submission_idempotencies_user_id",
table: "answer_submission_idempotencies",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ux_answer_submission_idempotency",
table: "answer_submission_idempotencies",
columns: new[] { "tenant_id", "user_id", "practice_session_id", "idempotency_key" },
unique: true);
migrationBuilder.Sql(
"""
INSERT INTO answer_submission_idempotencies
(id, tenant_id, user_id, practice_session_id, idempotency_key, request_hash,
answer_record_id, response_snapshot, created_at)
SELECT gen_random_uuid(), tenant_id, user_id, practice_session_id, idempotency_key,
request_hash, id, '{}'::jsonb, created_at
FROM answer_records
WHERE idempotency_key <> ''
ON CONFLICT (tenant_id, user_id, practice_session_id, idempotency_key) DO NOTHING
""");
migrationBuilder.DropIndex(
name: "ux_answer_records_idempotency",
table: "answer_records");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "answer_submission_idempotencies");
migrationBuilder.CreateIndex(
name: "ux_answer_records_idempotency",
table: "answer_records",
columns: new[] { "tenant_id", "user_id", "practice_session_id", "idempotency_key" },
unique: true);
}
}
}

View File

@@ -8762,10 +8762,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.IsUnique()
.HasDatabaseName("ix_answer_records_tenant_id_user_id_practice_session_id_client~");
b.HasIndex("TenantId", "UserId", "PracticeSessionId", "IdempotencyKey")
.IsUnique()
.HasDatabaseName("ux_answer_records_idempotency");
b.HasIndex("TenantId", "UserId", "PracticeSessionId", "SessionQuestionId", "Revision")
.IsUnique()
.HasDatabaseName("ux_answer_records_session_question_revision");
@@ -8773,6 +8769,73 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("answer_records", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Learning.AnswerSubmissionIdempotency", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("AnswerRecordId")
.HasColumnType("uuid")
.HasColumnName("answer_record_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("idempotency_key");
b.Property<Guid>("PracticeSessionId")
.HasColumnType("uuid")
.HasColumnName("practice_session_id");
b.Property<string>("RequestHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("request_hash");
b.Property<JsonElement>("ResponseSnapshot")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("response_snapshot")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_answer_submission_idempotencies");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_answer_submission_idempotencies_tenant_id_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_answer_submission_idempotencies_user_id");
b.HasIndex("TenantId", "AnswerRecordId")
.HasDatabaseName("ix_answer_submission_idempotencies_tenant_id_answer_record_id");
b.HasIndex("TenantId", "UserId", "PracticeSessionId", "IdempotencyKey")
.IsUnique()
.HasDatabaseName("ux_answer_submission_idempotency");
b.ToTable("answer_submission_idempotencies", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Learning.BusinessLine", b =>
{
b.Property<Guid>("Id")
@@ -20478,6 +20541,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_answer_records_practice_sessions_tenant_id_user_id_practice~");
});
modelBuilder.Entity("Tiku.Domain.Learning.AnswerSubmissionIdempotency", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_answer_submission_idempotencies_users_user_id");
b.HasOne("Tiku.Domain.Learning.PracticeSession", null)
.WithMany()
.HasForeignKey("TenantId", "UserId", "PracticeSessionId")
.HasPrincipalKey("TenantId", "UserId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_answer_submission_idempotencies_practice_sessions_tenant_id~");
});
modelBuilder.Entity("Tiku.Domain.Learning.ClassContentAssignment", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)

View File

@@ -145,6 +145,7 @@ public interface ILearningPersistence : IModulePersistence
DbSet<PracticeSessionQuestion> PracticeSessionQuestions { get; }
DbSet<AnswerRecord> AnswerRecords { get; }
DbSet<CurrentAnswer> CurrentAnswers { get; }
DbSet<AnswerSubmissionIdempotency> AnswerSubmissionIdempotencies { get; }
DbSet<LearningOperationIdempotency> LearningOperationIdempotencies { get; }
DbSet<LearningOutboxMessage> LearningOutboxMessages { get; }
DbSet<LearningProjectionReceipt> LearningProjectionReceipts { get; }

View File

@@ -19,6 +19,7 @@ public sealed partial class TikuDbContext
public DbSet<PracticeSessionQuestion> PracticeSessionQuestions => Set<PracticeSessionQuestion>();
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
public DbSet<CurrentAnswer> CurrentAnswers => Set<CurrentAnswer>();
public DbSet<AnswerSubmissionIdempotency> AnswerSubmissionIdempotencies => Set<AnswerSubmissionIdempotency>();
public DbSet<LearningOperationIdempotency> LearningOperationIdempotencies => Set<LearningOperationIdempotency>();
public DbSet<LearningOutboxMessage> LearningOutboxMessages => Set<LearningOutboxMessage>();
public DbSet<LearningProjectionReceipt> LearningProjectionReceipts => Set<LearningProjectionReceipt>();

View File

@@ -559,7 +559,15 @@ public sealed class LearningEndpointTests
};
var first = await client.PostAsJsonAsync("/api/student/learning/answers", firstRequest);
var replay = await client.PostAsJsonAsync("/api/student/learning/answers", firstRequest);
var replay = await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ClientSequence = 1,
IdempotencyKey = " revision-answer-1 ",
SelectedOptionIndices = [1]
});
var conflict = await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
@@ -604,6 +612,8 @@ public sealed class LearningEndpointTests
.OrderBy(item => item.Revision)
.ToArrayAsync();
Assert.Equal(2, records.Length);
Assert.Equal(2, await db.AnswerSubmissionIdempotencies.CountAsync(item =>
item.PracticeSessionId == answerable.SessionId));
var current = await db.CurrentAnswers.SingleAsync(item =>
item.PracticeSessionId == answerable.SessionId &&
item.SessionQuestionId == answerable.SessionQuestionId);