From c837b223143ae0dad664617a3621a61a7778cfa9 Mon Sep 17 00:00:00 2001 From: Palind <46059910+Palind-Rome@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:30:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(auth):=20=E9=9A=90=E8=97=8F=E5=B7=B2?= =?UTF-8?q?=E8=A7=A3=E6=95=A3=E7=A4=BE=E5=9B=A2=E7=9A=84=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E8=A7=92=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 做了什么:认证角色汇总仅保留运营中且审核通过社团的社团级角色。\n* 为什么:解散社团的历史成员和角色需要保留,但不应继续显示为当前身份或参与权限判断。\n* 影响范围:登录与会话角色、权限角色以及社团可见性回归测试。\n* 注意事项:不修改数据库历史记录,也不需要迁移。 --- backend.Tests/ClubVisibilityTests.cs | 77 ++++++++++++++++++++++++++++ backend/Services/AuthService.cs | 16 +++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/backend.Tests/ClubVisibilityTests.cs b/backend.Tests/ClubVisibilityTests.cs index 7f11716d..8628951e 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,80 @@ 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)); + } } diff --git a/backend/Services/AuthService.cs b/backend/Services/AuthService.cs index 4224d6aa..59f2d199 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; @@ -655,13 +657,15 @@ 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(); } @@ -1033,6 +1037,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 "" or 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; From 581cdd73357382ca716ab00981e936b822249d7c Mon Sep 17 00:00:00 2001 From: Palind <46059910+Palind-Rome@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:48:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(auth):=20=E8=BF=87=E6=BB=A4=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E4=B8=AD=E7=9A=84=E5=A4=B1=E6=95=88=E7=A4=BE=E5=9B=A2?= =?UTF-8?q?=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 做了什么:权限快照读取后再次按当前社团状态过滤,并补充缓存角色回归测试。\n* 为什么:社团解散后已有权限快照不能在缓存 TTL 内继续授予社团范围权限。\n* 影响范围:权限检查和社团可见性测试。\n* 注意事项:保留历史 UserRole 记录,不需要数据库迁移。 --- backend.Tests/ClubVisibilityTests.cs | 38 ++++++++++++++++++++++++++++ backend/Services/AuthService.cs | 28 ++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/backend.Tests/ClubVisibilityTests.cs b/backend.Tests/ClubVisibilityTests.cs index 8628951e..d53d9690 100644 --- a/backend.Tests/ClubVisibilityTests.cs +++ b/backend.Tests/ClubVisibilityTests.cs @@ -204,5 +204,43 @@ public async Task SessionRoles_ExcludeRolesScopedToInactiveClubs() 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 59f2d199..ff7756e4 100644 --- a/backend/Services/AuthService.cs +++ b/backend/Services/AuthService.cs @@ -650,7 +650,8 @@ 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) @@ -670,6 +671,29 @@ private async Task> GetRawAuthRolesAsync(int userId) .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 @@ -1043,7 +1067,7 @@ private static bool IsOperationalClub(ClubHub.Api.Data.Entities.Club? club) var status = NormalizeText(club.ClubStatus).ToLowerInvariant(); var auditStatus = NormalizeText(club.AuditStatus).ToLowerInvariant(); - return (status is "" or ClubActive or "normal" or "enabled" or "运营中" or "正常") && + return (status is ClubActive or "normal" or "enabled" or "运营中" or "正常") && (auditStatus is "" or AuditApproved or "已通过"); }