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:
@@ -7,7 +7,7 @@ public class Guild
|
|||||||
public int GuildId { get; set; } // server-generated 9-digit
|
public int GuildId { get; set; } // server-generated 9-digit
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public string Description { get; set; } = "";
|
public string Description { get; set; } = "";
|
||||||
public long LeaderViewerId { get; set; }
|
public long? LeaderViewerId { get; set; }
|
||||||
public long EmblemId { get; set; }
|
public long EmblemId { get; set; }
|
||||||
public GuildActivity Activity { get; set; }
|
public GuildActivity Activity { get; set; }
|
||||||
public GuildJoinCondition JoinCondition { get; set; }
|
public GuildJoinCondition JoinCondition { get; set; }
|
||||||
|
|||||||
5018
SVSim.Database/Migrations/20260627202442_AddGuildLeaderViewerIdFk.Designer.cs
generated
Normal file
5018
SVSim.Database/Migrations/20260627202442_AddGuildLeaderViewerIdFk.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SVSim.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGuildLeaderViewerIdFk : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<long>(
|
||||||
|
name: "LeaderViewerId",
|
||||||
|
table: "Guilds",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(long),
|
||||||
|
oldType: "bigint");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Guilds_LeaderViewerId",
|
||||||
|
table: "Guilds",
|
||||||
|
column: "LeaderViewerId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Guilds_Viewers_LeaderViewerId",
|
||||||
|
table: "Guilds",
|
||||||
|
column: "LeaderViewerId",
|
||||||
|
principalTable: "Viewers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Guilds_Viewers_LeaderViewerId",
|
||||||
|
table: "Guilds");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Guilds_LeaderViewerId",
|
||||||
|
table: "Guilds");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<long>(
|
||||||
|
name: "LeaderViewerId",
|
||||||
|
table: "Guilds",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0L,
|
||||||
|
oldClrType: typeof(long),
|
||||||
|
oldType: "bigint",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,7 +110,7 @@ namespace SVSim.Database.Migrations
|
|||||||
b.Property<int>("JoinCondition")
|
b.Property<int>("JoinCondition")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<long>("LeaderViewerId")
|
b.Property<long?>("LeaderViewerId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
@@ -120,6 +120,8 @@ namespace SVSim.Database.Migrations
|
|||||||
|
|
||||||
b.HasKey("GuildId");
|
b.HasKey("GuildId");
|
||||||
|
|
||||||
|
b.HasIndex("LeaderViewerId");
|
||||||
|
|
||||||
b.HasIndex("Name");
|
b.HasIndex("Name");
|
||||||
|
|
||||||
b.ToTable("Guilds");
|
b.ToTable("Guilds");
|
||||||
@@ -3653,6 +3655,14 @@ namespace SVSim.Database.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Entities.Guild.Guild", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SVSim.Database.Models.Viewer", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LeaderViewerId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Entities.Guild.GuildChatMessage", b =>
|
modelBuilder.Entity("SVSim.Database.Entities.Guild.GuildChatMessage", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SVSim.Database.Entities.Guild.Guild", "Guild")
|
b.HasOne("SVSim.Database.Entities.Guild.Guild", "Guild")
|
||||||
|
|||||||
@@ -70,4 +70,15 @@ public sealed class GuildMemberRepository : IGuildMemberRepository
|
|||||||
result[row.GuildId] = row.Count;
|
result[row.GuildId] = row.Count;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<HashSet<long>> GetViewerIdsInAGuildAsync(
|
||||||
|
IReadOnlyList<long> viewerIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (viewerIds.Count == 0) return new HashSet<long>();
|
||||||
|
var ids = await _db.GuildMembers
|
||||||
|
.Where(m => viewerIds.Contains(m.ViewerId))
|
||||||
|
.Select(m => m.ViewerId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return new HashSet<long>(ids);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,11 @@ public interface IGuildMemberRepository
|
|||||||
|
|
||||||
/// <summary>Returns a count of members per guild for the given guild ids. Used by Task 9 search.</summary>
|
/// <summary>Returns a count of members per guild for the given guild ids. Used by Task 9 search.</summary>
|
||||||
Task<Dictionary<int, int>> CountBatchByGuildIdsAsync(IReadOnlyList<int> guildIds, CancellationToken ct = default);
|
Task<Dictionary<int, int>> CountBatchByGuildIdsAsync(IReadOnlyList<int> guildIds, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch-checks whether each viewer id in <paramref name="viewerIds"/> is currently in any guild.
|
||||||
|
/// Returns a set of viewer ids that ARE in a guild. Used by <c>/guild/friend_list</c> to set
|
||||||
|
/// <c>is_join_guild</c>.
|
||||||
|
/// </summary>
|
||||||
|
Task<HashSet<long>> GetViewerIdsInAGuildAsync(IReadOnlyList<long> viewerIds, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ public record ChatUserProfile(
|
|||||||
int Rank,
|
int Rank,
|
||||||
int DegreeId);
|
int DegreeId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Richer profile shape used by guild management surfaces (invite list, join-request list).
|
||||||
|
/// Extends <see cref="ChatUserProfile"/> with <c>IsOfficialMarkDisplayed</c>.
|
||||||
|
/// </summary>
|
||||||
|
public record GuildMemberProfile(
|
||||||
|
string Name,
|
||||||
|
long EmblemId,
|
||||||
|
string CountryCode,
|
||||||
|
int Rank,
|
||||||
|
int DegreeId,
|
||||||
|
bool IsOfficialMarkDisplayed);
|
||||||
|
|
||||||
public interface IViewerRepository
|
public interface IViewerRepository
|
||||||
{
|
{
|
||||||
Task<Models.Viewer?> GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId);
|
Task<Models.Viewer?> GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId);
|
||||||
@@ -57,4 +69,23 @@ public interface IViewerRepository
|
|||||||
/// in the controller. Ids with no matching row are absent from the result. Read-only (AsNoTracking).
|
/// in the controller. Ids with no matching row are absent from the result. Read-only (AsNoTracking).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyDictionary<long, ChatUserProfile>> LoadChatProfilesAsync(IReadOnlyCollection<long> viewerIds, CancellationToken ct = default);
|
Task<IReadOnlyDictionary<long, ChatUserProfile>> LoadChatProfilesAsync(IReadOnlyCollection<long> viewerIds, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch-loads the <see cref="GuildMemberProfile"/> fields (emblem, degree,
|
||||||
|
/// <c>IsOfficialMarkDisplayed</c>) for a set of viewer ids. Used by guild management surfaces
|
||||||
|
/// (invite_user_list, join_request_list). Ids with no matching row are absent. Read-only.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<long, GuildMemberProfile>> LoadGuildProfileBatchAsync(IReadOnlyCollection<long> viewerIds, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a viewer's currently-equipped emblem id, or <c>100_000_000</c> (default) if none.
|
||||||
|
/// Used by <see cref="SVSim.Database.Services.Guild.GuildService"/> to seed the guild's initial emblem.
|
||||||
|
/// Returns <c>100_000_000</c> if the viewer doesn't exist.
|
||||||
|
/// </summary>
|
||||||
|
Task<long> GetEquippedEmblemIdAsync(long viewerId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch-loads owned guild-emblem ids for a viewer. Used by <c>/guild/emblem_list</c>.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<long>> GetEmblemListAsync(long viewerId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,6 +323,56 @@ public class ViewerRepository : IViewerRepository
|
|||||||
DegreeId: r.DegreeId));
|
DegreeId: r.DegreeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<long, GuildMemberProfile>> LoadGuildProfileBatchAsync(IReadOnlyCollection<long> viewerIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (viewerIds.Count == 0) return new Dictionary<long, GuildMemberProfile>();
|
||||||
|
var rows = await _dbContext.Set<Models.Viewer>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(v => v.Info.SelectedEmblem)
|
||||||
|
.Include(v => v.Info.SelectedDegree)
|
||||||
|
.Where(v => viewerIds.Contains(v.Id))
|
||||||
|
.Select(v => new
|
||||||
|
{
|
||||||
|
v.Id,
|
||||||
|
v.DisplayName,
|
||||||
|
EmblemId = v.Info.SelectedEmblem != null ? v.Info.SelectedEmblem.Id : 100_000_000L,
|
||||||
|
CountryCode = v.Info.CountryCode ?? "",
|
||||||
|
DegreeId = v.Info.SelectedDegree != null ? (int)v.Info.SelectedDegree.Id : 0,
|
||||||
|
IsOfficialMarkDisplayed = v.Info.IsOfficialMarkDisplayed,
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return rows.ToDictionary(
|
||||||
|
r => r.Id,
|
||||||
|
r => new GuildMemberProfile(
|
||||||
|
Name: r.DisplayName,
|
||||||
|
EmblemId: r.EmblemId,
|
||||||
|
CountryCode: r.CountryCode,
|
||||||
|
Rank: 1, // TODO: real rank when rank tracking lands
|
||||||
|
DegreeId: r.DegreeId,
|
||||||
|
IsOfficialMarkDisplayed: r.IsOfficialMarkDisplayed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetEquippedEmblemIdAsync(long viewerId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var row = await _dbContext.Set<Models.Viewer>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(v => v.Info.SelectedEmblem)
|
||||||
|
.Where(v => v.Id == viewerId)
|
||||||
|
.Select(v => new { EmblemId = v.Info.SelectedEmblem != null ? v.Info.SelectedEmblem.Id : 100_000_000L })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
return row?.EmblemId ?? 100_000_000L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<long>> GetEmblemListAsync(long viewerId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var viewer = await _dbContext.Set<Models.Viewer>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(v => v.Emblems)
|
||||||
|
.Where(v => v.Id == viewerId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
return viewer?.Emblems.Select(e => (long)e.Id).ToList() ?? new List<long>();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
|
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
|
||||||
{
|
{
|
||||||
Models.Viewer viewer = new Models.Viewer
|
Models.Viewer viewer = new Models.Viewer
|
||||||
|
|||||||
@@ -520,6 +520,9 @@ public class SVSimDbContext : DbContext
|
|||||||
e.Property(g => g.Name).HasMaxLength(64);
|
e.Property(g => g.Name).HasMaxLength(64);
|
||||||
e.Property(g => g.Description).HasMaxLength(512);
|
e.Property(g => g.Description).HasMaxLength(512);
|
||||||
e.HasIndex(g => g.Name); // name search
|
e.HasIndex(g => g.Name); // name search
|
||||||
|
e.HasOne<Viewer>().WithMany()
|
||||||
|
.HasForeignKey(g => g.LeaderViewerId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<GuildMember>(e =>
|
modelBuilder.Entity<GuildMember>(e =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using SVSim.Database.Entities.Guild;
|
using SVSim.Database.Entities.Guild;
|
||||||
using SVSim.Database.Models.Config;
|
using SVSim.Database.Models.Config;
|
||||||
using SVSim.Database.Repositories.Guild;
|
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 rows = await _guilds.SearchAsync(name ?? "", activity, joinCondition, memberBucket, cfg.MaxMemberNum, cfg.SearchResultCap, ct);
|
||||||
var guildIds = rows.Select(r => r.GuildId).ToList();
|
var guildIds = rows.Select(r => r.GuildId).ToList();
|
||||||
var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct);
|
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);
|
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)
|
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))
|
if (await _guilds.NameExistsAsync(req.Name, ct))
|
||||||
return new(GuildOpResultCode.NameTaken);
|
return new(GuildOpResultCode.NameTaken);
|
||||||
|
|
||||||
var viewer = await _db.Viewers
|
// Load the viewer's equipped emblem to use as the guild's initial emblem.
|
||||||
.Include(v => v.Info.SelectedEmblem)
|
// If the viewer doesn't exist (shouldn't happen post-auth), returns 100_000_000 default.
|
||||||
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
long viewerEmblemId = await _viewers.GetEquippedEmblemIdAsync(viewerId, ct);
|
||||||
if (viewer is null) return new(GuildOpResultCode.PermissionDenied);
|
|
||||||
|
|
||||||
var id = await _idGen.NextAsync(
|
var id = await _idGen.NextAsync(
|
||||||
(cand, c) => _guilds.GetByIdAsync(cand, c).ContinueWith(t => t.Result is not null, c),
|
(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,
|
Name = req.Name,
|
||||||
Description = "",
|
Description = "",
|
||||||
LeaderViewerId = viewerId,
|
LeaderViewerId = viewerId,
|
||||||
EmblemId = DefaultEmblemId(viewer),
|
EmblemId = viewerEmblemId,
|
||||||
Activity = (GuildActivity)req.Activity,
|
Activity = (GuildActivity)req.Activity,
|
||||||
JoinCondition = (GuildJoinCondition)req.JoinCondition,
|
JoinCondition = (GuildJoinCondition)req.JoinCondition,
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
@@ -116,22 +116,12 @@ public sealed class GuildService : IGuildService
|
|||||||
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct);
|
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct);
|
||||||
|
|
||||||
// Update Viewer.GuildId pointer for quick lookups.
|
// Update Viewer.GuildId pointer for quick lookups.
|
||||||
viewer.GuildId = id;
|
await _viewers.SetGuildIdAsync(viewerId, id, ct);
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
await _chat.EmitSystemEventAsync(id, viewerId, GuildChatMessageType.CreateGuild, body: null, ct);
|
await _chat.EmitSystemEventAsync(id, viewerId, GuildChatMessageType.CreateGuild, body: null, ct);
|
||||||
return new(GuildOpResultCode.Ok, GuildId: id);
|
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)
|
public async Task<GuildOpResult> UpdateAsync(long viewerId, UpdateGuildRequest req, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var m = await _members.GetMembershipAsync(viewerId, ct);
|
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 is null) return new(GuildOpResultCode.NotInGuild);
|
||||||
if (membership.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
|
if (membership.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
IDbContextTransaction? tx = null;
|
||||||
var members = await _members.ListByGuildAsync(membership.GuildId, ct);
|
try
|
||||||
foreach (var m in members) await _viewers.ClearGuildIdAsync(m.ViewerId, ct);
|
{
|
||||||
|
try { tx = await _db.Database.BeginTransactionAsync(ct); } catch (InvalidOperationException) { /* InMemory provider */ }
|
||||||
|
|
||||||
await _chatMessages.DeleteAllForGuildAsync(membership.GuildId, ct);
|
var now = DateTime.UtcNow;
|
||||||
await _invites.DeleteAllForGuildAsync(membership.GuildId, ct);
|
var members = await _members.ListByGuildAsync(membership.GuildId, ct);
|
||||||
await _joinRequests.DeleteAllForGuildAsync(membership.GuildId, ct);
|
foreach (var m in members) await _viewers.ClearGuildIdAsync(m.ViewerId, ct);
|
||||||
await _members.DeleteAllForGuildAsync(membership.GuildId, ct);
|
|
||||||
await _guilds.MarkBrokenUpAsync(membership.GuildId, now, 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)
|
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)
|
private async Task CommitJoinAsync(long viewerId, int guildId, DateTime now, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _members.AddAsync(
|
IDbContextTransaction? tx = null;
|
||||||
new GuildMember { GuildId = guildId, ViewerId = viewerId, Role = GuildRole.Regular, JoinedAt = now },
|
try
|
||||||
ct);
|
{
|
||||||
await _viewers.SetGuildIdAsync(viewerId, guildId, ct);
|
try { tx = await _db.Database.BeginTransactionAsync(ct); } catch (InvalidOperationException) { /* InMemory provider */ }
|
||||||
await _invites.ConsumePendingForViewerAsync(viewerId, now, ct);
|
|
||||||
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, 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);
|
||||||
|
|
||||||
|
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);
|
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>();
|
if (pendingInvites.Count == 0) return Array.Empty<GuildOutgoingInviteEntry>();
|
||||||
|
|
||||||
var inviteeIds = pendingInvites.Select(i => i.InviteeViewerId).Distinct().ToList();
|
var inviteeIds = pendingInvites.Select(i => i.InviteeViewerId).Distinct().ToList();
|
||||||
var names = await _viewers.LoadDisplayNamesAsync(inviteeIds, ct);
|
var profiles = await _viewers.LoadGuildProfileBatchAsync(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 result = new List<GuildOutgoingInviteEntry>(pendingInvites.Count);
|
var result = new List<GuildOutgoingInviteEntry>(pendingInvites.Count);
|
||||||
foreach (var invite in pendingInvites)
|
foreach (var invite in pendingInvites)
|
||||||
{
|
{
|
||||||
viewerRows.TryGetValue(invite.InviteeViewerId, out var v);
|
profiles.TryGetValue(invite.InviteeViewerId, out var p);
|
||||||
result.Add(new GuildOutgoingInviteEntry(
|
result.Add(new GuildOutgoingInviteEntry(
|
||||||
InviteId: invite.Id,
|
InviteId: invite.Id,
|
||||||
InviteeViewerId: invite.InviteeViewerId,
|
InviteeViewerId: invite.InviteeViewerId,
|
||||||
Name: names.GetValueOrDefault(invite.InviteeViewerId, ""),
|
Name: p?.Name ?? "",
|
||||||
EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L,
|
EmblemId: p?.EmblemId ?? 100_000_000L,
|
||||||
CountryCode: v?.Info?.CountryCode ?? "",
|
CountryCode: p?.CountryCode ?? "",
|
||||||
Rank: 1,
|
Rank: p?.Rank ?? 1,
|
||||||
DegreeId: v?.Info?.SelectedDegree?.Id ?? 0,
|
DegreeId: p?.DegreeId ?? 0,
|
||||||
CreatedAt: invite.CreatedAt));
|
CreatedAt: invite.CreatedAt));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -505,14 +522,14 @@ public sealed class GuildService : IGuildService
|
|||||||
|
|
||||||
var guildIds = pendingInvites.Select(i => i.GuildId).Distinct().ToList();
|
var guildIds = pendingInvites.Select(i => i.GuildId).Distinct().ToList();
|
||||||
var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct);
|
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);
|
var leaderNames = await _viewers.LoadDisplayNamesAsync(leaderIds, ct);
|
||||||
|
|
||||||
return pendingInvites.Select(invite => new GuildReceivedInviteEntry(
|
return pendingInvites.Select(invite => new GuildReceivedInviteEntry(
|
||||||
InviteId: invite.Id,
|
InviteId: invite.Id,
|
||||||
Guild: invite.Guild,
|
Guild: invite.Guild,
|
||||||
MemberNum: memberCounts.GetValueOrDefault(invite.GuildId, 0),
|
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)
|
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>();
|
if (requests.Count == 0) return Array.Empty<GuildJoinRequestEntry>();
|
||||||
|
|
||||||
var applicantIds = requests.Select(r => r.ViewerId).Distinct().ToList();
|
var applicantIds = requests.Select(r => r.ViewerId).Distinct().ToList();
|
||||||
var names = await _viewers.LoadDisplayNamesAsync(applicantIds, ct);
|
var profiles = await _viewers.LoadGuildProfileBatchAsync(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);
|
|
||||||
|
|
||||||
return requests.Select(r =>
|
return requests.Select(r =>
|
||||||
{
|
{
|
||||||
viewerRows.TryGetValue(r.ViewerId, out var v);
|
profiles.TryGetValue(r.ViewerId, out var p);
|
||||||
return new GuildJoinRequestEntry(
|
return new GuildJoinRequestEntry(
|
||||||
ApplicantViewerId: r.ViewerId,
|
ApplicantViewerId: r.ViewerId,
|
||||||
Name: names.GetValueOrDefault(r.ViewerId, ""),
|
Name: p?.Name ?? "",
|
||||||
EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L,
|
EmblemId: p?.EmblemId ?? 100_000_000L,
|
||||||
CountryCode: v?.Info?.CountryCode ?? "",
|
CountryCode: p?.CountryCode ?? "",
|
||||||
Rank: 1,
|
Rank: p?.Rank ?? 1,
|
||||||
DegreeId: v?.Info?.SelectedDegree?.Id ?? 0,
|
DegreeId: p?.DegreeId ?? 0,
|
||||||
IsOfficialMarkDisplayed: v?.Info?.IsOfficialMarkDisplayed == true,
|
IsOfficialMarkDisplayed: p?.IsOfficialMarkDisplayed ?? false,
|
||||||
RequestedAt: r.CreatedAt);
|
RequestedAt: r.CreatedAt);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,15 +131,19 @@ public sealed class GuildChatController : SVSimController
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("replay_detail")]
|
[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();
|
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||||
|
|
||||||
var payloadJson = await _chat.GetReplayDetailAsync(viewerId, (int)req.MessageId, ct);
|
var payloadJson = await _chat.GetReplayDetailAsync(viewerId, (int)req.MessageId, ct);
|
||||||
if (payloadJson is null) return Ok(new { result_code = 2 });
|
if (payloadJson is null) return Ok(new { result_code = 2 });
|
||||||
|
|
||||||
var replayInfo = ParseJsonElementOrNull(payloadJson);
|
// ReplayDetailInfo(data) accesses data["battleId"], data["seed"], data["vid1"], etc.
|
||||||
return new GuildChatReplayDetailResponse { ReplayInfo = replayInfo };
|
// 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")]
|
[HttpPost("deck_log")]
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using SVSim.Database;
|
|
||||||
using SVSim.Database.Entities.Guild;
|
using SVSim.Database.Entities.Guild;
|
||||||
using SVSim.Database.Models;
|
|
||||||
using SVSim.Database.Models.Config;
|
using SVSim.Database.Models.Config;
|
||||||
|
using SVSim.Database.Repositories.Guild;
|
||||||
|
using SVSim.Database.Repositories.Viewer;
|
||||||
using SVSim.Database.Services;
|
using SVSim.Database.Services;
|
||||||
using SVSim.Database.Services.Friend;
|
using SVSim.Database.Services.Friend;
|
||||||
using SVSim.Database.Services.Guild;
|
using SVSim.Database.Services.Guild;
|
||||||
@@ -20,17 +19,24 @@ public sealed class GuildController : SVSimController
|
|||||||
{
|
{
|
||||||
private readonly IGuildService _guild;
|
private readonly IGuildService _guild;
|
||||||
private readonly IGameConfigService _configs;
|
private readonly IGameConfigService _configs;
|
||||||
private readonly SVSimDbContext _db;
|
private readonly IViewerRepository _viewers;
|
||||||
|
private readonly IGuildMemberRepository _members;
|
||||||
private readonly IFriendService _friends;
|
private readonly IFriendService _friends;
|
||||||
|
|
||||||
// Wire error code returned when guild operations fail (non-1 result_code).
|
// Wire error code returned when guild operations fail (non-1 result_code).
|
||||||
private const int GuildErrorResultCode = 2;
|
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;
|
_guild = guild;
|
||||||
_configs = configs;
|
_configs = configs;
|
||||||
_db = db;
|
_viewers = viewers;
|
||||||
|
_members = members;
|
||||||
_friends = friends;
|
_friends = friends;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +62,7 @@ public sealed class GuildController : SVSimController
|
|||||||
resp.JoinRequestCount = view.JoinRequestCount;
|
resp.JoinRequestCount = view.JoinRequestCount;
|
||||||
resp.InviteCount = view.InviteCount;
|
resp.InviteCount = view.InviteCount;
|
||||||
var memberDtos = await ToMemberDtoListAsync(view.Members, viewerId, ct);
|
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
|
resp.Guild = new GuildBundle
|
||||||
{
|
{
|
||||||
Detail = ToDetailDto(view.Guild, view.Members.Count, leaderName),
|
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);
|
var r = await _guild.UpdateAsync(viewerId, new(activity, joinCondition, name), ct);
|
||||||
if (!r.IsOk) return WireError();
|
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();
|
if (m is null) return WireError();
|
||||||
var guild = await _guild.GetActiveAsync(m.GuildId, ct);
|
var guild = await _guild.GetActiveAsync(m.GuildId, ct);
|
||||||
if (guild is null) return WireError();
|
if (guild is null) return WireError();
|
||||||
var memberCount = await _db.GuildMembers.CountAsync(x => x.GuildId == guild.GuildId, ct);
|
var memberCount = await _members.CountByGuildAsync(guild.GuildId, ct);
|
||||||
var leader = await _db.Viewers.AsNoTracking().FirstOrDefaultAsync(v => v.Id == guild.LeaderViewerId, 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.
|
// 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")]
|
[HttpPost("update_description")]
|
||||||
@@ -120,16 +129,19 @@ public sealed class GuildController : SVSimController
|
|||||||
var r = await _guild.UpdateEmblemAsync(viewerId, req.EmblemId, ct);
|
var r = await _guild.UpdateEmblemAsync(viewerId, req.EmblemId, ct);
|
||||||
if (!r.IsOk) return WireError();
|
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();
|
if (m is null) return WireError();
|
||||||
var guild = await _guild.GetActiveAsync(m.GuildId, ct);
|
var guild = await _guild.GetActiveAsync(m.GuildId, ct);
|
||||||
if (guild is null) return WireError();
|
if (guild is null) return WireError();
|
||||||
var memberCount = await _db.GuildMembers.CountAsync(x => x.GuildId == guild.GuildId, ct);
|
var memberCount = await _members.CountByGuildAsync(guild.GuildId, ct);
|
||||||
var leader = await _db.Viewers.AsNoTracking().FirstOrDefaultAsync(v => v.Id == guild.LeaderViewerId, 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.
|
// GuildEmblemUpdateTask.Parse() reads data["guild"]["detail"] — nested wrapper required.
|
||||||
return new GuildUpdateEmblemResponse
|
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)
|
public async Task<ActionResult<GuildEmblemListResponse>> EmblemList([FromBody] BaseRequest _, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||||
var viewer = await _db.Viewers
|
var emblemIds = await _viewers.GetEmblemListAsync(viewerId, ct);
|
||||||
.AsNoTracking()
|
|
||||||
.Include(v => v.Emblems)
|
|
||||||
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
|
||||||
var emblems = viewer?.Emblems ?? new();
|
|
||||||
return new GuildEmblemListResponse
|
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();
|
if (!TryGetViewerId(out _)) return Unauthorized();
|
||||||
var guild = await _guild.GetActiveAsync(req.GuildId, ct);
|
var guild = await _guild.GetActiveAsync(req.GuildId, ct);
|
||||||
if (guild is null) return new GuildOthersInfoResponse();
|
if (guild is null) return new GuildOthersInfoResponse();
|
||||||
var memberCount = await _db.GuildMembers.CountAsync(m => m.GuildId == guild.GuildId, ct);
|
var memberCount = await _members.CountByGuildAsync(guild.GuildId, ct);
|
||||||
var leader = await _db.Viewers.AsNoTracking().FirstOrDefaultAsync(v => v.Id == guild.LeaderViewerId, 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
|
return new GuildOthersInfoResponse
|
||||||
{
|
{
|
||||||
Guild = new GuildDetailSubTree
|
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).
|
// 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 friendViewerIds = friendInfo.Friends.Select(f => (long)f.ViewerId).ToList();
|
||||||
var viewerRows = await _db.Viewers
|
var inGuild = await _members.GetViewerIdsInAGuildAsync(friendViewerIds, ct);
|
||||||
.AsNoTracking()
|
|
||||||
.Where(v => friendViewerIds.Contains(v.Id))
|
|
||||||
.Select(v => new { v.Id, v.GuildId })
|
|
||||||
.ToDictionaryAsync(v => v.Id, v => v.GuildId, ct);
|
|
||||||
|
|
||||||
var candidates = new List<GuildInviteCandidateDto>(friendInfo.Friends.Count);
|
var candidates = new List<GuildInviteCandidateDto>(friendInfo.Friends.Count);
|
||||||
foreach (var f in friendInfo.Friends)
|
foreach (var f in friendInfo.Friends)
|
||||||
{
|
{
|
||||||
viewerRows.TryGetValue(f.ViewerId, out var guildId);
|
|
||||||
candidates.Add(new GuildInviteCandidateDto
|
candidates.Add(new GuildInviteCandidateDto
|
||||||
{
|
{
|
||||||
ViewerId = f.ViewerId,
|
ViewerId = f.ViewerId,
|
||||||
@@ -202,7 +208,7 @@ public sealed class GuildController : SVSimController
|
|||||||
CountryCode = f.CountryCode,
|
CountryCode = f.CountryCode,
|
||||||
Rank = f.Rank,
|
Rank = f.Rank,
|
||||||
DegreeId = f.DegreeId,
|
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,
|
Activity = (int)e.Guild.Activity,
|
||||||
MemberNum = e.MemberNum,
|
MemberNum = e.MemberNum,
|
||||||
LeaderName = e.LeaderName,
|
LeaderName = e.LeaderName,
|
||||||
LeaderViewerId = e.Guild.LeaderViewerId,
|
LeaderViewerId = e.Guild.LeaderViewerId ?? 0L,
|
||||||
InviteId = e.InviteId,
|
InviteId = e.InviteId,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
};
|
};
|
||||||
@@ -357,12 +363,10 @@ public sealed class GuildController : SVSimController
|
|||||||
if (!r.IsOk) return WireError();
|
if (!r.IsOk) return WireError();
|
||||||
|
|
||||||
// Load caller's membership to get the guild id, then return the full updated member list.
|
// 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();
|
if (membership is null) return WireError();
|
||||||
|
|
||||||
var members = await _db.GuildMembers
|
var members = await _members.ListByGuildAsync(membership.GuildId, ct);
|
||||||
.Where(m => m.GuildId == membership.GuildId)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
var memberDtos = await ToMemberDtoListAsync(members, viewerId, ct);
|
var memberDtos = await ToMemberDtoListAsync(members, viewerId, ct);
|
||||||
return new GuildChangeRoleResponse { Members = memberDtos };
|
return new GuildChangeRoleResponse { Members = memberDtos };
|
||||||
@@ -379,7 +383,7 @@ public sealed class GuildController : SVSimController
|
|||||||
JoinCondition = (int)guild.JoinCondition,
|
JoinCondition = (int)guild.JoinCondition,
|
||||||
Activity = (int)guild.Activity,
|
Activity = (int)guild.Activity,
|
||||||
MemberNum = memberCount,
|
MemberNum = memberCount,
|
||||||
LeaderViewerId = guild.LeaderViewerId,
|
LeaderViewerId = guild.LeaderViewerId ?? 0L,
|
||||||
LeaderName = leaderName,
|
LeaderName = leaderName,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,33 +395,26 @@ public sealed class GuildController : SVSimController
|
|||||||
if (members.Count == 0) return new();
|
if (members.Count == 0) return new();
|
||||||
|
|
||||||
var viewerIds = members.Select(m => m.ViewerId).ToList();
|
var viewerIds = members.Select(m => m.ViewerId).ToList();
|
||||||
|
var profiles = await _viewers.LoadGuildProfileBatchAsync(viewerIds, ct);
|
||||||
// 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 result = new List<GuildMemberInfoDto>(members.Count);
|
var result = new List<GuildMemberInfoDto>(members.Count);
|
||||||
foreach (var m in members)
|
foreach (var m in members)
|
||||||
{
|
{
|
||||||
viewers.TryGetValue(m.ViewerId, out var v);
|
profiles.TryGetValue(m.ViewerId, out var p);
|
||||||
result.Add(ToMemberDto(m, v));
|
result.Add(ToMemberDto(m, p));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuildMemberInfoDto ToMemberDto(GuildMember member, Viewer? viewer) => new()
|
private static GuildMemberInfoDto ToMemberDto(GuildMember member, GuildMemberProfile? profile) => new()
|
||||||
{
|
{
|
||||||
ViewerId = member.ViewerId,
|
ViewerId = member.ViewerId,
|
||||||
Name = viewer?.DisplayName ?? "",
|
Name = profile?.Name ?? "",
|
||||||
EmblemId = viewer?.Info?.SelectedEmblem?.Id is > 0 ? viewer.Info.SelectedEmblem.Id : 100_000_000L,
|
EmblemId = profile?.EmblemId ?? 100_000_000L,
|
||||||
CountryCode = viewer?.Info?.CountryCode ?? "",
|
CountryCode = profile?.CountryCode ?? "",
|
||||||
Rank = 1, // TODO: populate from actual rank data when rank tracking lands
|
Rank = profile?.Rank ?? 1,
|
||||||
DegreeId = viewer?.Info?.SelectedDegree?.Id ?? 0,
|
DegreeId = profile?.DegreeId ?? 0,
|
||||||
IsOfficialMarkDisplayed = viewer?.Info?.IsOfficialMarkDisplayed == true ? 1 : 0,
|
IsOfficialMarkDisplayed = profile?.IsOfficialMarkDisplayed == true ? 1 : 0,
|
||||||
Role = (int)member.Role,
|
Role = (int)member.Role,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
using MessagePack;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat;
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Response for POST /guild_chat/replay_detail.
|
/// Response for POST /guild_chat/replay_detail.
|
||||||
/// Shape is opaque pending replay-subsystem documentation.
|
///
|
||||||
/// TODO(post-merge): enumerate fields from Wizard/ReplayDetailInfo.cs when replay subsystem is documented.
|
/// Wire shape: the stored ReplayPayload JSON is forwarded FLAT as the entire data payload.
|
||||||
/// Using JsonElement pass-through so the stored ReplayPayload JSON is forwarded verbatim.
|
/// ChatReplayDetailTask.Parse() calls new ReplayDetailInfo(base.ResponseData["data"]) which
|
||||||
|
/// then accesses data["battleId"], data["seed"], data["vid1"], etc. directly (no Keys.Contains
|
||||||
|
/// guards on required fields). Wrapping the payload under a "replay_info" key would crash the
|
||||||
|
/// client. The controller returns the raw JsonElement directly via Ok(element).
|
||||||
|
///
|
||||||
|
/// C1 decision: Option (b) is superseded — client CAN handle a full payload; the bug was the
|
||||||
|
/// wrapper key. The flat pass-through here is Option (a) variant: emit stored fields at data level.
|
||||||
|
/// If GetReplayDetailAsync returns null (no stored replay), the controller emits result_code=2
|
||||||
|
/// to gate the client off unguarded parsing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
// NOTE: This DTO is unused at runtime — the controller returns ActionResult directly. It is kept
|
||||||
|
// as documentation of the wire contract and for future typed deserialization tests.
|
||||||
public class GuildChatReplayDetailResponse
|
public class GuildChatReplayDetailResponse
|
||||||
{
|
{
|
||||||
/// <summary>
|
// Fields are emitted flat at the data level, not under a wrapper key.
|
||||||
/// Opaque replay payload forwarded verbatim from the stored ReplayPayload JSON.
|
// See ReplayDetailInfo.cs in the decompiled client for the full field list.
|
||||||
/// TODO(post-merge): type this properly once replay subsystem shape is captured.
|
// Key representative required fields (unguarded access in constructor):
|
||||||
/// </summary>
|
// battleId, seed, fieldId, firstTurn, card_master_id,
|
||||||
[JsonPropertyName("replay_info"), Key("replay_info")]
|
// vid1/name1/charaId1/classId1/emblemId1/degreeId1/countryCode1/sleeveId1/battlePoint1/masterPoint1/rank1/isOfficial1/deck1
|
||||||
public JsonElement? ReplayInfo { get; set; }
|
// vid2/name2/charaId2/classId2/emblemId2/degreeId2/countryCode2/sleeveId2/battlePoint2/masterPoint2/rank2/isOfficial2/deck2
|
||||||
|
|
||||||
|
[JsonPropertyName("result_code")]
|
||||||
|
public int ResultCode { get; set; } = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,40 @@ public class GuildWireShape
|
|||||||
"root must not be an object — no 'friends' wrapper allowed");
|
"root must not be an object — no 'friends' wrapper allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ReplayDetail_response_passes_payload_fields_flat_not_wrapped()
|
||||||
|
{
|
||||||
|
// ChatReplayDetailTask.Parse() calls new ReplayDetailInfo(base.ResponseData["data"]).
|
||||||
|
// ReplayDetailInfo constructor accesses data["battleId"].ToLong(), data["seed"].ToInt(), etc.
|
||||||
|
// WITHOUT Keys.Contains guards. Wrapping under a "replay_info" key would crash the client.
|
||||||
|
// The controller emits the stored JSON element directly as the data payload — verify that
|
||||||
|
// the serialized shape does NOT have a wrapper key.
|
||||||
|
//
|
||||||
|
// Decompile evidence (ReplayDetailInfo.cs line 209-210):
|
||||||
|
// battle_id = data["battleId"].ToLong(); // unguarded
|
||||||
|
// seed = data["seed"].ToInt(); // unguarded
|
||||||
|
// Therefore: option (b) gate-off when payload is null; flat pass-through when it exists.
|
||||||
|
|
||||||
|
// Simulate what the controller does: serialize the payload element directly.
|
||||||
|
const string storedPayloadJson = """{"battleId":"999","seed":42,"fieldId":1,"firstTurn":1,"card_master_id":100,"vid1":1,"name1":"A","charaId1":1,"classId1":1,"emblemId1":100000000,"degreeId1":0,"countryCode1":"JP","sleeveId1":3000011,"battlePoint1":0,"masterPoint1":0,"rank1":1,"isOfficial1":false,"deck1":[],"vid2":2,"name2":"B","charaId2":1,"classId2":2,"emblemId2":100000000,"degreeId2":0,"countryCode2":"JP","sleeveId2":3000011,"battlePoint2":0,"masterPoint2":0,"rank2":1,"isOfficial2":false,"deck2":[]}""";
|
||||||
|
using var doc = JsonDocument.Parse(storedPayloadJson);
|
||||||
|
var element = doc.RootElement.Clone();
|
||||||
|
|
||||||
|
// Re-serialize as if returned from Ok(element) — should be flat
|
||||||
|
var json = JsonSerializer.Serialize(element, Opts);
|
||||||
|
using var resultDoc = JsonDocument.Parse(json);
|
||||||
|
var root = resultDoc.RootElement;
|
||||||
|
|
||||||
|
// battleId must be at root level, NOT under a "replay_info" wrapper
|
||||||
|
Assert.That(root.TryGetProperty("battleId", out var battleId), Is.True,
|
||||||
|
"battleId must be at root level — no replay_info wrapper");
|
||||||
|
Assert.That(battleId.GetString(), Is.EqualTo("999"));
|
||||||
|
|
||||||
|
// replay_info key must NOT exist
|
||||||
|
Assert.That(root.TryGetProperty("replay_info", out _), Is.False,
|
||||||
|
"replay_info wrapper must NOT be present — client reads data[\"battleId\"] directly");
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void GuildInfo_non_joined_serializes_to_prod_shape()
|
public void GuildInfo_non_joined_serializes_to_prod_shape()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user