feat(guild): join / cancel_join_request / join_request_list
Implements three join-path state machine endpoints: - /guild/join: FREE (instant), APPROVAL (pending row + idempotent re-apply), ONLY_INVITE (pending invite required); CommitJoin consumes invites + cancels join requests; MaxMemberNum cap enforced. - /guild/cancel_join_request: cancels all pending requests for caller (no request_id on wire per GuildJoinRequestCancelTask decompile, composite PK kept). - /guild/join_request_list: leader/subleader sees pending applicants with viewer profile (name/emblem/degree/request_time unix secs). IGuildService.CancelJoinRequestAsync signature changed from (viewerId, guildId) to (viewerId) since the client sends no guildId. GuildJoinRequestEntry enriched record added to GuildServiceTypes. Re-apply after cancel resets existing row in-place to avoid composite-PK conflict. 11 integration tests, all green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -198,7 +198,8 @@ namespace SVSim.Database.Migrations
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GuildId", "InviteeViewerId")
|
||||
.IsUnique();
|
||||
.IsUnique()
|
||||
.HasFilter("\"Status\" = 0");
|
||||
|
||||
b.HasIndex("InviteeViewerId", "Status");
|
||||
|
||||
|
||||
@@ -250,11 +250,82 @@ public sealed class GuildService : IGuildService
|
||||
return GuildOpResult.Ok;
|
||||
}
|
||||
|
||||
public Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public async Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
|
||||
{
|
||||
if (await _members.GetMembershipAsync(viewerId, ct) is not null)
|
||||
return new(GuildOpResultCode.AlreadyInGuild);
|
||||
|
||||
public Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
var g = await _guilds.GetActiveByIdAsync(guildId, ct);
|
||||
if (g is null) return new(GuildOpResultCode.GuildNotFound);
|
||||
|
||||
var cfg = _config.Get<GuildConfig>();
|
||||
var memberCount = await _members.CountByGuildAsync(guildId, ct);
|
||||
if (memberCount >= cfg.MaxMemberNum) return new(GuildOpResultCode.MemberCapReached);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
bool hasPendingInvite = await _invites.GetAsync(guildId, viewerId, ct)
|
||||
is { Status: GuildInviteStatus.Pending };
|
||||
|
||||
if (g.JoinCondition == GuildJoinCondition.OnlyInvite && !hasPendingInvite)
|
||||
return new(GuildOpResultCode.PermissionDenied);
|
||||
|
||||
if (g.JoinCondition == GuildJoinCondition.Approval && !hasPendingInvite)
|
||||
{
|
||||
var existing = await _joinRequests.GetAsync(guildId, viewerId, ct);
|
||||
if (existing is { Status: GuildJoinRequestStatus.Pending })
|
||||
{
|
||||
// Already pending — idempotent, no-op.
|
||||
return GuildOpResult.Ok;
|
||||
}
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
// Row exists but was previously Canceled/Rejected — reset to Pending in place.
|
||||
existing.Status = GuildJoinRequestStatus.Pending;
|
||||
existing.CreatedAt = now;
|
||||
existing.RespondedAt = null;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _joinRequests.AddAsync(new GuildJoinRequest
|
||||
{
|
||||
GuildId = guildId,
|
||||
ViewerId = viewerId,
|
||||
Status = GuildJoinRequestStatus.Pending,
|
||||
CreatedAt = now,
|
||||
}, ct);
|
||||
}
|
||||
return GuildOpResult.Ok;
|
||||
}
|
||||
|
||||
await CommitJoinAsync(viewerId, guildId, now, ct);
|
||||
return GuildOpResult.Ok;
|
||||
}
|
||||
|
||||
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);
|
||||
await _chat.EmitSystemEventAsync(guildId, viewerId, GuildChatMessageType.Join, body: null, ct);
|
||||
}
|
||||
|
||||
public async Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, CancellationToken ct = default)
|
||||
{
|
||||
// Cancel all pending requests for this viewer (client sends no guildId — see decompile).
|
||||
var pending = await _joinRequests.ListPendingForViewerAsync(viewerId, ct);
|
||||
if (pending.Count == 0) return new(GuildOpResultCode.JoinRequestNotFound);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var req in pending)
|
||||
await _joinRequests.UpdateStatusAsync(req.GuildId, viewerId, GuildJoinRequestStatus.Canceled, now, ct);
|
||||
|
||||
return GuildOpResult.Ok;
|
||||
}
|
||||
|
||||
public Task<GuildOpResult> AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
@@ -328,6 +399,39 @@ public sealed class GuildService : IGuildService
|
||||
LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId, ""))).ToList();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Entities.Guild.GuildJoinRequest>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public async Task<IReadOnlyList<GuildJoinRequestEntry>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
|
||||
{
|
||||
var membership = await _members.GetMembershipAsync(viewerId, ct);
|
||||
if (membership is null) return Array.Empty<GuildJoinRequestEntry>();
|
||||
// Only leader/subleader may view the list — return empty for regular members.
|
||||
if (membership.Role is not (GuildRole.Leader or GuildRole.SubLeader))
|
||||
return Array.Empty<GuildJoinRequestEntry>();
|
||||
|
||||
var requests = await _joinRequests.ListPendingForGuildAsync(membership.GuildId, ct);
|
||||
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);
|
||||
|
||||
return requests.Select(r =>
|
||||
{
|
||||
viewerRows.TryGetValue(r.ViewerId, out var v);
|
||||
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,
|
||||
RequestedAt: r.CreatedAt);
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,3 +62,17 @@ public sealed record GuildReceivedInviteEntry(
|
||||
Entities.Guild.Guild Guild,
|
||||
int MemberNum,
|
||||
string LeaderName);
|
||||
|
||||
/// <summary>
|
||||
/// Enriched join-request entry returned by <see cref="IGuildService.ListPendingJoinRequestsForMyGuildAsync"/>.
|
||||
/// Combines applicant profile data with the request timestamp.
|
||||
/// </summary>
|
||||
public sealed record GuildJoinRequestEntry(
|
||||
long ApplicantViewerId,
|
||||
string Name,
|
||||
long EmblemId,
|
||||
string CountryCode,
|
||||
int Rank,
|
||||
int DegreeId,
|
||||
bool IsOfficialMarkDisplayed,
|
||||
DateTime RequestedAt);
|
||||
|
||||
@@ -27,7 +27,7 @@ public interface IGuildService
|
||||
Task<GuildOpResult> RejectInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default);
|
||||
|
||||
Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default);
|
||||
Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default);
|
||||
Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, CancellationToken ct = default);
|
||||
Task<GuildOpResult> AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default);
|
||||
Task<GuildOpResult> RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default);
|
||||
|
||||
@@ -38,5 +38,5 @@ public interface IGuildService
|
||||
Task<IReadOnlyList<Entities.Guild.GuildInvite>> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GuildOutgoingInviteEntry>> ListOutgoingInvitesAsync(long callerViewerId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GuildReceivedInviteEntry>> ListInvitedGuildsAsync(long viewerId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Entities.Guild.GuildJoinRequest>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GuildJoinRequestEntry>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user