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:
@@ -131,15 +131,19 @@ public sealed class GuildChatController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("replay_detail")]
|
||||
public async Task<ActionResult<GuildChatReplayDetailResponse>> ReplayDetail([FromBody] GuildChatReplayDetailRequest req, CancellationToken ct)
|
||||
public async Task<ActionResult> ReplayDetail([FromBody] GuildChatReplayDetailRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
|
||||
var payloadJson = await _chat.GetReplayDetailAsync(viewerId, (int)req.MessageId, ct);
|
||||
if (payloadJson is null) return Ok(new { result_code = 2 });
|
||||
|
||||
var replayInfo = ParseJsonElementOrNull(payloadJson);
|
||||
return new GuildChatReplayDetailResponse { ReplayInfo = replayInfo };
|
||||
// ReplayDetailInfo(data) accesses data["battleId"], data["seed"], data["vid1"], etc.
|
||||
// directly without Keys.Contains guards — returning a wrapper object crashes the client.
|
||||
// The stored ReplayPayload IS the full flat battle object; emit its fields directly as
|
||||
// the data payload so data["battleId"].ToLong() etc. resolve correctly.
|
||||
var replayElement = ParseJsonElementOrNull(payloadJson);
|
||||
return replayElement.HasValue ? Ok(replayElement.Value) : Ok(new { result_code = 2 });
|
||||
}
|
||||
|
||||
[HttpPost("deck_log")]
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Entities.Guild;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Guild;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Friend;
|
||||
using SVSim.Database.Services.Guild;
|
||||
@@ -20,17 +19,24 @@ public sealed class GuildController : SVSimController
|
||||
{
|
||||
private readonly IGuildService _guild;
|
||||
private readonly IGameConfigService _configs;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IViewerRepository _viewers;
|
||||
private readonly IGuildMemberRepository _members;
|
||||
private readonly IFriendService _friends;
|
||||
|
||||
// Wire error code returned when guild operations fail (non-1 result_code).
|
||||
private const int GuildErrorResultCode = 2;
|
||||
|
||||
public GuildController(IGuildService guild, IGameConfigService configs, SVSimDbContext db, IFriendService friends)
|
||||
public GuildController(
|
||||
IGuildService guild,
|
||||
IGameConfigService configs,
|
||||
IViewerRepository viewers,
|
||||
IGuildMemberRepository members,
|
||||
IFriendService friends)
|
||||
{
|
||||
_guild = guild;
|
||||
_configs = configs;
|
||||
_db = db;
|
||||
_viewers = viewers;
|
||||
_members = members;
|
||||
_friends = friends;
|
||||
}
|
||||
|
||||
@@ -56,7 +62,7 @@ public sealed class GuildController : SVSimController
|
||||
resp.JoinRequestCount = view.JoinRequestCount;
|
||||
resp.InviteCount = view.InviteCount;
|
||||
var memberDtos = await ToMemberDtoListAsync(view.Members, viewerId, ct);
|
||||
var leaderName = memberDtos.FirstOrDefault(m => m.ViewerId == view.Guild.LeaderViewerId)?.Name ?? "";
|
||||
var leaderName = memberDtos.FirstOrDefault(m => m.ViewerId == (view.Guild.LeaderViewerId ?? 0L))?.Name ?? "";
|
||||
resp.Guild = new GuildBundle
|
||||
{
|
||||
Detail = ToDetailDto(view.Guild, view.Members.Count, leaderName),
|
||||
@@ -95,14 +101,17 @@ public sealed class GuildController : SVSimController
|
||||
var r = await _guild.UpdateAsync(viewerId, new(activity, joinCondition, name), ct);
|
||||
if (!r.IsOk) return WireError();
|
||||
|
||||
var m = await _db.GuildMembers.FirstOrDefaultAsync(m => m.ViewerId == viewerId, ct);
|
||||
var m = await _members.GetMembershipAsync(viewerId, ct);
|
||||
if (m is null) return WireError();
|
||||
var guild = await _guild.GetActiveAsync(m.GuildId, ct);
|
||||
if (guild is null) return WireError();
|
||||
var memberCount = await _db.GuildMembers.CountAsync(x => x.GuildId == guild.GuildId, ct);
|
||||
var leader = await _db.Viewers.AsNoTracking().FirstOrDefaultAsync(v => v.Id == guild.LeaderViewerId, ct);
|
||||
var memberCount = await _members.CountByGuildAsync(guild.GuildId, ct);
|
||||
var leaderNames = guild.LeaderViewerId.HasValue
|
||||
? await _viewers.LoadDisplayNamesAsync(new[] { guild.LeaderViewerId.Value }, ct)
|
||||
: new Dictionary<long, string>();
|
||||
var leaderName = leaderNames.GetValueOrDefault(guild.LeaderViewerId ?? 0L, "");
|
||||
// GuildUpdateTask.Parse() reads data["guild"] directly as GuildDetailInfo — flat, no "detail" wrapper.
|
||||
return new GuildUpdateResponse { Guild = ToDetailDto(guild, memberCount, leader?.DisplayName ?? "") };
|
||||
return new GuildUpdateResponse { Guild = ToDetailDto(guild, memberCount, leaderName) };
|
||||
}
|
||||
|
||||
[HttpPost("update_description")]
|
||||
@@ -120,16 +129,19 @@ public sealed class GuildController : SVSimController
|
||||
var r = await _guild.UpdateEmblemAsync(viewerId, req.EmblemId, ct);
|
||||
if (!r.IsOk) return WireError();
|
||||
|
||||
var m = await _db.GuildMembers.FirstOrDefaultAsync(m => m.ViewerId == viewerId, ct);
|
||||
var m = await _members.GetMembershipAsync(viewerId, ct);
|
||||
if (m is null) return WireError();
|
||||
var guild = await _guild.GetActiveAsync(m.GuildId, ct);
|
||||
if (guild is null) return WireError();
|
||||
var memberCount = await _db.GuildMembers.CountAsync(x => x.GuildId == guild.GuildId, ct);
|
||||
var leader = await _db.Viewers.AsNoTracking().FirstOrDefaultAsync(v => v.Id == guild.LeaderViewerId, ct);
|
||||
var memberCount = await _members.CountByGuildAsync(guild.GuildId, ct);
|
||||
var leaderNames2 = guild.LeaderViewerId.HasValue
|
||||
? await _viewers.LoadDisplayNamesAsync(new[] { guild.LeaderViewerId.Value }, ct)
|
||||
: new Dictionary<long, string>();
|
||||
var leaderName2 = leaderNames2.GetValueOrDefault(guild.LeaderViewerId ?? 0L, "");
|
||||
// GuildEmblemUpdateTask.Parse() reads data["guild"]["detail"] — nested wrapper required.
|
||||
return new GuildUpdateEmblemResponse
|
||||
{
|
||||
Guild = new GuildDetailSubTree { Detail = ToDetailDto(guild, memberCount, leader?.DisplayName ?? "") }
|
||||
Guild = new GuildDetailSubTree { Detail = ToDetailDto(guild, memberCount, leaderName2) }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,14 +157,10 @@ public sealed class GuildController : SVSimController
|
||||
public async Task<ActionResult<GuildEmblemListResponse>> EmblemList([FromBody] BaseRequest _, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
var viewer = await _db.Viewers
|
||||
.AsNoTracking()
|
||||
.Include(v => v.Emblems)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
||||
var emblems = viewer?.Emblems ?? new();
|
||||
var emblemIds = await _viewers.GetEmblemListAsync(viewerId, ct);
|
||||
return new GuildEmblemListResponse
|
||||
{
|
||||
EmblemList = emblems.Select(e => new GuildEmblemEntry { EmblemId = e.Id }).ToList()
|
||||
EmblemList = emblemIds.Select(id => new GuildEmblemEntry { EmblemId = id }).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,13 +170,16 @@ public sealed class GuildController : SVSimController
|
||||
if (!TryGetViewerId(out _)) return Unauthorized();
|
||||
var guild = await _guild.GetActiveAsync(req.GuildId, ct);
|
||||
if (guild is null) return new GuildOthersInfoResponse();
|
||||
var memberCount = await _db.GuildMembers.CountAsync(m => m.GuildId == guild.GuildId, ct);
|
||||
var leader = await _db.Viewers.AsNoTracking().FirstOrDefaultAsync(v => v.Id == guild.LeaderViewerId, ct);
|
||||
var memberCount = await _members.CountByGuildAsync(guild.GuildId, ct);
|
||||
var othersLeaderNames = guild.LeaderViewerId.HasValue
|
||||
? await _viewers.LoadDisplayNamesAsync(new[] { guild.LeaderViewerId.Value }, ct)
|
||||
: new Dictionary<long, string>();
|
||||
var othersLeaderName = othersLeaderNames.GetValueOrDefault(guild.LeaderViewerId ?? 0L, "");
|
||||
return new GuildOthersInfoResponse
|
||||
{
|
||||
Guild = new GuildDetailSubTree
|
||||
{
|
||||
Detail = ToDetailDto(guild, memberCount, leader?.DisplayName ?? "")
|
||||
Detail = ToDetailDto(guild, memberCount, othersLeaderName)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -184,16 +195,11 @@ public sealed class GuildController : SVSimController
|
||||
|
||||
// For each friend, determine if they are already in a guild (is_join_guild).
|
||||
var friendViewerIds = friendInfo.Friends.Select(f => (long)f.ViewerId).ToList();
|
||||
var viewerRows = await _db.Viewers
|
||||
.AsNoTracking()
|
||||
.Where(v => friendViewerIds.Contains(v.Id))
|
||||
.Select(v => new { v.Id, v.GuildId })
|
||||
.ToDictionaryAsync(v => v.Id, v => v.GuildId, ct);
|
||||
var inGuild = await _members.GetViewerIdsInAGuildAsync(friendViewerIds, ct);
|
||||
|
||||
var candidates = new List<GuildInviteCandidateDto>(friendInfo.Friends.Count);
|
||||
foreach (var f in friendInfo.Friends)
|
||||
{
|
||||
viewerRows.TryGetValue(f.ViewerId, out var guildId);
|
||||
candidates.Add(new GuildInviteCandidateDto
|
||||
{
|
||||
ViewerId = f.ViewerId,
|
||||
@@ -202,7 +208,7 @@ public sealed class GuildController : SVSimController
|
||||
CountryCode = f.CountryCode,
|
||||
Rank = f.Rank,
|
||||
DegreeId = f.DegreeId,
|
||||
IsJoinGuild = guildId is not null,
|
||||
IsJoinGuild = inGuild.Contains(f.ViewerId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,7 +253,7 @@ public sealed class GuildController : SVSimController
|
||||
Activity = (int)e.Guild.Activity,
|
||||
MemberNum = e.MemberNum,
|
||||
LeaderName = e.LeaderName,
|
||||
LeaderViewerId = e.Guild.LeaderViewerId,
|
||||
LeaderViewerId = e.Guild.LeaderViewerId ?? 0L,
|
||||
InviteId = e.InviteId,
|
||||
}).ToList(),
|
||||
};
|
||||
@@ -357,12 +363,10 @@ public sealed class GuildController : SVSimController
|
||||
if (!r.IsOk) return WireError();
|
||||
|
||||
// Load caller's membership to get the guild id, then return the full updated member list.
|
||||
var membership = await _db.GuildMembers.FirstOrDefaultAsync(m => m.ViewerId == viewerId, ct);
|
||||
var membership = await _members.GetMembershipAsync(viewerId, ct);
|
||||
if (membership is null) return WireError();
|
||||
|
||||
var members = await _db.GuildMembers
|
||||
.Where(m => m.GuildId == membership.GuildId)
|
||||
.ToListAsync(ct);
|
||||
var members = await _members.ListByGuildAsync(membership.GuildId, ct);
|
||||
|
||||
var memberDtos = await ToMemberDtoListAsync(members, viewerId, ct);
|
||||
return new GuildChangeRoleResponse { Members = memberDtos };
|
||||
@@ -379,7 +383,7 @@ public sealed class GuildController : SVSimController
|
||||
JoinCondition = (int)guild.JoinCondition,
|
||||
Activity = (int)guild.Activity,
|
||||
MemberNum = memberCount,
|
||||
LeaderViewerId = guild.LeaderViewerId,
|
||||
LeaderViewerId = guild.LeaderViewerId ?? 0L,
|
||||
LeaderName = leaderName,
|
||||
};
|
||||
|
||||
@@ -391,33 +395,26 @@ public sealed class GuildController : SVSimController
|
||||
if (members.Count == 0) return new();
|
||||
|
||||
var viewerIds = members.Select(m => m.ViewerId).ToList();
|
||||
|
||||
// Batch-load viewer rows with Info + SelectedEmblem + SelectedDegree.
|
||||
var viewers = await _db.Viewers
|
||||
.AsNoTracking()
|
||||
.Include(v => v.Info.SelectedEmblem)
|
||||
.Include(v => v.Info.SelectedDegree)
|
||||
.Where(v => viewerIds.Contains(v.Id))
|
||||
.ToDictionaryAsync(v => v.Id, ct);
|
||||
var profiles = await _viewers.LoadGuildProfileBatchAsync(viewerIds, ct);
|
||||
|
||||
var result = new List<GuildMemberInfoDto>(members.Count);
|
||||
foreach (var m in members)
|
||||
{
|
||||
viewers.TryGetValue(m.ViewerId, out var v);
|
||||
result.Add(ToMemberDto(m, v));
|
||||
profiles.TryGetValue(m.ViewerId, out var p);
|
||||
result.Add(ToMemberDto(m, p));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static GuildMemberInfoDto ToMemberDto(GuildMember member, Viewer? viewer) => new()
|
||||
private static GuildMemberInfoDto ToMemberDto(GuildMember member, GuildMemberProfile? profile) => new()
|
||||
{
|
||||
ViewerId = member.ViewerId,
|
||||
Name = viewer?.DisplayName ?? "",
|
||||
EmblemId = viewer?.Info?.SelectedEmblem?.Id is > 0 ? viewer.Info.SelectedEmblem.Id : 100_000_000L,
|
||||
CountryCode = viewer?.Info?.CountryCode ?? "",
|
||||
Rank = 1, // TODO: populate from actual rank data when rank tracking lands
|
||||
DegreeId = viewer?.Info?.SelectedDegree?.Id ?? 0,
|
||||
IsOfficialMarkDisplayed = viewer?.Info?.IsOfficialMarkDisplayed == true ? 1 : 0,
|
||||
Name = profile?.Name ?? "",
|
||||
EmblemId = profile?.EmblemId ?? 100_000_000L,
|
||||
CountryCode = profile?.CountryCode ?? "",
|
||||
Rank = profile?.Rank ?? 1,
|
||||
DegreeId = profile?.DegreeId ?? 0,
|
||||
IsOfficialMarkDisplayed = profile?.IsOfficialMarkDisplayed == true ? 1 : 0,
|
||||
Role = (int)member.Role,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user