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:
gamer147
2026-06-09 18:42:10 -04:00
parent b117fe825c
commit 206be77a86
5 changed files with 193 additions and 0 deletions

View 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>();
}

View 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; }
}

View 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; }
}

View File

@@ -108,6 +108,10 @@ public class SVSimDbContext : DbContext
public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { 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
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 });
});
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);
}