diff --git a/backend.Tests/ClubVisibilityTests.cs b/backend.Tests/ClubVisibilityTests.cs index 7f11716d..d53d9690 100644 --- a/backend.Tests/ClubVisibilityTests.cs +++ b/backend.Tests/ClubVisibilityTests.cs @@ -3,6 +3,7 @@ using ClubHub.Api.Data; using ClubHub.Api.Data.Entities; using ClubHub.Api.Services; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace ClubHub.Api.Tests; @@ -128,4 +129,118 @@ public async Task MemberClubList_ExcludesDissolvedClubFromOperationalWorkspaces( Assert.Contains("成员可见运营社团", names); Assert.DoesNotContain("成员不可见解散社团", names); } + + [Fact] + public async Task SessionRoles_ExcludeRolesScopedToInactiveClubs() + { + var baseId = 9_302_000 + Interlocked.Increment(ref _sequence) * 10; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var authService = scope.ServiceProvider.GetRequiredService(); + await authService.InitializeBaseRolesAsync(); + + var now = DateTime.UtcNow; + var activeClubId = baseId + 1; + var inactiveClubId = baseId + 2; + var user = new User + { + UserId = baseId, + Username = $"session-role-user-{baseId}", + PasswordHash = "unused", + RealName = "会话角色测试用户", + AccountStatus = "normal", + CreatedAt = now + }; + db.Users.Add(user); + db.Clubs.AddRange( + new Club + { + ClubId = activeClubId, + ClubName = "会话运营社团", + AuditStatus = "approved", + ClubStatus = "active", + CreatedAt = now + }, + new Club + { + ClubId = inactiveClubId, + ClubName = "会话已解散社团", + AuditStatus = "approved", + ClubStatus = "inactive", + CreatedAt = now + }); + + var memberRole = await db.Roles.SingleAsync(role => role.RoleCode == "CLUB_MEMBER"); + var leaderRole = await db.Roles.SingleAsync(role => role.RoleCode == "CLUB_LEADER"); + db.UserRoles.AddRange( + new UserRole + { + UserRoleId = baseId + 3, + UserId = user.UserId, + RoleId = memberRole.RoleId, + ClubId = activeClubId, + AssignedAt = now + }, + new UserRole + { + UserRoleId = baseId + 4, + UserId = user.UserId, + RoleId = leaderRole.RoleId, + ClubId = inactiveClubId, + AssignedAt = now + }); + await db.SaveChangesAsync(); + + var session = await authService.GetSessionAsync(user.UserId, "test-token"); + + Assert.True(session.Succeeded, session.ErrorMessage); + Assert.Contains(session.Value!.Roles, role => role.ClubId == activeClubId); + Assert.DoesNotContain(session.Value.Roles, role => role.ClubId == inactiveClubId); + + var permissionRoles = await authService.GetPermissionRolesAsync(user.UserId); + Assert.Contains(permissionRoles, role => role.ClubId == activeClubId); + Assert.DoesNotContain(permissionRoles, role => role.ClubId == inactiveClubId); + + Assert.Equal( + 2, + await db.UserRoles.CountAsync(role => role.UserId == user.UserId)); + + var cachedInactiveRole = new AuthRole( + leaderRole.RoleId, + leaderRole.RoleCode, + leaderRole.RoleName, + "会话已解散社团负责人", + leaderRole.RoleScope, + inactiveClubId, + [inactiveClubId], + AuthService.GetRolePermissions(leaderRole.RoleCode), + leaderRole.PermissionDesc); + var cachedAuthService = new AuthService( + db, + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + new FixedPermissionSnapshotCache(new PermissionSnapshot(user.UserId, [cachedInactiveRole]))); + + var filteredCachedRoles = await cachedAuthService.GetPermissionRolesAsync(user.UserId); + Assert.DoesNotContain(filteredCachedRoles, role => role.ClubId == inactiveClubId); + } + + private sealed class FixedPermissionSnapshotCache(PermissionSnapshot snapshot) + : IPermissionSnapshotCache + { + public Task GetOrCreateAsync( + int userId, + Func> factory, + CancellationToken cancellationToken = default) => Task.FromResult(snapshot); + + public Task GetAccountStatusAsync( + int userId, + Func> factory, + CancellationToken cancellationToken = default) => factory(); + + public Task InvalidateAsync( + int userId, + bool requiredForSafety, + CancellationToken cancellationToken = default) => Task.CompletedTask; + } } diff --git a/backend/Services/AuthService.cs b/backend/Services/AuthService.cs index 4224d6aa..ff7756e4 100644 --- a/backend/Services/AuthService.cs +++ b/backend/Services/AuthService.cs @@ -16,6 +16,8 @@ public class AuthService private const string ClubOfficerRole = "CLUB_OFFICER"; private const string ClubLeaderRole = "CLUB_LEADER"; private const string AdvisorRole = "ADVISOR"; + private const string AuditApproved = "approved"; + private const string ClubActive = "active"; private const string VenueAdminRole = "VENUE_ADMIN"; private const string SystemAdminRole = "SYSTEM_ADMIN"; private const int StudentNoLength = 7; @@ -648,24 +650,50 @@ public async Task> GetPermissionRolesAsync(int userId) async () => new PermissionSnapshot( userId, await GetRawAuthRolesAsync(userId))); - return BuildPermissionRoles(snapshot.Roles); + var currentRoles = await FilterOperationalClubRolesAsync(snapshot.Roles); + return BuildPermissionRoles(currentRoles); } private async Task> GetRawAuthRolesAsync(int userId) { var userRoles = await _db.UserRoles .Include(ur => ur.Role) + .Include(ur => ur.Club) .Where(ur => ur.UserId == userId) .OrderBy(ur => ur.RoleId) .ThenBy(ur => ur.ClubId) .ToListAsync(); return userRoles - .Where(ur => ur.Role is not null) + .Where(ur => ur.Role is not null && + (ur.ClubId is null || IsOperationalClub(ur.Club))) .Select(ur => ToAuthRole(ur.Role!, ur.ClubId)) .ToList(); } + private async Task> FilterOperationalClubRolesAsync( + IReadOnlyList roles) + { + var clubIds = roles + .Where(role => role.ClubId is not null) + .Select(role => role.ClubId!.Value) + .Distinct() + .ToList(); + if (clubIds.Count == 0) return roles; + + var operationalClubIds = (await _db.Clubs + .AsNoTracking() + .Where(club => clubIds.Contains(club.ClubId)) + .ToListAsync()) + .Where(IsOperationalClub) + .Select(club => club.ClubId) + .ToHashSet(); + + return roles + .Where(role => role.ClubId is null || operationalClubIds.Contains(role.ClubId.Value)) + .ToList(); + } + private async Task> BuildDisplayRolesAsync(IReadOnlyList rawRoles) { var clubIds = rawRoles @@ -1033,6 +1061,16 @@ private static bool IsNormalAccount(User user) return normalized is "active" or NormalStatus or "enabled" or "在任" or "正常"; } + private static bool IsOperationalClub(ClubHub.Api.Data.Entities.Club? club) + { + if (club is null) return false; + + var status = NormalizeText(club.ClubStatus).ToLowerInvariant(); + var auditStatus = NormalizeText(club.AuditStatus).ToLowerInvariant(); + return (status is ClubActive or "normal" or "enabled" or "运营中" or "正常") && + (auditStatus is "" or AuditApproved or "已通过"); + } + private static bool IsUniqueConstraintViolation(DbUpdateException ex) { var message = ex.InnerException?.Message ?? ex.Message;