diff --git a/api/openapi.yaml b/api/openapi.yaml index 340a4483..c5cbc5c1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1036,7 +1036,7 @@ paths: /api/v1/clubs/{clubId}/members: get: summary: 查询社团成员与干部任期记录 - description: 系统管理员、社团管理员、本社团负责人、干部、成员和指导老师可以只读查看成员及任期;默认仅返回当前有效记录,可选择包含历史记录,并可按届、部门或小组筛选。社团管理员不参与内部任期维护。 + description: 系统管理员、社团管理员、本社团负责人、干部、成员和指导老师可以只读查看成员及任期;默认返回状态有效且尚未到期的当前或未来任期,可选择包含历史记录,并可按届、部门或小组筛选。社团管理员不参与内部任期维护。 operationId: getClubMembers parameters: - $ref: "#/components/parameters/Page" @@ -1304,7 +1304,7 @@ paths: /api/v1/clubs/{clubId}/members/terms: post: summary: 新增社团成员或干部任期 - description: 系统管理员、本社团负责人或指导老师可以为成员新增职位任期;新增任期时可关闭该成员原有效任期以保留历史记录。 + description: 系统管理员、本社团负责人或指导老师可以为成员新增职位任期;新增任期时可关闭该成员此前开始且与新任期重叠的有效任期以保留历史记录。同一成员在同一社团不得重复创建同名任期,选择关闭旧任期时也不得用更早或同日起始的新记录覆盖已有任期。 operationId: createClubMemberTerm parameters: - name: clubId @@ -1332,7 +1332,11 @@ paths: "404": description: 当前用户、社团或目标成员不存在 "409": - description: 社团状态不允许维护 + description: 社团状态不允许维护,或目标成员存在重复、同日起始或逆序重叠任期 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" /api/v1/clubs/{clubId}/members/{memberId}: patch: @@ -6639,7 +6643,7 @@ components: properties: code: type: string - pattern: '^(VALIDATION_ERROR|UNAUTHORIZED|FORBIDDEN|NOT_FOUND|CONFLICT|PAYLOAD_TOO_LARGE|RATE_LIMITED|SERVICE_UNAVAILABLE|INTERNAL_ERROR|REQUEST_FAILED)$' + pattern: "^(VALIDATION_ERROR|UNAUTHORIZED|FORBIDDEN|NOT_FOUND|CONFLICT|PAYLOAD_TOO_LARGE|RATE_LIMITED|SERVICE_UNAVAILABLE|INTERNAL_ERROR|REQUEST_FAILED)$" description: >- 固定的 HTTP 错误类别:VALIDATION_ERROR、UNAUTHORIZED、FORBIDDEN、NOT_FOUND、CONFLICT、 PAYLOAD_TOO_LARGE、RATE_LIMITED、SERVICE_UNAVAILABLE、INTERNAL_ERROR、REQUEST_FAILED。 @@ -7319,7 +7323,8 @@ components: description: 该话题的回复列表;回复对象的该字段为空数组。 items: $ref: "#/components/schemas/ForumPost" - required: [id, clubId, userId, content, isTop, postStatus, createdAt, replies] + required: + [id, clubId, userId, content, isTop, postStatus, createdAt, replies] CreateForumPostRequest: type: object diff --git a/backend.Tests/ClubMemberTermEndpointTests.cs b/backend.Tests/ClubMemberTermEndpointTests.cs new file mode 100644 index 00000000..26e32d4f --- /dev/null +++ b/backend.Tests/ClubMemberTermEndpointTests.cs @@ -0,0 +1,228 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using ClubHub.Api.Data; +using ClubHub.Api.Data.Entities; +using ClubHub.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace ClubHub.Api.Tests; + +public sealed class ClubMemberTermEndpointTests +{ + [Fact] + public async Task GetMembers_DefaultQueryIncludesFutureActiveTerm() + { + await using var factory = new ClubHubWebApplicationFactory(); + var dates = FutureTermDates(2); + using var client = await SeedClubAsync(factory, dates.Start, dates.End, "未来任期"); + + using var response = await client.GetAsync("/api/v1/clubs/830002/members"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var member = Assert.Single(document.RootElement.EnumerateArray()); + Assert.Equal("active", member.GetProperty("memberStatus").GetString()); + Assert.Equal("未来任期", member.GetProperty("termName").GetString()); + Assert.False(member.GetProperty("isCurrent").GetBoolean()); + } + + [Fact] + public async Task CreateMemberTerm_DuplicateTermReturnsConflictWithoutEndingExistingTerm() + { + await using var factory = new ClubHubWebApplicationFactory(); + var dates = FutureTermDates(2); + using var client = await SeedClubAsync(factory, dates.Start, dates.End, "2030-2031学年"); + + using var response = await CreateTermAsync( + client, + "2030-2031学年", + dates.Start, + dates.End); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + using var body = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + Assert.Equal("CONFLICT", body.RootElement.GetProperty("code").GetString()); + Assert.Contains("同名任期", body.RootElement.GetProperty("message").GetString()); + await AssertExistingTermUnchangedAsync(factory, dates.Start, dates.End); + } + + [Fact] + public async Task CreateMemberTerm_EarlierThanExistingActiveTermReturnsConflict() + { + await using var factory = new ClubHubWebApplicationFactory(); + var existingDates = FutureTermDates(3); + var requestedDates = FutureTermDates(2); + using var client = await SeedClubAsync( + factory, + existingDates.Start, + existingDates.End, + "较晚任期"); + + using var response = await CreateTermAsync( + client, + "较早任期", + requestedDates.Start, + requestedDates.End); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + await AssertExistingTermUnchangedAsync(factory, existingDates.Start, existingDates.End); + } + + [Fact] + public async Task CreateMemberTerm_SameStartWithDifferentNameReturnsConflict() + { + await using var factory = new ClubHubWebApplicationFactory(); + var dates = FutureTermDates(2); + using var client = await SeedClubAsync(factory, dates.Start, dates.End, "原任期"); + + using var response = await CreateTermAsync( + client, + "同日起始的新任期", + dates.Start, + dates.End); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + await AssertExistingTermUnchangedAsync(factory, dates.Start, dates.End); + } + + [Fact] + public async Task CreateMemberTerm_LaterTermClosesEarlierOverlappingTerm() + { + await using var factory = new ClubHubWebApplicationFactory(); + var existingStart = new DateTime(DateTime.UtcNow.Year, 7, 1); + var requestedDates = FutureTermDates(2); + var existingEnd = requestedDates.End; + using var client = await SeedClubAsync( + factory, + existingStart, + existingEnd, + "原任期"); + + using var response = await CreateTermAsync( + client, + "新任期", + requestedDates.Start, + requestedDates.End); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var terms = await db.ClubMembers + .Where(member => member.ClubId == 830002 && member.UserId == 830003) + .OrderBy(member => member.TermStart) + .ToListAsync(); + Assert.Equal(2, terms.Count); + Assert.Equal("ended", terms[0].MemberStatus); + Assert.Equal(requestedDates.Start.AddDays(-1), terms[0].TermEnd); + Assert.Equal("active", terms[1].MemberStatus); + Assert.Equal(requestedDates.Start, terms[1].TermStart); + Assert.Equal(requestedDates.End, terms[1].TermEnd); + } + + private static async Task SeedClubAsync( + ClubHubWebApplicationFactory factory, + DateTime existingStart, + DateTime existingEnd, + string existingTermName) + { + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + var principal = new User + { + UserId = 830001, + Username = "member-term-principal", + PasswordHash = "unused", + RealName = "任期测试负责人", + AccountStatus = "normal", + CreatedAt = now + }; + db.Users.AddRange( + principal, + new User + { + UserId = 830003, + Username = "member-term-target", + PasswordHash = "unused", + RealName = "任期测试成员", + AccountStatus = "normal", + CreatedAt = now + }); + db.Clubs.Add(new Club + { + ClubId = 830002, + ClubName = "任期回归测试社团", + PresidentUserId = principal.UserId, + AuditStatus = "approved", + ClubStatus = "active", + CreatedAt = now + }); + db.ClubMembers.Add(new ClubMember + { + MemberId = 830004, + ClubId = 830002, + UserId = 830003, + PositionName = "成员", + TermName = existingTermName, + TermStart = existingStart, + TermEnd = existingEnd, + MemberStatus = "active", + JoinAt = now, + ContributionScore = 0 + }); + await db.SaveChangesAsync(); + + var token = scope.ServiceProvider + .GetRequiredService() + .CreateToken(principal); + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token); + return client; + } + + private static async Task CreateTermAsync( + HttpClient client, + string termName, + DateTime termStart, + DateTime termEnd) + { + return await client.PostAsJsonAsync( + "/api/v1/clubs/830002/members/terms", + new + { + userId = 830003, + positionName = "成员", + termName, + termStart, + termEnd, + memberStatus = "active", + contributionScore = 0, + closeCurrentTerm = true + }); + } + + private static async Task AssertExistingTermUnchangedAsync( + ClubHubWebApplicationFactory factory, + DateTime expectedStart, + DateTime expectedEnd) + { + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var term = Assert.Single(await db.ClubMembers + .Where(member => member.ClubId == 830002 && member.UserId == 830003) + .ToListAsync()); + Assert.Equal("active", term.MemberStatus); + Assert.Equal(expectedStart, term.TermStart); + Assert.Equal(expectedEnd, term.TermEnd); + } + + private static (DateTime Start, DateTime End) FutureTermDates(int yearsAhead) + { + var startYear = DateTime.UtcNow.Year + yearsAhead; + return (new DateTime(startYear, 7, 1), new DateTime(startYear + 1, 6, 30)); + } +} diff --git a/backend.Tests/OpenApiRestContractTests.cs b/backend.Tests/OpenApiRestContractTests.cs index c04b6434..6053166e 100644 --- a/backend.Tests/OpenApiRestContractTests.cs +++ b/backend.Tests/OpenApiRestContractTests.cs @@ -115,7 +115,7 @@ public void ApiErrorCodeUsesStandardRequiredValues() "REQUEST_FAILED" }, code => Assert.Contains(code, apiErrorSchema, StringComparison.Ordinal)); - Assert.Contains("pattern: '^(", apiErrorSchema, StringComparison.Ordinal); + Assert.Matches(@"pattern: ['\""]\^\(", apiErrorSchema); Assert.Contains(" required:\n - code", NormalizeNewLines(apiErrorSchema), StringComparison.Ordinal); } diff --git a/backend/Controllers/ClubsController.cs b/backend/Controllers/ClubsController.cs index f97f2f4d..dac16081 100644 --- a/backend/Controllers/ClubsController.cs +++ b/backend/Controllers/ClubsController.cs @@ -1,3 +1,4 @@ +using System.Data; using System.ComponentModel.DataAnnotations; using System.Text.Json; using System.Text.RegularExpressions; @@ -9,6 +10,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Org.OpenAPITools.Converters; using CreateClubDepartmentRequest = Org.OpenAPITools.Models.CreateClubDepartmentRequest; using CreateClubGroupRequest = Org.OpenAPITools.Models.CreateClubGroupRequest; @@ -56,6 +58,9 @@ public class ClubsController : ControllerBase private const decimal ActivityCheckedOutScore = 10m; private const decimal ActivityCheckedInScore = 8m; private const decimal ActivityAcceptedScore = 5m; + private const int MaxMemberTermWriteRetries = 3; + private const string MemberTermUserRowLockSql = + "SELECT USER_ID FROM USERS WHERE USER_ID = :userId FOR UPDATE"; private readonly ClubHubDbContext _db; private static readonly JsonSerializerOptions RequestJsonOptions = CreateRequestJsonOptions(); @@ -1050,7 +1055,6 @@ public async Task GetMembers( { query = query.Where(cm => (cm.MemberStatus == null || cm.MemberStatus == MemberActive) && - (cm.TermStart == null || cm.TermStart <= today) && (cm.TermEnd == null || cm.TermEnd >= today)); } @@ -1170,82 +1174,105 @@ public async Task CreateMemberTerm(int clubId, [FromBody] CreateC return organization.Result; } - var targetUser = await _db.Users.FindAsync(req.UserId); - if (targetUser is null) + return await ExecuteMemberTermWriteAsync(async () => { - return NotFound(new { message = "目标成员用户不存在。" }); - } + if (!await LockMemberTermUserRowAsync(req.UserId)) + { + return NotFound(new { message = "目标成员用户不存在。" }); + } - if (!UsersController.IsActive(targetUser.AccountStatus)) - { - return BadRequest(new { message = "目标成员账号不可用,不能加入社团任期。" }); - } + var targetUser = await _db.Users.FindAsync(req.UserId); + if (!UsersController.IsActive(targetUser?.AccountStatus)) + { + return BadRequest(new { message = "目标成员账号不可用,不能加入社团任期。" }); + } - var now = DateTime.UtcNow; - var termStart = req.TermStart.Date; - var termEnd = req.TermEnd?.Date; - var memberStatus = ToMemberStatus(req.MemberStatus) ?? MemberActive; - - if (req.CloseCurrentTerm ?? true) - { - var closeDate = termStart.AddDays(-1); - var activeTerms = await _db.ClubMembers - .Where(cm => - cm.ClubId == clubId && - cm.UserId == req.UserId && - (cm.MemberStatus == null || cm.MemberStatus == MemberActive) && - (cm.TermEnd == null || cm.TermEnd >= termStart)) - .ToListAsync(); - - foreach (var activeTerm in activeTerms) + var now = DateTime.UtcNow; + var termStart = req.TermStart.Date; + var termEnd = req.TermEnd?.Date; + var memberStatus = ToMemberStatus(req.MemberStatus) ?? MemberActive; + var termName = req.TermName.Trim(); + var normalizedTermName = termName.ToUpperInvariant(); + + var hasDuplicateTerm = await _db.ClubMembers.AnyAsync(cm => + cm.ClubId == clubId && + cm.UserId == req.UserId && + cm.TermName != null && + cm.TermName.Trim().ToUpper() == normalizedTermName); + if (hasDuplicateTerm) { - activeTerm.MemberStatus = MemberEnded; - activeTerm.TermEnd = activeTerm.TermStart is not null && closeDate < activeTerm.TermStart.Value.Date - ? activeTerm.TermStart.Value.Date - : closeDate; + return Conflict(new { message = "该成员在本社团已存在同名任期,请编辑原记录。" }); } - } - var member = new ClubMember - { - ClubId = clubId, - UserId = req.UserId, - PositionName = req.PositionName.Trim(), - TermName = req.TermName.Trim(), - TermStart = termStart, - TermEnd = termEnd, - MemberStatus = memberStatus, - JoinAt = now, - ContributionScore = req.ContributionScore ?? 0 - }; - ApplyMemberOrganization(member, organization.Selection); + if (req.CloseCurrentTerm ?? true) + { + var closeDate = termStart.AddDays(-1); + var activeTerms = await _db.ClubMembers + .Where(cm => + cm.ClubId == clubId && + cm.UserId == req.UserId && + (cm.MemberStatus == null || cm.MemberStatus == MemberActive) && + (cm.TermEnd == null || cm.TermEnd >= termStart)) + .ToListAsync(); + + if (activeTerms.Any(activeTerm => + activeTerm.TermStart is not null && + activeTerm.TermStart.Value.Date >= termStart)) + { + return Conflict(new + { + message = "已有有效任期与新任期同日或更晚开始,请调整日期或编辑原记录。" + }); + } - if (IsCurrentMemberTerm(member) && - await CountCurrentMembershipClubsAsync(req.UserId, clubId) >= MaxStudentClubMemberships) - { - return Conflict(new { message = $"一个学生最多只能同时加入 {MaxStudentClubMemberships} 个社团,当前已达到上限。" }); - } + foreach (var activeTerm in activeTerms) + { + activeTerm.MemberStatus = MemberEnded; + activeTerm.TermEnd = closeDate; + } + } - _db.ClubMembers.Add(member); + var member = new ClubMember + { + ClubId = clubId, + UserId = req.UserId, + PositionName = req.PositionName.Trim(), + TermName = termName, + TermStart = termStart, + TermEnd = termEnd, + MemberStatus = memberStatus, + JoinAt = now, + ContributionScore = req.ContributionScore ?? 0 + }; + ApplyMemberOrganization(member, organization.Selection); - if (IsCurrentMemberTerm(member)) - { - await EnsureClubMemberRoleAsync(clubId, req.UserId, now); - } + if (IsCurrentMemberTerm(member) && + await CountCurrentMembershipClubsAsync(req.UserId, clubId) >= MaxStudentClubMemberships) + { + return Conflict(new { message = $"一个学生最多只能同时加入 {MaxStudentClubMemberships} 个社团,当前已达到上限。" }); + } - if (IsCurrentMemberTerm(member) && IsStrictPrincipalPosition(member.PositionName)) - { - club.PresidentUserId = req.UserId; - club.UpdatedAt = now; - await EnsureSingleClubPresidentRoleAsync(clubId, req.UserId, now); - } + _db.ClubMembers.Add(member); - await _db.SaveChangesAsync(); + if (IsCurrentMemberTerm(member)) + { + await EnsureClubMemberRoleAsync(clubId, req.UserId, now); + } - var created = await MemberQuery().FirstAsync(cm => cm.MemberId == member.MemberId); - return Created( - $"/api/clubs/{clubId}/members?includeHistory=true", - ToMemberRecordDto(created)); + if (IsCurrentMemberTerm(member) && IsStrictPrincipalPosition(member.PositionName)) + { + club.PresidentUserId = req.UserId; + club.UpdatedAt = now; + await EnsureSingleClubPresidentRoleAsync(clubId, req.UserId, now); + } + + await _db.SaveChangesAsync(); + + var created = await MemberQuery().FirstAsync(cm => cm.MemberId == member.MemberId); + return Created( + $"/api/clubs/{clubId}/members?includeHistory=true", + ToMemberRecordDto(created)); + }); } [HttpPatch("{clubId:int}/members/{memberId:int}")] @@ -2506,6 +2533,110 @@ private async Task CountCurrentMembershipClubsAsync(int userId, int? exclud .Count(); } + /// + /// Serializes member-term validation and writes so concurrent requests cannot + /// both pass the overlap checks for the same user. + /// + private async Task ExecuteMemberTermWriteAsync(Func> operation) + { + if (IsInMemoryDatabase()) + { + return await operation(); + } + + for (var attempt = 1; attempt <= MaxMemberTermWriteRetries; attempt++) + { + await using var transaction = await _db.Database.BeginJoinableTransactionAsync(IsolationLevel.Serializable); + try + { + var result = await operation(); + if (IsSuccessfulResult(result)) + { + await transaction.CommitAsync(); + } + else + { + await transaction.RollbackAsync(); + } + + _db.ChangeTracker.Clear(); + return result; + } + catch (Exception ex) when ( + ex is not OperationCanceledException && + ProjectMembershipService.IsRetryableWriteConflict(ex)) + { + await transaction.RollbackAsync(); + _db.ChangeTracker.Clear(); + + if (attempt == MaxMemberTermWriteRetries) + { + return Conflict(new { message = "任期写入发生并发冲突,请稍后重试。" }); + } + } + } + + return Conflict(new { message = "任期写入发生并发冲突,请稍后重试。" }); + } + + /// + /// Locks the stable user row before checking and changing that user's terms. + /// + private async Task LockMemberTermUserRowAsync(int userId) + { + if (IsInMemoryDatabase()) + { + return await _db.Users.AnyAsync(user => user.UserId == userId); + } + + var sql = MemberTermUserRowLockSql; + if (string.Equals( + _db.Database.ProviderName, + "Microsoft.EntityFrameworkCore.Sqlite", + StringComparison.Ordinal)) + { + const string forUpdateSuffix = " FOR UPDATE"; + if (sql.EndsWith(forUpdateSuffix, StringComparison.OrdinalIgnoreCase)) + { + sql = sql[..^forUpdateSuffix.Length]; + } + } + + var connection = _db.Database.GetDbConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = sql; + if (_db.Database.CurrentTransaction is { } transaction) + { + command.Transaction = transaction.GetDbTransaction(); + } + + var parameter = command.CreateParameter(); + parameter.ParameterName = "userId"; + parameter.DbType = DbType.Int32; + parameter.Value = userId; + command.Parameters.Add(parameter); + + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(HttpContext.RequestAborted); + } + + var result = await command.ExecuteScalarAsync(HttpContext.RequestAborted); + return result is not null && result != DBNull.Value; + } + + private static bool IsSuccessfulResult(IActionResult result) => result switch + { + ObjectResult { StatusCode: >= 200 and < 300 } => true, + StatusCodeResult { StatusCode: >= 200 and < 300 } => true, + _ => false + }; + + private bool IsInMemoryDatabase() => string.Equals( + _db.Database.ProviderName, + "Microsoft.EntityFrameworkCore.InMemory", + StringComparison.Ordinal); + private async Task RemoveOngoingAcceptedRecruitmentApplicationsAsync(int clubId, int userId) { var now = BusinessNow(); diff --git a/docs/images/pr-230/current-and-upcoming.png b/docs/images/pr-230/current-and-upcoming.png new file mode 100644 index 00000000..0d978d77 Binary files /dev/null and b/docs/images/pr-230/current-and-upcoming.png differ diff --git a/docs/images/pr-230/transition-management.png b/docs/images/pr-230/transition-management.png new file mode 100644 index 00000000..3b343c42 Binary files /dev/null and b/docs/images/pr-230/transition-management.png differ diff --git a/frontend/src/api/apis/DefaultApi.ts b/frontend/src/api/apis/DefaultApi.ts index fd0b072b..7b655271 100644 --- a/frontend/src/api/apis/DefaultApi.ts +++ b/frontend/src/api/apis/DefaultApi.ts @@ -4462,7 +4462,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** - * 系统管理员、本社团负责人或指导老师可以为成员新增职位任期;新增任期时可关闭该成员原有效任期以保留历史记录。 + * 系统管理员、本社团负责人或指导老师可以为成员新增职位任期;新增任期时可关闭该成员此前开始且与新任期重叠的有效任期以保留历史记录。同一成员在同一社团不得重复创建同名任期,选择关闭旧任期时也不得用更早或同日起始的新记录覆盖已有任期。 * 新增社团成员或干部任期 */ async createClubMemberTermRaw( @@ -4478,7 +4478,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** - * 系统管理员、本社团负责人或指导老师可以为成员新增职位任期;新增任期时可关闭该成员原有效任期以保留历史记录。 + * 系统管理员、本社团负责人或指导老师可以为成员新增职位任期;新增任期时可关闭该成员此前开始且与新任期重叠的有效任期以保留历史记录。同一成员在同一社团不得重复创建同名任期,选择关闭旧任期时也不得用更早或同日起始的新记录覆盖已有任期。 * 新增社团成员或干部任期 */ async createClubMemberTerm( @@ -7569,7 +7569,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** - * 系统管理员、社团管理员、本社团负责人、干部、成员和指导老师可以只读查看成员及任期;默认仅返回当前有效记录,可选择包含历史记录,并可按届、部门或小组筛选。社团管理员不参与内部任期维护。 + * 系统管理员、社团管理员、本社团负责人、干部、成员和指导老师可以只读查看成员及任期;默认返回状态有效且尚未到期的当前或未来任期,可选择包含历史记录,并可按届、部门或小组筛选。社团管理员不参与内部任期维护。 * 查询社团成员与干部任期记录 */ async getClubMembersRaw( @@ -7585,7 +7585,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** - * 系统管理员、社团管理员、本社团负责人、干部、成员和指导老师可以只读查看成员及任期;默认仅返回当前有效记录,可选择包含历史记录,并可按届、部门或小组筛选。社团管理员不参与内部任期维护。 + * 系统管理员、社团管理员、本社团负责人、干部、成员和指导老师可以只读查看成员及任期;默认返回状态有效且尚未到期的当前或未来任期,可选择包含历史记录,并可按届、部门或小组筛选。社团管理员不参与内部任期维护。 * 查询社团成员与干部任期记录 */ async getClubMembers( diff --git a/frontend/src/defenseBusinessRules.test.ts b/frontend/src/defenseBusinessRules.test.ts index f0e9485b..eab1eb7d 100644 --- a/frontend/src/defenseBusinessRules.test.ts +++ b/frontend/src/defenseBusinessRules.test.ts @@ -56,4 +56,18 @@ describe("答辩业务入口规则", () => { expect(clubListSource).toContain('message: "请填写材料链接或归档编号"'); expect(clubListSource.match(/label="材料链接或归档编号"/g)).toHaveLength(4); }); + + it("keeps academic-year choices stable and shows future member terms after refresh", () => { + expect(clubListSource).toContain( + "Array.from({ length: 101 }, (_, index) => academicTermOption(2000 + index))", + ); + expect(clubListSource).toContain("activeOrFutureClubMembers.value"); + expect(clubListSource).toContain("{ label: '当前与待生效', value: 'current' }"); + expect(clubListSource).toContain("sortMemberRows(filteredRows, memberSortMode.value)"); + expect(clubListSource).toContain("组织信息待完善"); + expect(clubListSource).not.toContain("!member.groupName?.trim()"); + expect(clubListSource).not.toContain("待补资料"); + expect(clubListSource).not.toContain("manualAcademicTermOptions"); + expect(clubListSource).not.toContain("新增学年"); + }); }); diff --git a/frontend/src/views/ClubList.vue b/frontend/src/views/ClubList.vue index a11ebf1b..a225ea2e 100644 --- a/frontend/src/views/ClubList.vue +++ b/frontend/src/views/ClubList.vue @@ -267,7 +267,6 @@ const clubs = ref([]); const applications = ref([]); const clubMembers = ref([]); const clubDepartments = ref([]); -const manualAcademicTermOptions = ref([]); const loading = ref(true); const usersLoading = ref(true); const dialogUsersLoading = ref(false); @@ -315,10 +314,9 @@ const memberFilters = reactive({ termName: "", departmentId: undefined as number | undefined, groupId: undefined as number | undefined, - unassignedOnly: false, + incompleteOnly: false, }); const memberSortMode = ref("organization"); -const newAcademicTermStartYear = ref(academicYearStart(new Date()) + 3); const applicationDialogVisible = ref(false); const applicationFormRef = ref(); @@ -637,7 +635,6 @@ const selectedDepartmentManagerScopes = computed(() => const canCreateMemberDepartment = computed( () => isOrganizationWorkspace.value && canManageSelectedClub.value, ); -const canCreateAcademicTerm = computed(() => canManageSelectedClub.value); const canCreateMemberGroup = computed( () => isOrganizationWorkspace.value && groupCreateDepartmentOptions.value.length > 0, ); @@ -704,10 +701,7 @@ const selectedBatchPositionRows = computed(() => selectedMemberRows.value.filter((row) => canBatchUpdateMemberTerm(row)), ); const academicTermOptions = computed(() => { - const currentYear = academicYearStart(new Date()); - const baseTerms = [currentYear - 1, currentYear, currentYear + 1, currentYear + 2].map((year) => - academicTermOption(year), - ); + const baseTerms = Array.from({ length: 101 }, (_, index) => academicTermOption(2000 + index)); const memberTerms = clubMembers.value .filter((member) => member.termName && member.termStart && member.termEnd) .map((member) => ({ @@ -716,11 +710,7 @@ const academicTermOptions = computed(() => { termEnd: dateOnly(member.termEnd), })); - return uniqueAcademicTermOptions([ - ...baseTerms, - ...manualAcademicTermOptions.value, - ...memberTerms, - ]); + return uniqueAcademicTermOptions([...baseTerms, ...memberTerms]); }); const memberTermSelectOptions = computed(() => { const options = [...academicTermOptions.value]; @@ -741,6 +731,13 @@ const memberTermFilterOptions = computed(() => ]), ); const currentClubMembers = computed(() => clubMembers.value.filter((member) => member.isCurrent)); +const activeOrFutureClubMembers = computed(() => + clubMembers.value.filter( + (member) => + isActiveStatus(member.memberStatus) && + (!dateOnly(member.termEnd) || dateOnly(member.termEnd) >= todayDateOnly()), + ), +); const currentActiveClubMembers = computed(() => currentClubMembers.value.filter((member) => isActiveStatus(member.memberStatus)), ); @@ -773,13 +770,13 @@ const organizationGroupMembers = computed(() => { }); const memberTableRows = computed(() => { const rows = ( - memberWorkspaceMode.value === "history" ? clubMembers.value : currentClubMembers.value + memberWorkspaceMode.value === "history" ? clubMembers.value : activeOrFutureClubMembers.value ).filter( (member) => (memberWorkspaceMode.value !== "history" || !memberFilters.termName || member.termName === memberFilters.termName) && - (!memberFilters.unassignedOnly || isMemberUnassigned(member)), + (!memberFilters.incompleteOnly || hasIncompleteMemberOrganization(member)), ); return sortMemberRows(rows, memberSortMode.value); @@ -796,19 +793,21 @@ const transitionSourceRows = computed(() => { } }); - return Array.from(rows.values()).sort((left, right) => - transitionRecordSortKey(right).localeCompare(transitionRecordSortKey(left)), + const filteredRows = Array.from(rows.values()).filter( + (member) => !memberFilters.incompleteOnly || hasIncompleteMemberOrganization(member), ); + return sortMemberRows(filteredRows, memberSortMode.value); }); const memberGroupSummary = computed(() => { - const rows = memberTableRows.value; + const rows = + memberWorkspaceMode.value === "transition" ? transitionSourceRows.value : memberTableRows.value; const currentRows = rows.filter((member) => member.isCurrent); return { total: rows.length, current: currentRows.length, departments: uniqueTextOptions(rows.map((member) => member.departmentName)).length, groups: uniqueTextOptions(rows.map((member) => member.groupName)).length, - unassigned: rows.filter(isMemberUnassigned).length, + incomplete: rows.filter(hasIncompleteMemberOrganization).length, }; }); const organizationSummary = computed(() => { @@ -1988,29 +1987,6 @@ async function submitMemberTerm() { } } -function addAcademicTermOption() { - if (!canCreateAcademicTerm.value) return; - - const startYear = Number(newAcademicTermStartYear.value); - if (!Number.isInteger(startYear) || startYear < 2000 || startYear > 2100) { - ElMessage.warning("请输入 2000-2100 之间的学年起始年份。"); - return; - } - - const term = academicTermOption(startYear); - if (academicTermOptions.value.some((option) => option.label === term.label)) { - ElMessage.info("该任期已存在。"); - return; - } - - manualAcademicTermOptions.value = uniqueAcademicTermOptions([ - ...manualAcademicTermOptions.value, - term, - ]); - newAcademicTermStartYear.value = startYear + 1; - ElMessage.success("任期已加入可选项"); -} - function resetFilters() { filters.auditStatus = ""; filters.keyword = ""; @@ -2022,11 +1998,11 @@ function clearMemberFilters() { memberFilters.termName = ""; memberFilters.departmentId = undefined; memberFilters.groupId = undefined; - memberFilters.unassignedOnly = false; + memberFilters.incompleteOnly = false; void loadMembers(); } -function toggleUnassignedMemberFilter(value: string | number | boolean) { +function toggleIncompleteMemberFilter(value: string | number | boolean) { if (!value) return; memberFilters.departmentId = undefined; @@ -2976,10 +2952,8 @@ function uniqueTextOptions(values: Array) { ).sort((left, right) => left.localeCompare(right, "zh-CN")); } -function isMemberUnassigned(member: ClubMemberRecord) { - return ( - !member.departmentName?.trim() || !member.groupName?.trim() || !member.positionName?.trim() - ); +function hasIncompleteMemberOrganization(member: ClubMemberRecord) { + return !member.departmentName?.trim() || !member.positionName?.trim(); } watch(currentUserId, () => { @@ -2990,7 +2964,7 @@ watch(selectedClubId, () => { memberFilters.departmentId = undefined; memberFilters.groupId = undefined; memberFilters.termName = ""; - memberFilters.unassignedOnly = false; + memberFilters.incompleteOnly = false; resetDepartmentForm(); resetGroupForm(); organizationTreeCollapsed.value = false; @@ -4019,7 +3993,7 @@ onUnmounted(() => { { v-model="memberFilters.departmentId" class="filter-item" clearable - :disabled="memberFilters.unassignedOnly" + :disabled="memberFilters.incompleteOnly" placeholder="按部门筛选" @change="handleMemberFilterDepartmentChange" > @@ -4069,7 +4043,7 @@ onUnmounted(() => { v-model="memberFilters.groupId" class="filter-item" clearable - :disabled="memberFilters.unassignedOnly" + :disabled="memberFilters.incompleteOnly" placeholder="按小组筛选" > { /> - 待补资料 + 组织信息待完善 清除筛选 {
- 当前列表 {{ memberGroupSummary.total }} 条 - 有效任期 {{ memberGroupSummary.current }} 条 + + {{ memberWorkspaceMode === "transition" ? "待换届" : "当前列表" }} + {{ memberGroupSummary.total }} 条 + + + 当前任期 {{ memberGroupSummary.current }} 条 + 部门 {{ memberGroupSummary.departments }} 个 小组 {{ memberGroupSummary.groups }} 个 - 待补资料 {{ memberGroupSummary.unassigned }} 条 -
- - - 新增学年 - -
+ 组织信息待完善 {{ memberGroupSummary.incomplete }} 条
@@ -4154,8 +4113,8 @@ onUnmounted(() => { :empty-text=" memberWorkspaceMode === 'current' ? canManageSelectedClub - ? '暂无当前名册,可新增成员任期' - : '暂无当前有效成员任期' + ? '暂无当前或待生效任期,可新增成员任期' + : '暂无当前或待生效成员任期' : '暂无历史任期记录' " row-key="memberId" @@ -4206,7 +4165,7 @@ onUnmounted(() => { - +