diff --git a/src/Jiaowu.Api/Controllers/StatisticsController.cs b/src/Jiaowu.Api/Controllers/StatisticsController.cs index 06f1d43..d63fbd0 100644 --- a/src/Jiaowu.Api/Controllers/StatisticsController.cs +++ b/src/Jiaowu.Api/Controllers/StatisticsController.cs @@ -25,7 +25,12 @@ public sealed class StatisticsController( SystemRoles.CollegeAdmin + "," + SystemRoles.Leader; - private Guid? RestrictedCollegeId => currentUserDataScope.Current.RestrictedCollegeId; + // HybridCache may execute its source factory without the request's ambient + // HttpContext. Keep the authorization scope stable for the whole controller + // invocation instead of resolving it again inside that background factory. + private readonly CurrentUserScope currentUser = currentUserDataScope.Current; + + private Guid? RestrictedCollegeId => currentUser.RestrictedCollegeId; private Guid? ResolveCollegeId(Guid? requestedCollegeId) { @@ -982,7 +987,7 @@ public sealed class StatisticsController( params string?[] filters) => AppCacheKeys.Statistics( area, - currentUserDataScope.Current.Scope.ToString(), + currentUser.Scope.ToString(), effectiveCollegeId, filters); diff --git a/tests/Jiaowu.Api.Tests/StatisticsControllerTests.cs b/tests/Jiaowu.Api.Tests/StatisticsControllerTests.cs index e720ccc..9f51583 100644 --- a/tests/Jiaowu.Api.Tests/StatisticsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/StatisticsControllerTests.cs @@ -94,6 +94,36 @@ public sealed class StatisticsControllerTests Assert.Equal(2, cache.SourceCalls); } + [Fact] + public async Task Course_summary_keeps_request_scope_when_cache_factory_loses_http_context() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var college = new College { Code = "C01", Name = "第一学院" }; + var category = new CourseCategory { Code = "CAT", Name = "测试分类" }; + db.AddRange(college, category, CreateCourse("C001", college, category)); + await db.SaveChangesAsync(); + + var scope = new MutableDataScope(college.Id); + var cache = new RecordingCache(() => scope.LoseHttpContext()); + var controller = new StatisticsController(db, scope, cache); + + var result = await controller.GetCourseSummary( + college.Id, + null, + null, + CancellationToken.None); + + Assert.Equal(1, TotalCourses(result)); + Assert.Equal(1, cache.SourceCalls); + } + private static int TotalCourses(ActionResult result) { var json = Assert.IsType(result.Value); @@ -117,7 +147,7 @@ public sealed class StatisticsControllerTests AssessmentMethod = AssessmentMethod.Examination }; - private sealed class RecordingCache : IAppCache + private sealed class RecordingCache(Action? beforeFactory = null) : IAppCache { private readonly Dictionary values = []; @@ -137,6 +167,7 @@ public sealed class StatisticsControllerTests SourceCalls++; Tags.Add(tags.ToArray()); + beforeFactory?.Invoke(); var loaded = await factory(cancellationToken); values[key] = loaded!; return loaded; @@ -157,4 +188,29 @@ public sealed class StatisticsControllerTests DataScope.College, new HashSet { SystemRoles.CollegeAdmin }); } + + private sealed class MutableDataScope(Guid collegeId) : ICurrentUserDataScope + { + private CurrentUserScope current = CreateCollegeScope(collegeId); + + public CurrentUserScope Current => current; + + public void LoseHttpContext() + { + current = new CurrentUserScope( + Guid.Empty, + null, + null, + DataScope.Self, + new HashSet()); + } + + private static CurrentUserScope CreateCollegeScope(Guid value) => + new( + Guid.NewGuid(), + "学院管理员", + value, + DataScope.College, + new HashSet { SystemRoles.CollegeAdmin }); + } }