feat(serial-code): add SerialCode + SerialCodeReward + ViewerSerialCodeRedemption entities
Three new EF entities for /campaign/regist_serial_code: SerialCodeEntry (code, message, window, enabled flag), SerialCodeRewardEntry (FK child, per-slot reward), and ViewerSerialCodeRedemption (composite-PK redemption record). Registered in SVSimDbContext with unique index on Code and cascade FK constraints. 3/3 persistence tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
27
SVSim.Database/Models/SerialCodeEntry.cs
Normal file
27
SVSim.Database/Models/SerialCodeEntry.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using SVSim.Database.Common;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level entity for a promotional serial code. Admin inserts these directly via SQL;
|
||||||
|
/// there is no JSON seed or admin endpoint. Case-sensitive match on <see cref="Code"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class SerialCodeEntry : BaseEntity<int>
|
||||||
|
{
|
||||||
|
/// <summary>User-typed code. Case-sensitive; unique index enforces no duplicates.</summary>
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Player-facing mail body, copied onto every <c>ViewerPresent</c> created at redemption.</summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>When the code becomes valid. NULL = always valid from creation.</summary>
|
||||||
|
public DateTime? StartAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the code expires. NULL = never expires.</summary>
|
||||||
|
public DateTime? EndAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Admin kill-switch. False = treat as if it doesn't exist.</summary>
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
|
||||||
|
public List<SerialCodeRewardEntry> Rewards { get; set; } = new List<SerialCodeRewardEntry>();
|
||||||
|
}
|
||||||
24
SVSim.Database/Models/SerialCodeRewardEntry.cs
Normal file
24
SVSim.Database/Models/SerialCodeRewardEntry.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using SVSim.Database.Common;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One reward slot belonging to a <see cref="SerialCodeEntry"/>. On redemption each row
|
||||||
|
/// becomes one <see cref="ViewerPresent"/> in the player's gift inbox.
|
||||||
|
/// </summary>
|
||||||
|
public class SerialCodeRewardEntry : BaseEntity<int>
|
||||||
|
{
|
||||||
|
public int SerialCodeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>0-based ordering within the code's rewards.</summary>
|
||||||
|
public int Slot { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UserGoodsType cast to int (matches the wire convention used elsewhere).</summary>
|
||||||
|
public int RewardType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Detail id for the goods. 0 for wallet currencies.</summary>
|
||||||
|
public long RewardDetailId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Positive integer count.</summary>
|
||||||
|
public int RewardCount { get; set; }
|
||||||
|
}
|
||||||
13
SVSim.Database/Models/ViewerSerialCodeRedemption.cs
Normal file
13
SVSim.Database/Models/ViewerSerialCodeRedemption.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per (viewer, code) redemption. Composite PK on <c>(ViewerId, SerialCodeId)</c>
|
||||||
|
/// enforces the single-use-per-viewer guarantee at the DB layer; the controller catches
|
||||||
|
/// the unique-constraint violation as a race-condition backstop.
|
||||||
|
/// </summary>
|
||||||
|
public class ViewerSerialCodeRedemption
|
||||||
|
{
|
||||||
|
public long ViewerId { get; set; }
|
||||||
|
public int SerialCodeId { get; set; }
|
||||||
|
public DateTime RedeemedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -108,6 +108,10 @@ public class SVSimDbContext : DbContext
|
|||||||
public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { get; set; } = null!;
|
public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { get; set; } = null!;
|
||||||
public DbSet<ViewerArenaTwoPickRun> ViewerArenaTwoPickRuns { get; set; } = null!;
|
public DbSet<ViewerArenaTwoPickRun> ViewerArenaTwoPickRuns { get; set; } = null!;
|
||||||
|
|
||||||
|
public DbSet<SerialCodeEntry> SerialCodes => Set<SerialCodeEntry>();
|
||||||
|
public DbSet<SerialCodeRewardEntry> SerialCodeRewards => Set<SerialCodeRewardEntry>();
|
||||||
|
public DbSet<ViewerSerialCodeRedemption> ViewerSerialCodeRedemptions => Set<ViewerSerialCodeRedemption>();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
@@ -418,6 +422,39 @@ public class SVSimDbContext : DbContext
|
|||||||
b.HasIndex(e => new { e.ViewerId, e.AcquireTime, e.Id });
|
b.HasIndex(e => new { e.ViewerId, e.AcquireTime, e.Id });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<SerialCodeEntry>(b =>
|
||||||
|
{
|
||||||
|
b.HasKey(e => e.Id);
|
||||||
|
b.Property(e => e.Id).ValueGeneratedOnAdd();
|
||||||
|
b.Property(e => e.Code).HasMaxLength(64).IsRequired();
|
||||||
|
b.Property(e => e.Message).HasMaxLength(255);
|
||||||
|
b.HasIndex(e => e.Code).IsUnique();
|
||||||
|
b.HasMany(e => e.Rewards)
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey(r => r.SerialCodeId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<SerialCodeRewardEntry>(b =>
|
||||||
|
{
|
||||||
|
b.HasKey(e => e.Id);
|
||||||
|
b.Property(e => e.Id).ValueGeneratedOnAdd();
|
||||||
|
b.HasIndex(e => new { e.SerialCodeId, e.Slot });
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ViewerSerialCodeRedemption>(b =>
|
||||||
|
{
|
||||||
|
b.HasKey(e => new { e.ViewerId, e.SerialCodeId });
|
||||||
|
b.HasOne<Viewer>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.ViewerId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasOne<SerialCodeEntry>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.SerialCodeId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
92
SVSim.UnitTests/Persistence/SerialCodePersistenceTests.cs
Normal file
92
SVSim.UnitTests/Persistence/SerialCodePersistenceTests.cs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Persistence;
|
||||||
|
|
||||||
|
public class SerialCodePersistenceTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task SerialCode_round_trips_with_rewards()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
using (var seedScope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var ctx = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
ctx.SerialCodes.Add(new SerialCodeEntry
|
||||||
|
{
|
||||||
|
Code = "ABCD-1234",
|
||||||
|
Message = "Test code",
|
||||||
|
IsEnabled = true,
|
||||||
|
Rewards =
|
||||||
|
{
|
||||||
|
new SerialCodeRewardEntry { Slot = 0, RewardType = 1, RewardDetailId = 0, RewardCount = 100 },
|
||||||
|
new SerialCodeRewardEntry { Slot = 1, RewardType = 9, RewardDetailId = 0, RewardCount = 500 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verifyScope = factory.Services.CreateScope();
|
||||||
|
var ctx2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var code = await ctx2.SerialCodes
|
||||||
|
.Include(c => c.Rewards.OrderBy(r => r.Slot))
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstAsync(c => c.Code == "ABCD-1234");
|
||||||
|
|
||||||
|
Assert.That(code.Message, Is.EqualTo("Test code"));
|
||||||
|
Assert.That(code.IsEnabled, Is.True);
|
||||||
|
Assert.That(code.Rewards, Has.Count.EqualTo(2));
|
||||||
|
Assert.That(code.Rewards[0].RewardCount, Is.EqualTo(100));
|
||||||
|
Assert.That(code.Rewards[1].RewardCount, Is.EqualTo(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Unique_constraint_on_Code_rejects_duplicates()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
ctx.SerialCodes.Add(new SerialCodeEntry { Code = "DUP", Message = "first", IsEnabled = true });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
ctx.SerialCodes.Add(new SerialCodeEntry { Code = "DUP", Message = "second", IsEnabled = true });
|
||||||
|
Assert.That(async () => await ctx.SaveChangesAsync(), Throws.Exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Composite_PK_on_redemption_rejects_double_redeem()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
|
||||||
|
int codeId;
|
||||||
|
using (var seedScope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var ctx = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var code = new SerialCodeEntry { Code = "ONCE", Message = "single use", IsEnabled = true };
|
||||||
|
ctx.SerialCodes.Add(code);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
codeId = code.Id;
|
||||||
|
|
||||||
|
ctx.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption
|
||||||
|
{
|
||||||
|
ViewerId = viewerId, SerialCodeId = codeId, RedeemedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second redemption attempt in a fresh scope so the change tracker doesn't intercept
|
||||||
|
// before the DB constraint fires.
|
||||||
|
using var dupeScope = factory.Services.CreateScope();
|
||||||
|
var ctx2 = dupeScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
ctx2.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption
|
||||||
|
{
|
||||||
|
ViewerId = viewerId, SerialCodeId = codeId, RedeemedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
Assert.That(async () => await ctx2.SaveChangesAsync(), Throws.Exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user