Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/CampusRegistrationsController.cs
T
2026-08-22 18:52:41 +08:00

406 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> Mine(CancellationToken ct) => Ok(await ApplicationsForCurrentStudent()
.OrderByDescending(x => x.CreatedAt).Select(ProjectApplicationDto()).ToListAsync(ct));
[HttpGet("wallet/mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<object>(items.Cast<object>().ToList(), total, page, pageSize));
}
[HttpGet("wallets/filter-options")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> 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<ActionResult> 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<object?>)[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<ActionResult> ImportWalletAdjustments(IFormFile file, CancellationToken ct)
{
IReadOnlyList<ExcelRow> rows;
try { rows = await ExcelWorkbookHelper.ReadAsync(file, ["学号", "调整金额", "调整原因"], ct); }
catch (InvalidDataException ex) { return BadRequest(new { message = ex.Message }); }
var errors = new List<string>(); 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<object?>)[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<ActionResult> 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<ActionResult> 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<ActionResult> 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<CampusRegistrationProject> ScopedProjects() => scope.Current.RestrictedCollegeId is { } collegeId ? db.CampusRegistrationProjects.Where(x => x.CollegeId == null || x.CollegeId == collegeId) : db.CampusRegistrationProjects;
private IQueryable<CampusRegistrationApplication> ScopedApplications() => scope.Current.RestrictedCollegeId is { } collegeId ? db.CampusRegistrationApplications.Where(x => x.Project!.CollegeId == null || x.Project.CollegeId == collegeId) : db.CampusRegistrationApplications;
private IQueryable<CampusRegistrationApplication> 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<int> 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<Func<CampusRegistrationApplication, object>> 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);