fix(guild): final-review remediation — replay_detail flat, FK on leader, transaction wraps, _db extraction

C1: replay_detail — flatten stored payload to data level. ChatReplayDetailTask.Parse() calls
new ReplayDetailInfo(data) which unguardedly accesses data[battleId], data[seed], data[vid1],
etc. Wrapping under replay_info key crashes the client. Controller now returns Ok(JsonElement)
directly so stored battle fields are at data root. Wire-shape test added.

C2: Guild.LeaderViewerId long to long?; add HasOne<Viewer> FK with OnDelete=SetNull; migration
AddGuildLeaderViewerIdFk; all consumers null-guarded with ?? 0L.

C3: BreakupAsync — wrap 6 destructive ops in IDbContextTransaction with InMemory fallback.

C4: CommitJoinAsync — wrap member-add + viewer-guildId-set + invite/request cleanup in
IDbContextTransaction with InMemory fallback. Chat event emitted after commit.

I1: GuildController — remove SVSimDbContext field; inject IViewerRepository + IGuildMemberRepository.
Add IGuildMemberRepository.GetViewerIdsInAGuildAsync (batch guild-membership check).
All _db.Viewers / _db.GuildMembers queries replaced with repo calls.

I2: GuildService — extract 3 _db.Viewers queries: GetEquippedEmblemIdAsync (CreateAsync),
LoadGuildProfileBatchAsync (ListOutgoingInvitesAsync + ListPendingJoinRequestsForMyGuildAsync).
Add GuildMemberProfile record with IsOfficialMarkDisplayed. GetEmblemListAsync for EmblemList.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-27 16:33:59 -04:00
parent 754b2ca466
commit ff77d5e5b6
14 changed files with 5378 additions and 136 deletions

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using SVSim.Database.Entities.Guild;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Guild;
@@ -67,9 +68,9 @@ public sealed class GuildService : IGuildService
var rows = await _guilds.SearchAsync(name ?? "", activity, joinCondition, memberBucket, cfg.MaxMemberNum, cfg.SearchResultCap, ct);
var guildIds = rows.Select(r => r.GuildId).ToList();
var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct);
var leaderIds = rows.Select(r => r.LeaderViewerId).Distinct().ToList();
var leaderIds = rows.Select(r => r.LeaderViewerId).Where(id => id.HasValue).Select(id => id!.Value).Distinct().ToList();
var leaderNames = await _viewers.LoadDisplayNamesAsync(leaderIds, ct);
return rows.Select(r => new GuildSearchEntry(r, memberCounts.GetValueOrDefault(r.GuildId, 0), leaderNames.GetValueOrDefault(r.LeaderViewerId, ""))).ToList();
return rows.Select(r => new GuildSearchEntry(r, memberCounts.GetValueOrDefault(r.GuildId, 0), leaderNames.GetValueOrDefault(r.LeaderViewerId ?? 0L, ""))).ToList();
}
public async Task<GuildOpResult> CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default)
@@ -86,10 +87,9 @@ public sealed class GuildService : IGuildService
if (await _guilds.NameExistsAsync(req.Name, ct))
return new(GuildOpResultCode.NameTaken);
var viewer = await _db.Viewers
.Include(v => v.Info.SelectedEmblem)
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
if (viewer is null) return new(GuildOpResultCode.PermissionDenied);
// Load the viewer's equipped emblem to use as the guild's initial emblem.
// If the viewer doesn't exist (shouldn't happen post-auth), returns 100_000_000 default.
long viewerEmblemId = await _viewers.GetEquippedEmblemIdAsync(viewerId, ct);
var id = await _idGen.NextAsync(
(cand, c) => _guilds.GetByIdAsync(cand, c).ContinueWith(t => t.Result is not null, c),
@@ -101,7 +101,7 @@ public sealed class GuildService : IGuildService
Name = req.Name,
Description = "",
LeaderViewerId = viewerId,
EmblemId = DefaultEmblemId(viewer),
EmblemId = viewerEmblemId,
Activity = (GuildActivity)req.Activity,
JoinCondition = (GuildJoinCondition)req.JoinCondition,
CreatedAt = now,
@@ -116,22 +116,12 @@ public sealed class GuildService : IGuildService
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct);
// Update Viewer.GuildId pointer for quick lookups.
viewer.GuildId = id;
await _db.SaveChangesAsync(ct);
await _viewers.SetGuildIdAsync(viewerId, id, ct);
await _chat.EmitSystemEventAsync(id, viewerId, GuildChatMessageType.CreateGuild, body: null, ct);
return new(GuildOpResultCode.Ok, GuildId: id);
}
/// <summary>
/// Returns the viewer's currently-equipped emblem id, or 100000000 (default) if none.
/// </summary>
private static long DefaultEmblemId(Models.Viewer viewer)
{
var emblemId = viewer.Info?.SelectedEmblem?.Id;
return emblemId is > 0 ? emblemId.Value : 100_000_000L;
}
public async Task<GuildOpResult> UpdateAsync(long viewerId, UpdateGuildRequest req, CancellationToken ct = default)
{
var m = await _members.GetMembershipAsync(viewerId, ct);
@@ -177,17 +167,33 @@ public sealed class GuildService : IGuildService
if (membership is null) return new(GuildOpResultCode.NotInGuild);
if (membership.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
var now = DateTime.UtcNow;
var members = await _members.ListByGuildAsync(membership.GuildId, ct);
foreach (var m in members) await _viewers.ClearGuildIdAsync(m.ViewerId, ct);
IDbContextTransaction? tx = null;
try
{
try { tx = await _db.Database.BeginTransactionAsync(ct); } catch (InvalidOperationException) { /* InMemory provider */ }
await _chatMessages.DeleteAllForGuildAsync(membership.GuildId, ct);
await _invites.DeleteAllForGuildAsync(membership.GuildId, ct);
await _joinRequests.DeleteAllForGuildAsync(membership.GuildId, ct);
await _members.DeleteAllForGuildAsync(membership.GuildId, ct);
await _guilds.MarkBrokenUpAsync(membership.GuildId, now, ct);
var now = DateTime.UtcNow;
var members = await _members.ListByGuildAsync(membership.GuildId, ct);
foreach (var m in members) await _viewers.ClearGuildIdAsync(m.ViewerId, ct);
return GuildOpResult.Ok;
await _chatMessages.DeleteAllForGuildAsync(membership.GuildId, ct);
await _invites.DeleteAllForGuildAsync(membership.GuildId, ct);
await _joinRequests.DeleteAllForGuildAsync(membership.GuildId, ct);
await _members.DeleteAllForGuildAsync(membership.GuildId, ct);
await _guilds.MarkBrokenUpAsync(membership.GuildId, now, ct);
if (tx != null) await tx.CommitAsync(ct);
return GuildOpResult.Ok;
}
catch
{
if (tx != null) await tx.RollbackAsync(ct);
throw;
}
finally
{
tx?.Dispose();
}
}
public async Task<GuildOpResult> InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
@@ -305,12 +311,31 @@ public sealed class GuildService : IGuildService
private async Task CommitJoinAsync(long viewerId, int guildId, DateTime now, CancellationToken ct)
{
await _members.AddAsync(
new GuildMember { GuildId = guildId, ViewerId = viewerId, Role = GuildRole.Regular, JoinedAt = now },
ct);
await _viewers.SetGuildIdAsync(viewerId, guildId, ct);
await _invites.ConsumePendingForViewerAsync(viewerId, now, ct);
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct);
IDbContextTransaction? tx = null;
try
{
try { tx = await _db.Database.BeginTransactionAsync(ct); } catch (InvalidOperationException) { /* InMemory provider */ }
await _members.AddAsync(
new GuildMember { GuildId = guildId, ViewerId = viewerId, Role = GuildRole.Regular, JoinedAt = now },
ct);
await _viewers.SetGuildIdAsync(viewerId, guildId, ct);
await _invites.ConsumePendingForViewerAsync(viewerId, now, ct);
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct);
if (tx != null) await tx.CommitAsync(ct);
}
catch
{
if (tx != null) await tx.RollbackAsync(ct);
throw;
}
finally
{
tx?.Dispose();
}
// Emit chat event outside the transaction — chat failure doesn't roll back the join.
await _chat.EmitSystemEventAsync(guildId, viewerId, GuildChatMessageType.Join, body: null, ct);
}
@@ -470,28 +495,20 @@ public sealed class GuildService : IGuildService
if (pendingInvites.Count == 0) return Array.Empty<GuildOutgoingInviteEntry>();
var inviteeIds = pendingInvites.Select(i => i.InviteeViewerId).Distinct().ToList();
var names = await _viewers.LoadDisplayNamesAsync(inviteeIds, ct);
// Load emblem + degree data for invitees.
var viewerRows = await _db.Viewers
.AsNoTracking()
.Include(v => v.Info.SelectedEmblem)
.Include(v => v.Info.SelectedDegree)
.Where(v => inviteeIds.Contains(v.Id))
.ToDictionaryAsync(v => v.Id, ct);
var profiles = await _viewers.LoadGuildProfileBatchAsync(inviteeIds, ct);
var result = new List<GuildOutgoingInviteEntry>(pendingInvites.Count);
foreach (var invite in pendingInvites)
{
viewerRows.TryGetValue(invite.InviteeViewerId, out var v);
profiles.TryGetValue(invite.InviteeViewerId, out var p);
result.Add(new GuildOutgoingInviteEntry(
InviteId: invite.Id,
InviteeViewerId: invite.InviteeViewerId,
Name: names.GetValueOrDefault(invite.InviteeViewerId, ""),
EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L,
CountryCode: v?.Info?.CountryCode ?? "",
Rank: 1,
DegreeId: v?.Info?.SelectedDegree?.Id ?? 0,
Name: p?.Name ?? "",
EmblemId: p?.EmblemId ?? 100_000_000L,
CountryCode: p?.CountryCode ?? "",
Rank: p?.Rank ?? 1,
DegreeId: p?.DegreeId ?? 0,
CreatedAt: invite.CreatedAt));
}
return result;
@@ -505,14 +522,14 @@ public sealed class GuildService : IGuildService
var guildIds = pendingInvites.Select(i => i.GuildId).Distinct().ToList();
var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct);
var leaderIds = pendingInvites.Select(i => i.Guild.LeaderViewerId).Distinct().ToList();
var leaderIds = pendingInvites.Select(i => i.Guild.LeaderViewerId).Where(id => id.HasValue).Select(id => id!.Value).Distinct().ToList();
var leaderNames = await _viewers.LoadDisplayNamesAsync(leaderIds, ct);
return pendingInvites.Select(invite => new GuildReceivedInviteEntry(
InviteId: invite.Id,
Guild: invite.Guild,
MemberNum: memberCounts.GetValueOrDefault(invite.GuildId, 0),
LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId, ""))).ToList();
LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId ?? 0L, ""))).ToList();
}
public async Task<IReadOnlyList<GuildJoinRequestEntry>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
@@ -527,26 +544,19 @@ public sealed class GuildService : IGuildService
if (requests.Count == 0) return Array.Empty<GuildJoinRequestEntry>();
var applicantIds = requests.Select(r => r.ViewerId).Distinct().ToList();
var names = await _viewers.LoadDisplayNamesAsync(applicantIds, ct);
var viewerRows = await _db.Viewers
.AsNoTracking()
.Include(v => v.Info.SelectedEmblem)
.Include(v => v.Info.SelectedDegree)
.Where(v => applicantIds.Contains(v.Id))
.ToDictionaryAsync(v => v.Id, ct);
var profiles = await _viewers.LoadGuildProfileBatchAsync(applicantIds, ct);
return requests.Select(r =>
{
viewerRows.TryGetValue(r.ViewerId, out var v);
profiles.TryGetValue(r.ViewerId, out var p);
return new GuildJoinRequestEntry(
ApplicantViewerId: r.ViewerId,
Name: names.GetValueOrDefault(r.ViewerId, ""),
EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L,
CountryCode: v?.Info?.CountryCode ?? "",
Rank: 1,
DegreeId: v?.Info?.SelectedDegree?.Id ?? 0,
IsOfficialMarkDisplayed: v?.Info?.IsOfficialMarkDisplayed == true,
Name: p?.Name ?? "",
EmblemId: p?.EmblemId ?? 100_000_000L,
CountryCode: p?.CountryCode ?? "",
Rank: p?.Rank ?? 1,
DegreeId: p?.DegreeId ?? 0,
IsOfficialMarkDisplayed: p?.IsOfficialMarkDisplayed ?? false,
RequestedAt: r.CreatedAt);
}).ToList();
}