feat(friend): implement 5 read methods on FriendService + register DI + read test suite
GetFriendsAsync, GetReceiveAppliesAsync, GetSendAppliesAsync, GetPlayedTogetherAsync, SearchAsync all implemented. LoadViewerProjectionAsync materialises the full Viewer entity (with Include/ThenInclude for SelectedEmblem/Degree) then projects in-memory — avoids the EF Core limitation where Include is silently ignored under Select. FriendService + IPlayedTogetherWriter registered as Scoped in Program.cs. 12 read tests, all green; full suite 1171/1171 still passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,20 +28,119 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public Task<FriendInfoResult> GetFriendsAsync(long viewerId, CancellationToken ct) =>
|
||||
throw new NotImplementedException();
|
||||
public async Task<FriendInfoResult> GetFriendsAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var friendIds = await _db.ViewerFriends
|
||||
.AsNoTracking()
|
||||
.Where(f => f.OwnerViewerId == viewerId)
|
||||
.OrderBy(f => f.CreatedAt).ThenBy(f => f.FriendViewerId)
|
||||
.Select(f => f.FriendViewerId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<ReceiveApplyInfoResult> GetReceiveAppliesAsync(long viewerId, CancellationToken ct) =>
|
||||
throw new NotImplementedException();
|
||||
var friends = new List<FriendEntry>(friendIds.Count);
|
||||
foreach (var friendId in friendIds)
|
||||
{
|
||||
var entry = await BuildFriendEntryAsync(friendId, ct);
|
||||
if (entry is not null) friends.Add(entry);
|
||||
}
|
||||
|
||||
public Task<SendApplyInfoResult> GetSendAppliesAsync(long viewerId, CancellationToken ct) =>
|
||||
throw new NotImplementedException();
|
||||
return new FriendInfoResult(friends, friends.Count, FriendMaxCount);
|
||||
}
|
||||
|
||||
public Task<PlayedTogetherResult> GetPlayedTogetherAsync(long viewerId, CancellationToken ct) =>
|
||||
throw new NotImplementedException();
|
||||
public async Task<ReceiveApplyInfoResult> GetReceiveAppliesAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.ViewerFriendApplies
|
||||
.Where(a => a.ToViewerId == viewerId)
|
||||
.OrderBy(a => a.CreatedAt).ThenBy(a => a.Id)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<FriendEntry?> SearchAsync(long viewerId, int targetViewerId, CancellationToken ct) =>
|
||||
throw new NotImplementedException();
|
||||
var applies = new List<FriendApplyEntry>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
applies.Add(await BuildApplyEntryAsync(row.Id, row.FromViewerId, row.CreatedAt, row.MissionType, ct));
|
||||
|
||||
return new ReceiveApplyInfoResult(applies, ApproveApplyCount: 0);
|
||||
}
|
||||
|
||||
public async Task<SendApplyInfoResult> GetSendAppliesAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.ViewerFriendApplies
|
||||
.Where(a => a.FromViewerId == viewerId)
|
||||
.OrderBy(a => a.CreatedAt).ThenBy(a => a.Id)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var applies = new List<FriendApplyEntry>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
applies.Add(await BuildApplyEntryAsync(row.Id, row.ToViewerId, row.CreatedAt, row.MissionType, ct));
|
||||
|
||||
int remaining = Math.Max(0, SendApplyMaxCount - rows.Count);
|
||||
return new SendApplyInfoResult(applies, remaining, SendApplyMaxCount);
|
||||
}
|
||||
|
||||
public async Task<PlayedTogetherResult> GetPlayedTogetherAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.ViewerPlayedTogethers
|
||||
.Where(p => p.OwnerViewerId == viewerId)
|
||||
.OrderByDescending(p => p.PlayedAt)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var entries = new List<PlayedTogetherEntry>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var opp = await LoadViewerProjectionAsync(row.OpponentViewerId, ct);
|
||||
if (opp is null) continue; // opponent deleted; skip the dead row
|
||||
|
||||
bool isFriend = await _db.ViewerFriends.AsNoTracking()
|
||||
.AnyAsync(f => f.OwnerViewerId == viewerId && f.FriendViewerId == row.OpponentViewerId, ct);
|
||||
|
||||
int friendStatus = 0;
|
||||
int friendApplyId = 0;
|
||||
if (isFriend)
|
||||
{
|
||||
friendStatus = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
var sent = await _db.ViewerFriendApplies.AsNoTracking()
|
||||
.Where(a => a.FromViewerId == viewerId && a.ToViewerId == row.OpponentViewerId)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync(ct);
|
||||
if (sent is { } sId) { friendStatus = 2; friendApplyId = sId; }
|
||||
else
|
||||
{
|
||||
var recv = await _db.ViewerFriendApplies.AsNoTracking()
|
||||
.Where(a => a.FromViewerId == row.OpponentViewerId && a.ToViewerId == viewerId)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync(ct);
|
||||
if (recv is { } rId) { friendStatus = 3; friendApplyId = rId; }
|
||||
}
|
||||
}
|
||||
|
||||
entries.Add(new PlayedTogetherEntry(
|
||||
(int)opp.Id,
|
||||
opp.DisplayName,
|
||||
opp.CountryCode,
|
||||
ResolveRank(opp.DisplayName),
|
||||
opp.EmblemId,
|
||||
opp.DegreeId,
|
||||
FormatWireTimestamp(opp.LastLogin),
|
||||
FormatWireTimestamp(row.PlayedAt),
|
||||
friendStatus,
|
||||
friendApplyId,
|
||||
row.PlayedMode,
|
||||
row.BattleType,
|
||||
row.DeckFormat,
|
||||
row.TwoPickType));
|
||||
}
|
||||
|
||||
return new PlayedTogetherResult(entries);
|
||||
}
|
||||
|
||||
public async Task<FriendEntry?> SearchAsync(long viewerId, int targetViewerId, CancellationToken ct)
|
||||
{
|
||||
if (targetViewerId == (int)viewerId) return null;
|
||||
return await BuildFriendEntryAsync(targetViewerId, ct);
|
||||
}
|
||||
|
||||
public Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct) =>
|
||||
throw new NotImplementedException();
|
||||
@@ -66,4 +165,93 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter
|
||||
|
||||
public Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private sealed record ViewerProjection(
|
||||
long Id,
|
||||
string DisplayName,
|
||||
DateTime LastLogin,
|
||||
string CountryCode,
|
||||
long EmblemId,
|
||||
int DegreeId);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a Viewer with Info + cosmetic nav refs, then projects to a slim record.
|
||||
/// We materialise the full entity rather than using Select() because EF Core
|
||||
/// ignores Include/ThenInclude when a Select projection is present.
|
||||
/// </summary>
|
||||
private async Task<ViewerProjection?> LoadViewerProjectionAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var v = await _db.Viewers
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Id == viewerId)
|
||||
.Include(x => x.Info).ThenInclude(i => i.SelectedEmblem)
|
||||
.Include(x => x.Info).ThenInclude(i => i.SelectedDegree)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (v is null) return null;
|
||||
|
||||
return new ViewerProjection(
|
||||
v.Id,
|
||||
v.DisplayName,
|
||||
v.LastLogin,
|
||||
v.Info.CountryCode,
|
||||
v.Info.SelectedEmblem?.Id ?? 0,
|
||||
v.Info.SelectedDegree?.Id ?? 0);
|
||||
}
|
||||
|
||||
private async Task<FriendEntry?> BuildFriendEntryAsync(long friendViewerId, CancellationToken ct)
|
||||
{
|
||||
var v = await LoadViewerProjectionAsync(friendViewerId, ct);
|
||||
if (v is null) return null;
|
||||
|
||||
return new FriendEntry(
|
||||
ViewerId: (int)v.Id,
|
||||
Name: v.DisplayName,
|
||||
CountryCode: v.CountryCode,
|
||||
Rank: ResolveRank(v.DisplayName),
|
||||
EmblemId: v.EmblemId,
|
||||
DegreeId: v.DegreeId,
|
||||
LastPlayTime: FormatWireTimestamp(v.LastLogin),
|
||||
DeviceType: DefaultDeviceType,
|
||||
MaxFriend: DefaultMaxFriend,
|
||||
IsReceivedTwoPickMission: DefaultIsReceivedTwoPickMission,
|
||||
Birth: DefaultBirth,
|
||||
MissionChangeTime: DefaultMissionChangeTime,
|
||||
MissionReceiveType: DefaultMissionReceiveType,
|
||||
IsOfficial: DefaultIsOfficial,
|
||||
IsOfficialMarkDisplayed: DefaultIsOfficialMarkDisplayed);
|
||||
}
|
||||
|
||||
private async Task<FriendApplyEntry> BuildApplyEntryAsync(int applyId, long otherViewerId, DateTime createdAt, int missionType, CancellationToken ct)
|
||||
{
|
||||
var v = await LoadViewerProjectionAsync(otherViewerId, ct);
|
||||
// If viewer was deleted between apply creation and now, emit a placeholder so the wire doesn't break.
|
||||
var displayName = v?.DisplayName ?? string.Empty;
|
||||
var lastLogin = v?.LastLogin ?? DateTime.UnixEpoch;
|
||||
var countryCode = v?.CountryCode ?? string.Empty;
|
||||
var emblemId = v?.EmblemId ?? 0;
|
||||
var degreeId = v?.DegreeId ?? 0;
|
||||
|
||||
return new FriendApplyEntry(
|
||||
Id: applyId,
|
||||
ViewerId: (int)otherViewerId,
|
||||
Name: displayName,
|
||||
CountryCode: countryCode,
|
||||
Rank: ResolveRank(displayName),
|
||||
EmblemId: emblemId,
|
||||
DegreeId: degreeId,
|
||||
LastPlayTime: FormatWireTimestamp(lastLogin),
|
||||
CreateTime: FormatWireTimestamp(createdAt),
|
||||
MissionType: missionType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rank derivation. We don't track per-viewer rank yet; always 1. Hook here when rank data lands.
|
||||
/// </summary>
|
||||
private static int ResolveRank(string _) => 1;
|
||||
|
||||
private static string FormatWireTimestamp(DateTime dt) =>
|
||||
dt.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user