数据库脚本修复

This commit is contained in:
biss committed 2026-08-23 09:11:18 +08:00
1 parent b7b7c25d78
commit 14af0910e7
7 files changed
+500 -1

No files matched your search

@@ -0,0 +1,135 @@
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using System.Security.Claims;
using Jiaowu.Api.Domain.Identity;
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/tuition")]
public sealed class TuitionController(AppDbContext db) : ControllerBase
{
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet("terms"), Authorize(Roles = Managers)]
public async Task<ActionResult> Terms(CancellationToken ct) => Ok(await db.AcademicTerms.AsNoTracking().OrderByDescending(x => x.StartDate).Select(x => new { x.Id, x.Name, x.IsCurrent, Setting = db.TuitionTermSettings.Where(s => s.AcademicTermId == x.Id).Select(s => new { s.CreditUnitPrice, s.Status, s.CreditsLockedAt }).FirstOrDefault() }).ToListAsync(ct));
[HttpPut("terms/{termId:guid}/credit-price"), Authorize(Roles = Managers)]
public async Task<ActionResult> SetCreditPrice(Guid termId, TuitionPriceRequest request, CancellationToken ct)
{
if (request.CreditUnitPrice < 0) return BadRequest(new { message = "每学分金额不能为负数。" });
if (!await db.AcademicTerms.AnyAsync(x => x.Id == termId, ct)) return NotFound();
var setting = await db.TuitionTermSettings.SingleOrDefaultAsync(x => x.AcademicTermId == termId, ct);
if (setting is null) { setting = new TuitionTermSetting { AcademicTermId = termId, CreditUnitPrice = request.CreditUnitPrice }; db.TuitionTermSettings.Add(setting); }
else if (setting.Status != TuitionTermStatus.Draft) return Conflict(new { message = "学分费已锁定,不能修改单价。" });
else setting.CreditUnitPrice = request.CreditUnitPrice;
await db.SaveChangesAsync(ct); return NoContent();
}
[HttpGet("terms/{termId:guid}/import-template.xlsx"), Authorize(Roles = Managers)]
public IActionResult Template(Guid termId) => File(ExcelWorkbookHelper.Create("学费名单导入", ["学号", "基础学费", "住宿费"], [], ["按学号匹配学生;仅覆盖未缴的基础学费和住宿费。", "已缴项目的差额不会覆盖,将自动记为期末多退少补调整项。"]), ExcelWorkbookHelper.ContentType, DownloadFileName.Template("学费名单", ".xlsx"));
[HttpPost("terms/{termId:guid}/import"), Authorize(Roles = Managers)]
public async Task<ActionResult> Import(Guid termId, IFormFile file, CancellationToken ct)
{
IReadOnlyList<ExcelRow> rows; try { rows = await ExcelWorkbookHelper.ReadAsync(file, ["学号", "基础学费", "住宿费"], ct); } catch (InvalidDataException ex) { return BadRequest(new { message = ex.Message }); }
if (!await db.AcademicTerms.AnyAsync(x => x.Id == termId, ct)) return NotFound();
if (rows.Count == 0) return BadRequest(new { message = "Excel 中没有可导入的数据行,请从第 2 行开始填写。" });
var errors = new List<string>();
var inputs = new List<(int RowNumber, string Number, decimal Base, decimal Accommodation)>();
foreach (var row in rows)
{
var number = row["学号"];
if (string.IsNullOrWhiteSpace(number)) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; }
if (!decimal.TryParse(row["基础学费"], out var baseTuition) || baseTuition < 0)
errors.Add($"第 {row.RowNumber} 行:基础学费必须是大于等于 0 的数字。");
if (!decimal.TryParse(row["住宿费"], out var accommodation) || accommodation < 0)
errors.Add($"第 {row.RowNumber} 行:住宿费必须是大于等于 0 的数字。");
if (baseTuition >= 0 && accommodation >= 0) inputs.Add((row.RowNumber, number, baseTuition, accommodation));
}
if (errors.Count > 0) return BadRequest(new { message = string.Join(";", errors.Take(20)) });
if (inputs.GroupBy(x => x.Number).Any(x => x.Count() > 1)) return BadRequest(new { message = "Excel 中存在重复学号,请每名学生只保留一行。" });
var students = await db.Students.Where(x => inputs.Select(i => i.Number).Contains(x.StudentNumber)).ToDictionaryAsync(x => x.StudentNumber, ct);
var missingNumbers = inputs.Where(x => !students.ContainsKey(x.Number)).Select(x => $"第 {x.RowNumber} 行({x.Number})").Take(20).ToArray();
if (missingNumbers.Length > 0) return BadRequest(new { message = $"以下学号未匹配学生:{string.Join("、", missingNumbers)}。" });
foreach (var input in inputs)
{
var bill = await db.TuitionBills.SingleOrDefaultAsync(x => x.AcademicTermId == termId && x.StudentId == students[input.Number].Id, ct);
if (bill is null) { bill = new TuitionBill { AcademicTermId = termId, StudentId = students[input.Number].Id, BaseTuition = input.Base, AccommodationFee = input.Accommodation }; db.TuitionBills.Add(bill); continue; }
if (bill.BasePaid == 0) bill.BaseTuition = input.Base; else bill.ReconciliationAdjustment += input.Base - bill.BaseTuition;
if (bill.AccommodationPaid == 0) bill.AccommodationFee = input.Accommodation; else bill.ReconciliationAdjustment += input.Accommodation - bill.AccommodationFee;
}
await db.SaveChangesAsync(ct); return Ok(new { imported = inputs.Count });
}
[HttpPost("terms/{termId:guid}/lock-credits"), Authorize(Roles = Managers)]
public async Task<ActionResult> LockCredits(Guid termId, CancellationToken ct)
{
var setting = await db.TuitionTermSettings.SingleOrDefaultAsync(x => x.AcademicTermId == termId, ct);
if (setting is null) return BadRequest(new { message = "请先设定每学分金额。" });
if (setting.Status != TuitionTermStatus.Draft) return Conflict(new { message = "学分费已经锁定。" });
if (await db.CourseSelectionRounds.AnyAsync(x => x.AcademicTermId == termId && x.Status != CourseSelectionRoundStatus.Closed, ct)) return Conflict(new { message = "本学期仍有未结束的选课轮次,不能锁定学分费。" });
var credits = await db.CourseEnrollments.Where(x => x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == termId && x.Status == CourseEnrollmentStatus.Enrolled).GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Credits = x.Sum(e => e.CourseSelectionOffering!.TeachingTask!.Course!.Credits) }).ToListAsync(ct);
foreach (var credit in credits) { var bill = await db.TuitionBills.SingleOrDefaultAsync(x => x.AcademicTermId == termId && x.StudentId == credit.StudentId, ct); if (bill is null) { bill = new TuitionBill { AcademicTermId = termId, StudentId = credit.StudentId }; db.TuitionBills.Add(bill); } bill.CreditCount = credit.Credits; bill.CreditFee = credit.Credits * setting.CreditUnitPrice; }
setting.Status = TuitionTermStatus.CreditsLocked; setting.CreditsLockedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct); return Ok(new { locked = credits.Count });
}
[HttpPost("terms/{termId:guid}/open-payment"), Authorize(Roles = Managers)]
public async Task<ActionResult> OpenPayment(Guid termId, CancellationToken ct) { var setting = await db.TuitionTermSettings.SingleOrDefaultAsync(x => x.AcademicTermId == termId, ct); if (setting is null || setting.Status != TuitionTermStatus.CreditsLocked) return Conflict(new { message = "请先锁定学分费。" }); setting.Status = TuitionTermStatus.OpenForPayment; await db.SaveChangesAsync(ct); return NoContent(); }
[HttpGet("bills"), Authorize(Roles = Managers)]
public async Task<ActionResult> Bills(Guid termId, [FromQuery] string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
{
var source = db.TuitionBills.AsNoTracking().Where(x => x.AcademicTermId == termId);
if (!string.IsNullOrWhiteSpace(keyword))
{
var search = keyword.Trim();
source = source.Where(x => x.Student!.StudentNumber.Contains(search) || x.Student.Name.Contains(search));
}
page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100);
var total = await source.CountAsync(ct);
var items = await source.OrderBy(x => x.Student!.StudentNumber).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new { x.Id, x.StudentId, x.Student!.StudentNumber, StudentName = x.Student.Name, x.BaseTuition, x.AccommodationFee, x.CreditCount, x.CreditFee, x.ReconciliationAdjustment, x.BasePaid, x.AccommodationPaid, x.CreditPaid, x.AdjustmentPaid })
.ToListAsync(ct);
return Ok(new PagedResult<object>(items.Cast<object>().ToList(), total, page, pageSize));
}
[HttpPost("bills/{billId:guid}/reconcile"), Authorize(Roles = Managers)]
public async Task<ActionResult> Reconcile(Guid billId, ReconcileRequest request, CancellationToken ct) { var bill = await db.TuitionBills.FindAsync([billId], ct); if (bill is null) return NotFound(); bill.ReconciliationAdjustment += request.Amount; await db.SaveChangesAsync(ct); return NoContent(); }
[HttpGet("mine"), Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Mine(CancellationToken ct)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) return Forbid();
return Ok(await db.TuitionBills.AsNoTracking().Where(x => x.Student!.UserId == userId).OrderByDescending(x => x.AcademicTerm!.StartDate).Select(x => new { x.Id, TermName = x.AcademicTerm!.Name, x.BaseTuition, x.AccommodationFee, x.CreditCount, x.CreditFee, x.ReconciliationAdjustment, x.BasePaid, x.AccommodationPaid, x.CreditPaid, x.AdjustmentPaid, IsPayable = db.TuitionTermSettings.Any(s => s.AcademicTermId == x.AcademicTermId && s.Status == TuitionTermStatus.OpenForPayment) }).ToListAsync(ct));
}
[HttpPost("bills/{billId:guid}/items/{item}/pay-balance"), Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> PayBalance(Guid billId, TuitionFeeItem item, CancellationToken ct)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) return Forbid();
var bill = await db.TuitionBills.Include(x => x.Student).SingleOrDefaultAsync(x => x.Id == billId && x.Student!.UserId == userId, ct);
if (bill is null) return NotFound();
if (!await db.TuitionTermSettings.AnyAsync(x => x.AcademicTermId == bill.AcademicTermId && x.Status == TuitionTermStatus.OpenForPayment, ct)) return Conflict(new { message = "当前学期尚未开放缴费。" });
var due = Due(bill, item); if (due <= 0) return Conflict(new { message = "该项目没有待缴金额。" });
var wallet = await db.CampusWalletAccounts.SingleOrDefaultAsync(x => x.StudentId == bill.StudentId, ct); if (wallet is null || wallet.Balance < due) return Conflict(new { message = "校园余额不足。" });
wallet.Balance -= due; AddPaid(bill, item, due); db.TuitionPayments.Add(new TuitionPayment { TuitionBillId = bill.Id, Item = item, Amount = due, Method = TuitionPaymentMethod.Balance, ConfirmedByUserId = userId, ConfirmedAt = DateTime.UtcNow }); await db.SaveChangesAsync(ct); return NoContent();
}
[HttpPost("bills/{billId:guid}/items/{item}/confirm-offline"), Authorize(Roles = Managers)]
public async Task<ActionResult> ConfirmOffline(Guid billId, TuitionFeeItem item, OfflineTuitionPaymentRequest request, CancellationToken ct)
{ var bill = await db.TuitionBills.FindAsync([billId], ct); if (bill is null) return NotFound(); var due = Due(bill, item); if (due <= 0) return Conflict(new { message = "该项目没有待缴金额。" }); AddPaid(bill, item, due); db.TuitionPayments.Add(new TuitionPayment { TuitionBillId = bill.Id, Item = item, Amount = due, Method = TuitionPaymentMethod.Offline, ReferenceNumber = request.ReferenceNumber?.Trim(), ConfirmedByUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!), ConfirmedAt = DateTime.UtcNow }); await db.SaveChangesAsync(ct); return NoContent(); }
private static decimal Due(TuitionBill b, TuitionFeeItem item) => item switch { TuitionFeeItem.BaseTuition => b.BaseTuition - b.BasePaid, TuitionFeeItem.AccommodationFee => b.AccommodationFee - b.AccommodationPaid, TuitionFeeItem.CreditFee => b.CreditFee - b.CreditPaid, TuitionFeeItem.ReconciliationAdjustment => b.ReconciliationAdjustment - b.AdjustmentPaid, _ => 0 };
private static void AddPaid(TuitionBill b, TuitionFeeItem item, decimal amount) { if (item == TuitionFeeItem.BaseTuition) b.BasePaid += amount; else if (item == TuitionFeeItem.AccommodationFee) b.AccommodationPaid += amount; else if (item == TuitionFeeItem.CreditFee) b.CreditPaid += amount; else b.AdjustmentPaid += amount; }
}
public sealed record TuitionPriceRequest(decimal CreditUnitPrice);
public sealed record ReconcileRequest(decimal Amount, string? Reason);
public sealed record OfflineTuitionPaymentRequest(string? ReferenceNumber);
@@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
public partial class CampusRegistrationPayments : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// MySQL DDL is not transactional. IF NOT EXISTS makes a retry safe when
// an earlier attempt created this first table but failed later in the migration.
migrationBuilder.Sql("""
CREATE TABLE IF NOT EXISTS `CampusRegistrationProjects` (
`Id` char(36) NOT NULL, `Code` varchar(40) NOT NULL, `Name` varchar(120) NOT NULL,
`Description` varchar(2000) NULL, `CollegeId` char(36) NULL,
`RegistrationStartsAt` datetime(6) NOT NULL, `RegistrationEndsAt` datetime(6) NOT NULL,
`Capacity` int NOT NULL, `FeeAmount` decimal(10,2) NOT NULL, `RequiresReview` tinyint(1) NOT NULL,
`Status` int NOT NULL, `ContactName` varchar(50) NULL, `ContactPhone` varchar(30) NULL,
`Notes` varchar(500) NULL, `CreatedByUserId` char(36) NOT NULL,
`CreatedAt` datetime(6) NOT NULL, `UpdatedAt` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CRP_College` FOREIGN KEY (`CollegeId`) REFERENCES `Colleges` (`Id`) ON DELETE SET NULL,
CONSTRAINT `FK_CRP_Creator` FOREIGN KEY (`CreatedByUserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""");
migrationBuilder.Sql("""
CREATE TABLE IF NOT EXISTS `CampusRegistrationApplications` (
`Id` char(36) NOT NULL, `ProjectId` char(36) NOT NULL, `StudentId` char(36) NOT NULL, `Status` int NOT NULL,
`ApplicantNote` varchar(1000) NULL, `ReviewedByUserId` char(36) NULL, `ReviewedAt` datetime(6) NULL,
`ReviewComment` varchar(500) NULL, `ConfirmedAt` datetime(6) NULL, `CancelledAt` datetime(6) NULL,
`CreatedAt` datetime(6) NOT NULL, `UpdatedAt` datetime(6) NOT NULL, PRIMARY KEY (`Id`),
CONSTRAINT `FK_CRA_Project` FOREIGN KEY (`ProjectId`) REFERENCES `CampusRegistrationProjects` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_CRA_Student` FOREIGN KEY (`StudentId`) REFERENCES `Students` (`Id`) ON DELETE RESTRICT,
CONSTRAINT `FK_CRA_Reviewer` FOREIGN KEY (`ReviewedByUserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `CampusPaymentOrders` (
`Id` char(36) NOT NULL, `ApplicationId` char(36) NOT NULL, `OrderNumber` varchar(40) NOT NULL,
`Amount` decimal(10,2) NOT NULL, `PaymentMethod` int NOT NULL, `Status` int NOT NULL,
`ConfirmedByUserId` char(36) NULL, `ConfirmedAt` datetime(6) NULL, `ReferenceNumber` varchar(100) NULL,
`Note` varchar(500) NULL, `CreatedAt` datetime(6) NOT NULL, `UpdatedAt` datetime(6) NOT NULL, PRIMARY KEY (`Id`),
CONSTRAINT `FK_CPO_Application` FOREIGN KEY (`ApplicationId`) REFERENCES `CampusRegistrationApplications` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_CPO_Confirmer` FOREIGN KEY (`ConfirmedByUserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""");
CreateIndexIfMissing(migrationBuilder, "CampusRegistrationProjects", "IX_CampusRegistrationProjects_Code", "`Code`", unique: true);
CreateIndexIfMissing(migrationBuilder, "CampusRegistrationProjects", "IX_CRP_College_Status_Ends", "`CollegeId`, `Status`, `RegistrationEndsAt`");
CreateIndexIfMissing(migrationBuilder, "CampusRegistrationProjects", "IX_CampusRegistrationProjects_CreatedByUserId", "`CreatedByUserId`");
CreateIndexIfMissing(migrationBuilder, "CampusRegistrationApplications", "IX_CampusRegistrationApplications_ProjectId_StudentId", "`ProjectId`, `StudentId`", unique: true);
CreateIndexIfMissing(migrationBuilder, "CampusRegistrationApplications", "IX_CampusRegistrationApplications_ProjectId_Status_CreatedAt", "`ProjectId`, `Status`, `CreatedAt`");
CreateIndexIfMissing(migrationBuilder, "CampusRegistrationApplications", "IX_CampusRegistrationApplications_StudentId", "`StudentId`");
CreateIndexIfMissing(migrationBuilder, "CampusRegistrationApplications", "IX_CampusRegistrationApplications_ReviewedByUserId", "`ReviewedByUserId`");
CreateIndexIfMissing(migrationBuilder, "CampusPaymentOrders", "IX_CampusPaymentOrders_ApplicationId", "`ApplicationId`", unique: true);
CreateIndexIfMissing(migrationBuilder, "CampusPaymentOrders", "IX_CampusPaymentOrders_OrderNumber", "`OrderNumber`", unique: true);
CreateIndexIfMissing(migrationBuilder, "CampusPaymentOrders", "IX_CampusPaymentOrders_Status_CreatedAt", "`Status`, `CreatedAt`");
CreateIndexIfMissing(migrationBuilder, "CampusPaymentOrders", "IX_CampusPaymentOrders_ConfirmedByUserId", "`ConfirmedByUserId`");
}
private static void CreateIndexIfMissing(MigrationBuilder migrationBuilder, string table, string name, string columns, bool unique = false)
{
var statement = $"CREATE {(unique ? "UNIQUE " : string.Empty)}INDEX `{name}` ON `{table}` ({columns})";
migrationBuilder.Sql($"""
SET @index_exists = (SELECT COUNT(*) FROM `INFORMATION_SCHEMA`.`STATISTICS`
WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = '{table}' AND `INDEX_NAME` = '{name}');
SET @create_index_sql = IF(@index_exists = 0, '{statement}', 'SELECT 1');
PREPARE create_index_stmt FROM @create_index_sql;
EXECUTE create_index_stmt;
DEALLOCATE PREPARE create_index_stmt;
""");
}
protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable(name: "CampusPaymentOrders"); migrationBuilder.DropTable(name: "CampusRegistrationApplications"); migrationBuilder.DropTable(name: "CampusRegistrationProjects"); }
}