diff --git a/api/openapi.yaml b/api/openapi.yaml index c5cbc5c1..050cef6e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4392,7 +4392,7 @@ paths: schema: $ref: "#/components/schemas/ProjectMember" "400": - description: 请求参数无效,或候选用户不是所属社团当前有效成员 + description: 请求参数无效,或普通成员候选用户不是所属社团当前有效成员 content: application/json: schema: @@ -4425,7 +4425,7 @@ paths: /api/v1/projects/{projectId}/member-candidates: get: summary: 获取项目成员候选人 - description: 仅返回账号正常、属于项目所属社团且当前未参与该项目的有效成员。 + description: 返回账号正常、尚未参与项目的候选人;普通成员须属于项目所属社团且当前有效,导师候选人为教师账号。 operationId: getProjectMemberCandidates security: - bearerAuth: [] @@ -11811,7 +11811,7 @@ components: ProjectMemberCandidate: type: object - description: 可加入项目的所属社团当前有效成员。 + description: 可加入项目的候选人;普通成员来自所属社团当前有效成员,导师为账号正常的教师。 properties: userId: type: integer @@ -11842,7 +11842,7 @@ components: userId: type: integer minimum: 1 - description: 所属社团当前有效成员的用户 ID。 + description: 普通成员须为所属社团当前有效成员;导师须为账号正常的教师。 example: 12 memberRole: type: string diff --git a/backend.Tests/ClubVisibilityTests.cs b/backend.Tests/ClubVisibilityTests.cs new file mode 100644 index 00000000..7f11716d --- /dev/null +++ b/backend.Tests/ClubVisibilityTests.cs @@ -0,0 +1,131 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using ClubHub.Api.Data; +using ClubHub.Api.Data.Entities; +using ClubHub.Api.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace ClubHub.Api.Tests; + +public sealed class ClubVisibilityTests : IClassFixture +{ + private readonly ClubHubWebApplicationFactory _factory; + private static int _sequence; + + public ClubVisibilityTests(ClubHubWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task PublicClubList_ExcludesUnapprovedOrInactiveClubs() + { + var baseId = 9_300_000 + Interlocked.Increment(ref _sequence) * 10; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Clubs.AddRange( + new Club + { + ClubId = baseId, + ClubName = "可见运营社团", + AuditStatus = "approved", + ClubStatus = "active", + CreatedAt = DateTime.UtcNow + }, + new Club + { + ClubId = baseId + 1, + ClubName = "已解散社团", + AuditStatus = "approved", + ClubStatus = "inactive", + CreatedAt = DateTime.UtcNow + }, + new Club + { + ClubId = baseId + 2, + ClubName = "待审核社团", + AuditStatus = "pending", + ClubStatus = "pending", + CreatedAt = DateTime.UtcNow + }); + await db.SaveChangesAsync(); + + using var client = _factory.CreateClient(); + using var response = await client.GetAsync("/api/v1/clubs"); + + response.EnsureSuccessStatusCode(); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var names = body.RootElement.EnumerateArray() + .Select(club => club.GetProperty("name").GetString()) + .ToArray(); + Assert.Contains("可见运营社团", names); + Assert.DoesNotContain("已解散社团", names); + Assert.DoesNotContain("待审核社团", names); + } + + [Fact] + public async Task MemberClubList_ExcludesDissolvedClubFromOperationalWorkspaces() + { + var baseId = 9_301_000 + Interlocked.Increment(ref _sequence) * 10; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + db.Users.Add(new User + { + UserId = baseId, + Username = $"club-visibility-user-{baseId}", + PasswordHash = "unused", + RealName = "社团可见性测试用户", + AccountStatus = "normal", + CreatedAt = now + }); + db.Clubs.AddRange( + new Club + { + ClubId = baseId + 1, + ClubName = "成员可见运营社团", + AuditStatus = "approved", + ClubStatus = "active", + CreatedAt = now + }, + new Club + { + ClubId = baseId + 2, + ClubName = "成员不可见解散社团", + AuditStatus = "approved", + ClubStatus = "inactive", + CreatedAt = now + }); + db.ClubMembers.AddRange( + new ClubMember + { + MemberId = baseId + 3, + ClubId = baseId + 1, + UserId = baseId, + MemberStatus = "active", + TermStart = now.AddDays(-1), + TermEnd = now.AddDays(30) + }, + new ClubMember + { + MemberId = baseId + 4, + ClubId = baseId + 2, + UserId = baseId, + MemberStatus = "active", + TermStart = now.AddDays(-1), + TermEnd = now.AddDays(30) + }); + await db.SaveChangesAsync(); + + var token = scope.ServiceProvider.GetRequiredService().CreateToken( + new User { UserId = baseId, Username = $"club-visibility-user-{baseId}" }); + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await client.GetAsync("/api/v1/clubs"); + + response.EnsureSuccessStatusCode(); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var names = body.RootElement.EnumerateArray() + .Select(club => club.GetProperty("name").GetString()) + .ToArray(); + Assert.Contains("成员可见运营社团", names); + Assert.DoesNotContain("成员不可见解散社团", names); + } +} diff --git a/backend.Tests/ProjectMembershipServiceTests.cs b/backend.Tests/ProjectMembershipServiceTests.cs index eb0c073b..e7144d37 100644 --- a/backend.Tests/ProjectMembershipServiceTests.cs +++ b/backend.Tests/ProjectMembershipServiceTests.cs @@ -366,6 +366,61 @@ public async Task GetCandidateUsersQuery_ExcludesExistingProjectMembers() Assert.Equal("Candidate", candidates[0].RealName); } + [Fact] + public async Task GetCandidateUsersQuery_IncludesActiveTeacherWithoutClubMembership() + { + var baseId = 3250 + Interlocked.Increment(ref _sequence) * 10; + var clubId = baseId; + var projectId = baseId; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + var project = new Project + { + ProjectId = projectId, + ProjectName = "Teacher Mentor Project", + ClubId = clubId, + LeaderUserId = baseId + 10, + ProjectStatus = "running", + CreatedAt = now + }; + + db.Add(new Club { ClubId = clubId, ClubName = "Teacher Mentor Club", ClubStatus = "active", CreatedAt = now }); + db.Add(project); + db.Add(new User + { + UserId = baseId + 1, + Username = "teacher-mentor", + StudentNo = "05001", + PasswordHash = "unused", + RealName = "Teacher Mentor", + AccountStatus = "normal", + CreatedAt = now + }); + db.Add(new Role + { + RoleId = baseId + 4, + RoleCode = "TEACHER", + RoleName = "教师", + RoleScope = "system", + CreatedAt = now + }); + db.Add(new UserRole + { + UserRoleId = baseId + 5, + UserId = baseId + 1, + RoleId = baseId + 4, + AssignedAt = now + }); + await db.SaveChangesAsync(); + + var candidates = await service.GetCandidateUsersQuery(project).ToListAsync(); + + Assert.Contains(candidates, candidate => candidate.StudentNo == "05001"); + } + [Fact] public async Task GetCandidateUsersQuery_Defers_CompositionUntilMaterialization() { diff --git a/backend/Controllers/ClubsController.cs b/backend/Controllers/ClubsController.cs index dac16081..103b3282 100644 --- a/backend/Controllers/ClubsController.cs +++ b/backend/Controllers/ClubsController.cs @@ -161,16 +161,22 @@ public async Task GetAll() { var query = ClubQuery(); var userId = User.GetUserId(); - if (userId is not null) + if (userId is null) + { + query = query.Where(c => c.AuditStatus == AuditApproved && c.ClubStatus == ClubActive); + } + else { var viewer = await LoadUserAsync(userId.Value); if (viewer is not null && !UsersController.IsPlatformAdmin(viewer)) { query = query.Where(c => - c.ApplicantUserId == viewer.UserId || - c.PresidentUserId == viewer.UserId || - c.Members.Any(m => m.UserId == viewer.UserId) || - c.UserRoles.Any(ur => ur.UserId == viewer.UserId)); + c.AuditStatus == AuditApproved && + c.ClubStatus == ClubActive && + (c.ApplicantUserId == viewer.UserId || + c.PresidentUserId == viewer.UserId || + c.Members.Any(m => m.UserId == viewer.UserId) || + c.UserRoles.Any(ur => ur.UserId == viewer.UserId))); } } diff --git a/backend/Controllers/ProjectMembersController.cs b/backend/Controllers/ProjectMembersController.cs index 7df9dc52..6d6ecb87 100644 --- a/backend/Controllers/ProjectMembersController.cs +++ b/backend/Controllers/ProjectMembersController.cs @@ -170,7 +170,8 @@ public async Task AddMember(int projectId, [FromBody] AddProjectM return Error(400, "project_member_candidate_disabled", "候选用户账号状态异常,不能加入项目。"); } - if (!await _membershipService.IsActiveClubMemberAsync(project.ClubId, request.UserId)) + if (memberRole != ProjectMembershipService.MentorRole && + !await _membershipService.IsActiveClubMemberAsync(project.ClubId, request.UserId)) { return Error(400, "project_member_candidate_ineligible", "候选用户不是项目所属社团的当前有效成员。"); } diff --git a/backend/Models/AddProjectMemberRequest.cs b/backend/Models/AddProjectMemberRequest.cs index 46977d20..178bb135 100644 --- a/backend/Models/AddProjectMemberRequest.cs +++ b/backend/Models/AddProjectMemberRequest.cs @@ -26,9 +26,9 @@ namespace Org.OpenAPITools.Models public partial class AddProjectMemberRequest { /// - /// 所属社团当前有效成员的用户 ID。 + /// 普通成员须为所属社团当前有效成员;导师须为账号正常的教师。 /// - /// 所属社团当前有效成员的用户 ID。 + /// 普通成员须为所属社团当前有效成员;导师须为账号正常的教师。 /* 12 */ [Required] [DataMember(Name="userId", EmitDefaultValue=true)] diff --git a/backend/Models/ProjectMemberCandidate.cs b/backend/Models/ProjectMemberCandidate.cs index 8f816304..6d68d9f9 100644 --- a/backend/Models/ProjectMemberCandidate.cs +++ b/backend/Models/ProjectMemberCandidate.cs @@ -20,7 +20,7 @@ namespace Org.OpenAPITools.Models { /// - /// 可加入项目的所属社团当前有效成员。 + /// 可加入项目的候选人;普通成员来自所属社团当前有效成员,导师为账号正常的教师。 /// [DataContract] public partial class ProjectMemberCandidate diff --git a/backend/Services/ProjectMembershipService.cs b/backend/Services/ProjectMembershipService.cs index 3a6af45a..46be98be 100644 --- a/backend/Services/ProjectMembershipService.cs +++ b/backend/Services/ProjectMembershipService.cs @@ -20,6 +20,7 @@ public class ProjectMembershipService private const int MaxWriteRetries = 3; private const string ManageProjectTasksPermission = "project:task:manage"; private static readonly string[] ActiveStatuses = [string.Empty, "active", "normal", "enabled", "在任", "正常"]; + private static readonly string[] TeacherRoleCodes = ["TEACHER", "ADVISOR"]; private readonly ClubHubDbContext _db; private readonly AuthService _authService; @@ -155,15 +156,18 @@ public IQueryable GetCandidateUsersQuery(Project project) { var businessDate = DateTime.UtcNow.Date; - return _db.Users + var query = _db.Users .AsNoTracking() .Where(user => user.AccountStatus == null || ActiveStatuses.Contains(user.AccountStatus.Trim().ToLower())) - .Where(user => user.ClubMemberships.Any(member => - member.ClubId == project.ClubId && - (member.MemberStatus == null || ActiveStatuses.Contains(member.MemberStatus.Trim().ToLower())) && - (member.TermStart == null || member.TermStart <= businessDate) && - (member.TermEnd == null || member.TermEnd >= businessDate))) + .Where(user => + user.ClubMemberships.Any(member => + member.ClubId == project.ClubId && + (member.MemberStatus == null || ActiveStatuses.Contains(member.MemberStatus.Trim().ToLower())) && + (member.TermStart == null || member.TermStart <= businessDate) && + (member.TermEnd == null || member.TermEnd >= businessDate)) || + user.UserRoles.Any(userRole => + userRole.Role != null && TeacherRoleCodes.Contains(userRole.Role.RoleCode))) .Where(user => !_db.ProjectMembers.Any(member => member.ProjectId == project.ProjectId && member.UserId == user.UserId && @@ -171,6 +175,8 @@ public IQueryable GetCandidateUsersQuery(Project project) .OrderBy(user => user.RealName) .ThenBy(user => user.StudentNo) .ThenBy(user => user.UserId); + + return query; } /// diff --git a/frontend/src/api/apis/DefaultApi.ts b/frontend/src/api/apis/DefaultApi.ts index 7b655271..2fdd269c 100644 --- a/frontend/src/api/apis/DefaultApi.ts +++ b/frontend/src/api/apis/DefaultApi.ts @@ -8430,7 +8430,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** - * 仅返回账号正常、属于项目所属社团且当前未参与该项目的有效成员。 + * 返回账号正常、尚未参与项目的候选人;普通成员须属于项目所属社团且当前有效,导师候选人为教师账号。 * 获取项目成员候选人 */ async getProjectMemberCandidatesRaw( @@ -8446,7 +8446,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** - * 仅返回账号正常、属于项目所属社团且当前未参与该项目的有效成员。 + * 返回账号正常、尚未参与项目的候选人;普通成员须属于项目所属社团且当前有效,导师候选人为教师账号。 * 获取项目成员候选人 */ async getProjectMemberCandidates( diff --git a/frontend/src/api/models/AddProjectMemberRequest.ts b/frontend/src/api/models/AddProjectMemberRequest.ts index e4b4e817..b7b6d4ea 100644 --- a/frontend/src/api/models/AddProjectMemberRequest.ts +++ b/frontend/src/api/models/AddProjectMemberRequest.ts @@ -21,7 +21,7 @@ import { mapValues } from "../runtime"; */ export interface AddProjectMemberRequest { /** - * 所属社团当前有效成员的用户 ID。 + * 普通成员须为所属社团当前有效成员;导师须为账号正常的教师。 */ userId: number; /** diff --git a/frontend/src/api/models/ProjectMemberCandidate.ts b/frontend/src/api/models/ProjectMemberCandidate.ts index c622052d..8cd5fc1f 100644 --- a/frontend/src/api/models/ProjectMemberCandidate.ts +++ b/frontend/src/api/models/ProjectMemberCandidate.ts @@ -15,7 +15,7 @@ import { mapValues } from "../runtime"; /** - * 可加入项目的所属社团当前有效成员。 + * 可加入项目的候选人;普通成员来自所属社团当前有效成员,导师为账号正常的教师。 * @export * @interface ProjectMemberCandidate */ diff --git a/frontend/src/components/ProjectMembersPanel.vue b/frontend/src/components/ProjectMembersPanel.vue index a8aaebcd..56034b09 100644 --- a/frontend/src/components/ProjectMembersPanel.vue +++ b/frontend/src/components/ProjectMembersPanel.vue @@ -60,10 +60,14 @@ const canManage = computed(() => { const projectClosed = computed(() => props.projectStatus === "closed"); const editable = computed(() => canManage.value && !projectClosed.value); +function isTeacherCandidate(candidate: ProjectMemberCandidate) { + return /^\d{5}$/.test(candidate.studentNo ?? ""); +} + const visibleCandidates = computed(() => addForm.memberRole === AddProjectMemberRequestMemberRoleEnum.Mentor - ? candidates.value.filter((candidate) => /^\d{5}$/.test(candidate.studentNo ?? "")) - : candidates.value, + ? candidates.value.filter(isTeacherCandidate) + : candidates.value.filter((candidate) => !isTeacherCandidate(candidate)), ); const addRules: FormRules = { @@ -418,7 +422,7 @@ onUnmounted(() => { label-position="top" @submit.prevent > - + {
负责人不能在此指定,请使用项目列表中的负责人分配功能。
-
选择“导师”时,仅显示教师账号。
+
+ 普通成员来自所属社团当前有效成员;选择“导师”时仅显示教师账号。 +
({ id, @@ -47,4 +51,19 @@ describe("forum club scope", () => { it("wires the same operational-club filter into project selectors", () => { expect(projectListSource).toContain("filterOperationalClubs"); }); + + it("keeps dissolved clubs out of operational workspaces and identities", () => { + expect(budgetManagementSource).toContain("filterOperationalClubs"); + expect(materialBorrowSource).toContain("filterOperationalClubs"); + expect(clubListSource).toContain("filterOperationalClubs(clubData)"); + expect(clubListSource).toContain("operationalClubIds"); + }); + + it("shows teacher candidates only for the mentor role", () => { + expect(projectMembersPanelSource).toContain("isTeacherCandidate"); + expect(projectMembersPanelSource).toContain("candidates.value.filter(isTeacherCandidate)"); + expect(projectMembersPanelSource).toContain( + "candidates.value.filter((candidate) => !isTeacherCandidate(candidate))", + ); + }); }); diff --git a/frontend/src/forumClubScope.ts b/frontend/src/forumClubScope.ts index 4dcdb3b7..3cafcc11 100644 --- a/frontend/src/forumClubScope.ts +++ b/frontend/src/forumClubScope.ts @@ -2,17 +2,23 @@ import type { Club } from "./api/models"; const normalizeStatus = (value?: string | null) => value?.trim().toLowerCase() ?? ""; -export function isOperationalClub(club: Pick): boolean { +export function isOperationalClub>( + club: T, +): boolean { return ( normalizeStatus(club.status) === "active" && normalizeStatus(club.auditStatus) === "approved" ); } -export function filterOperationalClubs(clubs: Club[]): Club[] { +export function filterOperationalClubs>( + clubs: T[], +): T[] { return clubs.filter(isOperationalClub); } -export function isActiveForumClub(club: Pick): boolean { +export function isActiveForumClub>( + club: T, +): boolean { return isOperationalClub(club); } diff --git a/frontend/src/views/BudgetManagement.vue b/frontend/src/views/BudgetManagement.vue index 40dded02..5acaadd6 100644 --- a/frontend/src/views/BudgetManagement.vue +++ b/frontend/src/views/BudgetManagement.vue @@ -18,10 +18,13 @@ import type { import { apiClient, createIdempotencyKey } from "../apiClient"; import { requestJson } from "../composables/useApiRequest"; import { formatVenueReservationDateTime } from "../beijingTime"; +import { filterOperationalClubs } from "../forumClubScope"; interface ClubOption { id: number; name: string; + status?: string | null; + auditStatus?: string | null; } interface ActivityOption { @@ -278,7 +281,13 @@ const resubmitRules: FormRules = { }; function ensureActiveClub() { - if (activeClubId.value !== undefined) return; + if ( + (activeClubId.value === 0 && canViewAllBudgets.value) || + (activeClubId.value !== undefined && + visibleClubs.value.some((club) => club.id === activeClubId.value)) + ) { + return; + } activeClubId.value = canViewAllBudgets.value ? 0 : visibleClubs.value[0]?.id; } @@ -289,7 +298,8 @@ async function loadClubs() { return false; } - clubs.value = await requestJson(`/api/v1/clubs?viewerUserId=${userId}`); + const nextClubs = await requestJson(`/api/v1/clubs?viewerUserId=${userId}`); + clubs.value = filterOperationalClubs(nextClubs); ensureActiveClub(); return true; } diff --git a/frontend/src/views/ClubList.vue b/frontend/src/views/ClubList.vue index a225ea2e..1a2daa44 100644 --- a/frontend/src/views/ClubList.vue +++ b/frontend/src/views/ClubList.vue @@ -30,6 +30,7 @@ import { type MemberGroupingScope, } from "../composables/useClubEvaluationScope"; import { hasScopedRole, roleCoversClub } from "../composables/useManageableClubs"; +import { filterOperationalClubs, isOperationalClub } from "../forumClubScope"; type AuditStatus = "pending" | "approved" | "rejected"; type ReviewDecision = "approved" | "rejected"; @@ -558,21 +559,27 @@ const identityRows = computed(() => { const user = currentUser.value; if (!user) return []; - const rows = myMemberships.value.map((membership) => ({ - clubId: membership.clubId, - clubName: membership.clubName, - departmentName: membership.departmentName, - groupName: membership.groupName, - positionName: membership.positionName, - termName: membership.termName, - memberStatus: membership.memberStatus, - })); + const operationalClubIds = new Set(clubs.value.filter(isOperationalClub).map((club) => club.id)); + const shouldShowClub = (clubId: number) => + hasAllPermissions.value || operationalClubIds.has(clubId); + const rows = myMemberships.value + .filter((membership) => shouldShowClub(membership.clubId)) + .map((membership) => ({ + clubId: membership.clubId, + clubName: membership.clubName, + departmentName: membership.departmentName, + groupName: membership.groupName, + positionName: membership.positionName, + termName: membership.termName, + memberStatus: membership.memberStatus, + })); const existingClubIds = new Set(rows.map((row) => row.clubId)); user.roles.forEach((role) => { const clubIds = role.clubIds?.length ? role.clubIds : role.clubId !== null ? [role.clubId] : []; clubIds + .filter(shouldShowClub) .filter((clubId) => !existingClubIds.has(clubId)) .forEach((clubId) => { existingClubIds.add(clubId); @@ -995,7 +1002,7 @@ async function loadData() { ]); if (requestId !== dataRequestId) return; applications.value = applicationData; - clubs.value = clubData; + clubs.value = hasAllPermissions.value ? clubData : filterOperationalClubs(clubData); syncSelectedClub(); await Promise.all([loadMembers(), loadDepartments()]); } catch (e) { diff --git a/frontend/src/views/MaterialBorrow.vue b/frontend/src/views/MaterialBorrow.vue index 5d5c2056..8bf90762 100644 --- a/frontend/src/views/MaterialBorrow.vue +++ b/frontend/src/views/MaterialBorrow.vue @@ -17,10 +17,13 @@ import { formatVenueReservationDateTime, venueReservationTimestamp, } from "../beijingTime"; +import { filterOperationalClubs } from "../forumClubScope"; interface ClubOption { id: number; name: string; + status?: string | null; + auditStatus?: string | null; } interface Material { @@ -329,7 +332,13 @@ function initialClubIdFromRoute() { } function ensureActiveClub() { - if (activeClubId.value !== undefined) return; + if ( + (activeClubId.value === 0 && canViewAllMaterials.value) || + (activeClubId.value !== undefined && + visibleClubs.value.some((club) => club.id === activeClubId.value)) + ) { + return; + } const preferredClubId = initialClubIdFromRoute(); if ( preferredClubId && @@ -352,7 +361,8 @@ async function loadClubs() { clubsLoading.value = true; try { - clubs.value = await requestJson(`/api/v1/clubs?viewerUserId=${userId}`); + const nextClubs = await requestJson(`/api/v1/clubs?viewerUserId=${userId}`); + clubs.value = filterOperationalClubs(nextClubs); ensureActiveClub(); return true; } catch (e) {