using System.Data; using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/campus-registrations")] public sealed class CampusRegistrationsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase { private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; [HttpGet("projects")] public async Task GetProjects([FromQuery] string? keyword, CancellationToken ct) { var source = ScopedProjects().AsNoTracking(); if (!string.IsNullOrWhiteSpace(keyword)) source = source.Where(x => x.Name.Contains(keyword) || x.Code.Contains(keyword)); if (scope.Current.IsInRole(SystemRoles.Student)) source = source.Where(x => x.Status == CampusRegistrationProjectStatus.Published); var now = DateTime.UtcNow; return Ok(await source.OrderByDescending(x => x.RegistrationEndsAt).Select(x => new { x.Id, x.Code, x.Name, x.Description, x.RegistrationStartsAt, x.RegistrationEndsAt, x.Capacity, x.FeeAmount, x.RequiresReview, x.Status, x.ContactName, x.ContactPhone, x.Notes, CollegeName = x.College == null ? "全校" : x.College.Name, ConfirmedCount = x.Applications.Count(a => a.Status == CampusRegistrationApplicationStatus.Confirmed), IsAvailableNow = x.Status == CampusRegistrationProjectStatus.Published && now >= x.RegistrationStartsAt && now <= x.RegistrationEndsAt }).ToListAsync(ct)); } [HttpPost("projects")] [Authorize(Roles = Managers)] public async Task CreateProject(ProjectRequest request, CancellationToken ct) { var error = ValidateProject(request); if (error is not null) return BadRequest(new { message = error }); if (!CanManageCollege(request.CollegeId)) return Forbid(); var code = request.Code.Trim().ToUpperInvariant(); if (await db.CampusRegistrationProjects.AnyAsync(x => x.Code == code, ct)) return Conflict(new { message = "项目编码已存在。" }); var project = new CampusRegistrationProject { Code = code, Name = request.Name.Trim(), Description = TrimOrNull(request.Description), CollegeId = request.CollegeId, RegistrationStartsAt = request.RegistrationStartsAt, RegistrationEndsAt = request.RegistrationEndsAt, Capacity = request.Capacity, FeeAmount = request.FeeAmount, RequiresReview = request.RequiresReview, Status = request.Status, ContactName = TrimOrNull(request.ContactName), ContactPhone = TrimOrNull(request.ContactPhone), Notes = TrimOrNull(request.Notes), CreatedByUserId = scope.Current.UserId }; db.CampusRegistrationProjects.Add(project); await db.SaveChangesAsync(ct); return Created($"api/campus-registrations/projects/{project.Id}", new { project.Id }); } [HttpPut("projects/{id:guid}")] [Authorize(Roles = Managers)] public async Task UpdateProject(Guid id, ProjectRequest request, CancellationToken ct) { var project = await db.CampusRegistrationProjects.FindAsync([id], ct); if (project is null) return NotFound(); if (!CanManageProject(project)) return Forbid(); var error = ValidateProject(request); if (error is not null) return BadRequest(new { message = error }); if (!CanManageCollege(request.CollegeId)) return Forbid(); var code = request.Code.Trim().ToUpperInvariant(); if (await db.CampusRegistrationProjects.AnyAsync(x => x.Id != id && x.Code == code, ct)) return Conflict(new { message = "项目编码已存在。" }); project.Code = code; project.Name = request.Name.Trim(); project.Description = TrimOrNull(request.Description); project.CollegeId = request.CollegeId; project.RegistrationStartsAt = request.RegistrationStartsAt; project.RegistrationEndsAt = request.RegistrationEndsAt; project.Capacity = request.Capacity; project.FeeAmount = request.FeeAmount; project.RequiresReview = request.RequiresReview; project.Status = request.Status; project.ContactName = TrimOrNull(request.ContactName); project.ContactPhone = TrimOrNull(request.ContactPhone); project.Notes = TrimOrNull(request.Notes); await db.SaveChangesAsync(ct); return NoContent(); } [HttpPost("projects/{id:guid}/apply")] [Authorize(Roles = SystemRoles.Student)] public async Task Apply(Guid id, ApplyRequest request, CancellationToken ct) { var student = await db.Students.SingleOrDefaultAsync(x => x.UserId == scope.Current.UserId, ct); if (student is null) return BadRequest(new { message = "当前账号未关联学生档案。" }); var project = await db.CampusRegistrationProjects.Include(x => x.Applications).SingleOrDefaultAsync(x => x.Id == id, ct); if (project is null || !CanStudentAccessProject(project, student)) return NotFound(); var now = DateTime.UtcNow; if (project.Status != CampusRegistrationProjectStatus.Published || now < project.RegistrationStartsAt || now > project.RegistrationEndsAt) return Conflict(new { message = "当前不在报名时间内。" }); if (await db.CampusRegistrationApplications.AnyAsync(x => x.ProjectId == id && x.StudentId == student.Id, ct)) return Conflict(new { message = "请勿重复报名。" }); var confirmed = project.Applications.Count(x => x.Status == CampusRegistrationApplicationStatus.Confirmed); var status = confirmed >= project.Capacity ? CampusRegistrationApplicationStatus.Waitlisted : project.RequiresReview ? CampusRegistrationApplicationStatus.PendingReview : CampusRegistrationApplicationStatus.PendingPayment; var application = new CampusRegistrationApplication { ProjectId = id, StudentId = student.Id, Status = status, ApplicantNote = TrimOrNull(request.Note) }; db.CampusRegistrationApplications.Add(application); if (status == CampusRegistrationApplicationStatus.PendingPayment) CreateOrder(application, project); await db.SaveChangesAsync(ct); return Created($"api/campus-registrations/applications/{application.Id}", new { application.Id, application.Status }); } [HttpGet("mine")] [Authorize(Roles = SystemRoles.Student)] public async Task Mine(CancellationToken ct) => Ok(await ApplicationsForCurrentStudent() .OrderByDescending(x => x.CreatedAt).Select(ProjectApplicationDto()).ToListAsync(ct)); [HttpGet("wallet/mine")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyWallet(CancellationToken ct) { var wallet = await db.CampusWalletAccounts.AsNoTracking() .SingleOrDefaultAsync(x => x.Student!.UserId == scope.Current.UserId, ct); return Ok(new { balance = wallet?.Balance ?? 0m }); } [HttpPost("wallets/students/{studentId:guid}/adjust")] [Authorize(Roles = Managers)] public async Task AdjustWallet(Guid studentId, WalletAdjustmentRequest request, CancellationToken ct) { if (request.Amount == 0 || string.IsNullOrWhiteSpace(request.Reason)) return BadRequest(new { message = "请填写非零调整金额和调整原因。" }); var student = await db.Students.Include(x => x.AdministrativeClass).ThenInclude(x => x!.Major) .SingleOrDefaultAsync(x => x.Id == studentId, ct); if (student is null) return NotFound(); if (!CanManageCollege(student.AdministrativeClass!.Major!.CollegeId)) return Forbid(); var wallet = await db.CampusWalletAccounts.SingleOrDefaultAsync(x => x.StudentId == studentId, ct); if (wallet is null) { wallet = new CampusWalletAccount { StudentId = studentId, Balance = 0m }; db.CampusWalletAccounts.Add(wallet); } var nextBalance = wallet.Balance + request.Amount; if (nextBalance < 0) return Conflict(new { message = "调整后余额不能小于零。" }); wallet.Balance = nextBalance; db.CampusWalletTransactions.Add(new CampusWalletTransaction { WalletAccount = wallet, Amount = request.Amount, BalanceAfter = nextBalance, Type = CampusWalletTransactionType.AdministratorAdjustment, Reason = request.Reason.Trim(), OperatedByUserId = scope.Current.UserId }); await db.SaveChangesAsync(ct); return Ok(new { balance = wallet.Balance }); } [HttpGet("wallets/students")] [Authorize(Roles = Managers)] public async Task GetStudentWallets( [FromQuery] string? keyword, [FromQuery] int? grade, [FromQuery] Guid? collegeId, [FromQuery] Guid? majorId, [FromQuery] Guid? administrativeClassId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) { var students = db.Students.AsNoTracking().AsQueryable(); if (scope.Current.RestrictedCollegeId is { } restrictedCollegeId) { if (collegeId.HasValue && collegeId != restrictedCollegeId) return Forbid(); collegeId = restrictedCollegeId; } if (collegeId.HasValue) students = students.Where(x => x.AdministrativeClass!.Major!.CollegeId == collegeId); if (majorId.HasValue) students = students.Where(x => x.AdministrativeClass!.MajorId == majorId); if (administrativeClassId.HasValue) students = students.Where(x => x.AdministrativeClassId == administrativeClassId); if (grade.HasValue) students = students.Where(x => x.AdministrativeClass!.Grade == grade); if (!string.IsNullOrWhiteSpace(keyword)) { var term = keyword.Trim(); students = students.Where(x => x.StudentNumber.Contains(term) || x.Name.Contains(term)); } var total = await students.CountAsync(ct); page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); var items = await students.OrderBy(x => x.StudentNumber).Skip((page - 1) * pageSize).Take(pageSize) .Select(x => new { x.Id, x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, MajorName = x.AdministrativeClass.Major.Name, ClassName = x.AdministrativeClass.Name, Balance = db.CampusWalletAccounts.Where(wallet => wallet.StudentId == x.Id) .Select(wallet => (decimal?)wallet.Balance).FirstOrDefault() ?? 0m, LastChangedAt = db.CampusWalletTransactions.Where(transaction => transaction.WalletAccount!.StudentId == x.Id) .OrderByDescending(transaction => transaction.CreatedAt).Select(transaction => (DateTime?)transaction.CreatedAt).FirstOrDefault() }).ToListAsync(ct); return Ok(new PagedResult(items.Cast().ToList(), total, page, pageSize)); } [HttpGet("wallets/filter-options")] [Authorize(Roles = Managers)] public async Task GetWalletFilterOptions(CancellationToken ct) { var classes = db.AdministrativeClasses.AsNoTracking().AsQueryable(); if (scope.Current.RestrictedCollegeId is { } collegeId) classes = classes.Where(x => x.Major!.CollegeId == collegeId); return Ok(new { colleges = await db.Colleges.AsNoTracking() .Where(x => scope.Current.RestrictedCollegeId == null || x.Id == scope.Current.RestrictedCollegeId) .OrderBy(x => x.Name).Select(x => new { x.Id, x.Name }).ToListAsync(ct), majors = await db.Majors.AsNoTracking() .Where(x => scope.Current.RestrictedCollegeId == null || x.CollegeId == scope.Current.RestrictedCollegeId) .OrderBy(x => x.Name).Select(x => new { x.Id, x.Name, x.CollegeId }).ToListAsync(ct), classes = await classes.OrderBy(x => x.Grade).ThenBy(x => x.Name) .Select(x => new { x.Id, x.Name, x.Grade, x.MajorId }).ToListAsync(ct) }); } [HttpGet("wallets/adjustment-template.xlsx")] [Authorize(Roles = Managers)] public IActionResult DownloadWalletAdjustmentTemplate() => File( ExcelWorkbookHelper.Create("余额批量调整", ["学号", "调整金额", "调整原因"], [], ["调整金额:正数充值,负数扣减;调整后余额不得小于零。", "每行都会生成独立余额流水,调整原因必填。"]), ExcelWorkbookHelper.ContentType, DownloadFileName.Template("学生余额批量调整", ".xlsx")); [HttpGet("wallets/export.xlsx")] [Authorize(Roles = Managers)] public async Task ExportWallets(CancellationToken ct) { var students = db.Students.AsNoTracking().AsQueryable(); if (scope.Current.RestrictedCollegeId is { } collegeId) students = students.Where(x => x.AdministrativeClass!.Major!.CollegeId == collegeId); var rows = await students.OrderBy(x => x.StudentNumber).Select(x => new { x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, MajorName = x.AdministrativeClass.Major.Name, ClassName = x.AdministrativeClass.Name, Balance = db.CampusWalletAccounts.Where(a => a.StudentId == x.Id).Select(a => (decimal?)a.Balance).FirstOrDefault() ?? 0m }).ToListAsync(ct); return File(ExcelWorkbookHelper.Create("学生余额", ["学号", "姓名", "学院", "专业", "行政班", "当前余额"], rows.Select(x => (IReadOnlyList)[x.StudentNumber, x.Name, x.CollegeName, x.MajorName, x.ClassName, x.Balance])), ExcelWorkbookHelper.ContentType, DownloadFileName.Export("学生余额", ".xlsx")); } [HttpPost("wallets/import-adjustments")] [Authorize(Roles = Managers)] public async Task ImportWalletAdjustments(IFormFile file, CancellationToken ct) { IReadOnlyList rows; try { rows = await ExcelWorkbookHelper.ReadAsync(file, ["学号", "调整金额", "调整原因"], ct); } catch (InvalidDataException ex) { return BadRequest(new { message = ex.Message }); } var errors = new List(); var inputs = new List<(string Number, decimal Amount, string Reason)>(); foreach (var row in rows) { if (!decimal.TryParse(row["调整金额"], out var amount) || amount == 0 || string.IsNullOrWhiteSpace(row["学号"]) || string.IsNullOrWhiteSpace(row["调整原因"])) errors.Add($"第 {row.RowNumber} 行:请填写学号、非零调整金额和调整原因。"); else inputs.Add((row["学号"], amount, row["调整原因"].Trim())); } var students = await db.Students.Include(x => x.AdministrativeClass).ThenInclude(x => x!.Major).Where(x => inputs.Select(i => i.Number).Contains(x.StudentNumber)).ToDictionaryAsync(x => x.StudentNumber, ct); foreach (var input in inputs) if (!students.TryGetValue(input.Number, out var student) || !CanManageCollege(student.AdministrativeClass!.Major!.CollegeId)) errors.Add($"学号 {input.Number} 不存在或不在当前管理范围内。"); if (errors.Count > 0) return BadRequest(new { message = string.Join(";", errors.Take(20)) }); var accounts = await db.CampusWalletAccounts.Where(x => inputs.Select(i => students[i.Number].Id).Contains(x.StudentId)).ToDictionaryAsync(x => x.StudentId, ct); foreach (var input in inputs) { var student = students[input.Number]; if (!accounts.TryGetValue(student.Id, out var account)) { account = new CampusWalletAccount { StudentId = student.Id }; db.CampusWalletAccounts.Add(account); accounts[student.Id] = account; } var balance = account.Balance + input.Amount; if (balance < 0) { errors.Add($"学号 {input.Number} 调整后余额不能小于零。"); continue; } account.Balance = balance; db.CampusWalletTransactions.Add(new CampusWalletTransaction { WalletAccount = account, Amount = input.Amount, BalanceAfter = balance, Type = CampusWalletTransactionType.AdministratorAdjustment, Reason = input.Reason, OperatedByUserId = scope.Current.UserId }); } if (errors.Count > 0) return BadRequest(new { message = string.Join(";", errors) }); await db.SaveChangesAsync(ct); return Ok(new { adjusted = inputs.Count }); } [HttpPost("applications/{id:guid}/cancel")] [Authorize(Roles = SystemRoles.Student)] public async Task Cancel(Guid id, CancellationToken ct) { var application = await db.CampusRegistrationApplications.Include(x => x.Project).Include(x => x.PaymentOrder).SingleOrDefaultAsync(x => x.Id == id && x.Student!.UserId == scope.Current.UserId, ct); if (application is null) return NotFound(); if (application.Status is CampusRegistrationApplicationStatus.Confirmed or CampusRegistrationApplicationStatus.Rejected or CampusRegistrationApplicationStatus.Cancelled) return Conflict(new { message = "当前报名不能撤销。" }); application.Status = CampusRegistrationApplicationStatus.Cancelled; application.CancelledAt = DateTime.UtcNow; if (application.PaymentOrder?.Status == CampusPaymentOrderStatus.Pending) application.PaymentOrder.Status = CampusPaymentOrderStatus.Cancelled; try { await db.SaveChangesAsync(ct); return NoContent(); } catch (DbUpdateConcurrencyException) { return Conflict(new { message = "该报名已被其他管理员处理,请刷新后查看最新状态。" }); } } [HttpGet("applications")] [Authorize(Roles = Managers)] public async Task GetApplications([FromQuery] Guid? projectId, [FromQuery] CampusRegistrationApplicationStatus? status, CancellationToken ct) { var source = ScopedApplications().AsNoTracking(); if (projectId.HasValue) source = source.Where(x => x.ProjectId == projectId); if (status.HasValue) source = source.Where(x => x.Status == status); return Ok(await source.OrderByDescending(x => x.CreatedAt).Select(ProjectApplicationDto()).ToListAsync(ct)); } [HttpGet("applications/export.xlsx")] [Authorize(Roles = Managers)] public async Task ExportApplications([FromQuery] Guid? projectId, CancellationToken ct) { var rows = await ScopedApplications().AsNoTracking().Where(x => !projectId.HasValue || x.ProjectId == projectId).OrderByDescending(x => x.CreatedAt) .Select(x => new { x.Project!.Code, ProjectName = x.Project.Name, x.Student!.StudentNumber, StudentName = x.Student.Name, Status = x.Status.ToString(), x.CreatedAt, Amount = x.PaymentOrder == null ? 0m : x.PaymentOrder.Amount, PaymentStatus = x.PaymentOrder == null ? "—" : x.PaymentOrder.Status.ToString(), x.ReviewComment }).ToListAsync(ct); var content = ExcelWorkbookHelper.Create("校园报名与缴费", ["项目编码", "项目名称", "学号", "姓名", "报名状态", "提交时间", "应收金额", "支付状态", "审核意见"], rows.Select(x => (IReadOnlyList)[x.Code, x.ProjectName, x.StudentNumber, x.StudentName, x.Status, x.CreatedAt, x.Amount, x.PaymentStatus, x.ReviewComment]).ToList()); return File(content, ExcelWorkbookHelper.ContentType, DownloadFileName.Export("校园报名与缴费", ".xlsx")); } [HttpPost("applications/{id:guid}/review")] [Authorize(Roles = Managers)] public async Task Review(Guid id, ReviewRequest request, CancellationToken ct) { var application = await db.CampusRegistrationApplications.Include(x => x.Project).SingleOrDefaultAsync(x => x.Id == id, ct); if (application is null) return NotFound(); if (!CanManageProject(application.Project!)) return Forbid(); if (application.Status != CampusRegistrationApplicationStatus.PendingReview) return Conflict(new { message = "该报名当前无需审核。" }); application.ReviewedByUserId = scope.Current.UserId; application.ReviewedAt = DateTime.UtcNow; application.ReviewComment = TrimOrNull(request.Comment); if (!request.Approved) application.Status = CampusRegistrationApplicationStatus.Rejected; else if (await ConfirmedCount(application.ProjectId, ct) >= application.Project!.Capacity) application.Status = CampusRegistrationApplicationStatus.Waitlisted; else { application.Status = CampusRegistrationApplicationStatus.PendingPayment; CreateOrder(application, application.Project!); } try { await db.SaveChangesAsync(ct); return NoContent(); } catch (DbUpdateConcurrencyException) { return Conflict(new { message = "该报名已被其他管理员处理,请刷新后查看最新状态。" }); } } [HttpPost("orders/{id:guid}/confirm-offline")] [Authorize(Roles = Managers)] public async Task ConfirmOffline(Guid id, OfflinePaymentRequest request, CancellationToken ct) { var order = await db.CampusPaymentOrders.Include(x => x.Application).ThenInclude(x => x!.Project).SingleOrDefaultAsync(x => x.Id == id, ct); if (order is null) return NotFound(); if (!CanManageProject(order.Application!.Project!)) return Forbid(); if (order.Status != CampusPaymentOrderStatus.Pending || order.PaymentMethod != CampusPaymentMethod.Offline) return Conflict(new { message = "该订单不能线下确认。" }); if (await ConfirmedCount(order.Application.ProjectId, ct) >= order.Application.Project!.Capacity) { order.Application.Status = CampusRegistrationApplicationStatus.Waitlisted; await db.SaveChangesAsync(ct); return Conflict(new { message = "名额已满,报名已转为候补。" }); } order.Status = CampusPaymentOrderStatus.Paid; order.ConfirmedAt = DateTime.UtcNow; order.ConfirmedByUserId = scope.Current.UserId; order.ReferenceNumber = TrimOrNull(request.ReferenceNumber); order.Note = TrimOrNull(request.Note); order.Application.Status = CampusRegistrationApplicationStatus.Confirmed; order.Application.ConfirmedAt = DateTime.UtcNow; try { await db.SaveChangesAsync(ct); return NoContent(); } catch (DbUpdateConcurrencyException) { return Conflict(new { message = "该订单已被其他管理员处理,请刷新后查看最新状态。" }); } } [HttpPost("orders/{id:guid}/pay-balance")] [Authorize(Roles = SystemRoles.Student)] public async Task PayByBalance(Guid id, CancellationToken ct) { var order = await db.CampusPaymentOrders.Include(x => x.Application).ThenInclude(x => x!.Project) .SingleOrDefaultAsync(x => x.Id == id && x.Application!.Student!.UserId == scope.Current.UserId, ct); if (order is null) return NotFound(); if (order.Status != CampusPaymentOrderStatus.Pending || order.Application!.Status != CampusRegistrationApplicationStatus.PendingPayment) return Conflict(new { message = "该订单当前不能使用余额支付。" }); if (await ConfirmedCount(order.Application.ProjectId, ct) >= order.Application.Project!.Capacity) { order.Application.Status = CampusRegistrationApplicationStatus.Waitlisted; await db.SaveChangesAsync(ct); return Conflict(new { message = "名额已满,报名已转为候补。" }); } await using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); var debited = await db.CampusWalletAccounts .Where(x => x.StudentId == order.Application.StudentId && x.Balance >= order.Amount) .ExecuteUpdateAsync(setters => setters .SetProperty(x => x.Balance, x => x.Balance - order.Amount) .SetProperty(x => x.UpdatedAt, _ => DateTime.UtcNow), ct); if (debited == 0) { await transaction.RollbackAsync(ct); return Conflict(new { message = "余额不足,请联系管理员调整余额或选择线下缴费。" }); } var balanceAfter = await db.CampusWalletAccounts.AsNoTracking() .Where(x => x.StudentId == order.Application.StudentId) .Select(x => x.Balance).SingleAsync(ct); db.CampusWalletTransactions.Add(new CampusWalletTransaction { WalletAccountId = await db.CampusWalletAccounts.Where(x => x.StudentId == order.Application.StudentId) .Select(x => x.Id).SingleAsync(ct), Amount = -order.Amount, BalanceAfter = balanceAfter, Type = CampusWalletTransactionType.Payment, Reason = $"支付报名项目:{order.Application.Project!.Name}", PaymentOrderId = order.Id, OperatedByUserId = scope.Current.UserId }); order.Status = CampusPaymentOrderStatus.Paid; order.PaymentMethod = CampusPaymentMethod.Balance; order.ConfirmedAt = DateTime.UtcNow; order.ConfirmedByUserId = scope.Current.UserId; order.Application.Status = CampusRegistrationApplicationStatus.Confirmed; order.Application.ConfirmedAt = DateTime.UtcNow; try { await db.SaveChangesAsync(ct); await transaction.CommitAsync(ct); return NoContent(); } catch (DbUpdateConcurrencyException) { return Conflict(new { message = "订单或余额已变化,请刷新后重试。" }); } catch (DbUpdateException) { return Conflict(new { message = "该订单已完成支付或余额已变化,请刷新后查看最新状态。" }); } } private IQueryable ScopedProjects() => scope.Current.RestrictedCollegeId is { } collegeId ? db.CampusRegistrationProjects.Where(x => x.CollegeId == null || x.CollegeId == collegeId) : db.CampusRegistrationProjects; private IQueryable ScopedApplications() => scope.Current.RestrictedCollegeId is { } collegeId ? db.CampusRegistrationApplications.Where(x => x.Project!.CollegeId == null || x.Project.CollegeId == collegeId) : db.CampusRegistrationApplications; private IQueryable ApplicationsForCurrentStudent() => db.CampusRegistrationApplications.Where(x => x.Student!.UserId == scope.Current.UserId); private bool CanManageCollege(Guid? collegeId) => scope.Current.RestrictedCollegeId is not { } restricted || collegeId is null || collegeId == restricted; private bool CanManageProject(CampusRegistrationProject project) => CanManageCollege(project.CollegeId); private static bool CanStudentAccessProject(CampusRegistrationProject project, Student student) => project.CollegeId is null || project.CollegeId == student.AdministrativeClass!.Major!.CollegeId; private Task ConfirmedCount(Guid projectId, CancellationToken ct) => db.CampusRegistrationApplications.CountAsync(x => x.ProjectId == projectId && x.Status == CampusRegistrationApplicationStatus.Confirmed, ct); private void CreateOrder(CampusRegistrationApplication application, CampusRegistrationProject project) { var free = project.FeeAmount == 0; var order = new CampusPaymentOrder { ApplicationId = application.Id, OrderNumber = $"CR{DateTime.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..38], Amount = project.FeeAmount, PaymentMethod = free ? CampusPaymentMethod.Free : CampusPaymentMethod.Offline, Status = free ? CampusPaymentOrderStatus.Paid : CampusPaymentOrderStatus.Pending, ConfirmedAt = free ? DateTime.UtcNow : null, ConfirmedByUserId = free ? scope.Current.UserId : null }; db.CampusPaymentOrders.Add(order); if (free) { application.Status = CampusRegistrationApplicationStatus.Confirmed; application.ConfirmedAt = DateTime.UtcNow; } } private static string? ValidateProject(ProjectRequest r) => string.IsNullOrWhiteSpace(r.Code) || r.Code.Trim().Length > 40 ? "请填写不超过 40 字的项目编码。" : string.IsNullOrWhiteSpace(r.Name) || r.Name.Trim().Length > 120 ? "请填写不超过 120 字的项目名称。" : r.RegistrationEndsAt <= r.RegistrationStartsAt ? "报名结束时间必须晚于开始时间。" : r.Capacity < 1 ? "名额必须至少为 1。" : r.FeeAmount < 0 ? "金额不能为负数。" : null; private static string? TrimOrNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static System.Linq.Expressions.Expression> ProjectApplicationDto() => x => new { x.Id, x.ProjectId, ProjectCode = x.Project!.Code, ProjectName = x.Project.Name, x.Status, x.ApplicantNote, x.CreatedAt, x.ReviewComment, x.ConfirmedAt, x.CancelledAt, StudentId = x.StudentId, StudentNumber = x.Student!.StudentNumber, StudentName = x.Student.Name, Order = x.PaymentOrder == null ? null : new { x.PaymentOrder.Id, x.PaymentOrder.OrderNumber, x.PaymentOrder.Amount, x.PaymentOrder.PaymentMethod, x.PaymentOrder.Status, x.PaymentOrder.ReferenceNumber } }; } public sealed record ProjectRequest(string Code, string Name, string? Description, Guid? CollegeId, DateTime RegistrationStartsAt, DateTime RegistrationEndsAt, int Capacity, decimal FeeAmount, bool RequiresReview, CampusRegistrationProjectStatus Status, string? ContactName, string? ContactPhone, string? Notes); public sealed record ApplyRequest(string? Note); public sealed record ReviewRequest(bool Approved, string? Comment); public sealed record OfflinePaymentRequest(string? ReferenceNumber, string? Note); public sealed record WalletAdjustmentRequest(decimal Amount, string? Reason);