已完成,学生端考勤记录现在按课程展示。

每门课程会独立显示:
课程名称、代码、教学班和任课教师。
课程总体出勤率。
出勤、迟到、缺勤、请假、免修次数。
按日期排列的历次点名记录。
缺勤或迟到记录仍可单独申诉。
This commit is contained in:
2026-07-26 10:49:06 +08:00 Unverified
parent 72297aa23f
commit 3b73292d93
12 changed files with 2172 additions and 54 deletions
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography;
using ClosedXML.Excel;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
@@ -76,8 +77,12 @@ public sealed class AttendanceController(
x.Name,
x.AttendanceDate,
x.Status,
x.CheckInMethod,
x.CheckInStartsAt,
x.CheckInEndsAt,
x.Notes,
x.SubmittedAt,
CheckedInCount = x.Records.Count(r => r.CheckInAt != null),
PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present),
AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent),
LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late),
@@ -109,20 +114,71 @@ public sealed class AttendanceController(
if (studentIds.Count == 0)
return ConflictProblem("该教学班没有有效选课学生。");
var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
if (!Enum.IsDefined(checkInMethod))
return ConflictProblem("不支持该签到方式。");
var now = DateTime.UtcNow;
DateTime? checkInEndsAt = null;
string? checkInToken = null;
if (checkInMethod != AttendanceCheckInMethod.Manual)
{
if (request.CheckInDurationMinutes is < 1 or > 180)
return ConflictProblem("签到时长应为 1 至 180 分钟。");
checkInEndsAt = now.AddMinutes(request.CheckInDurationMinutes!.Value);
}
if (checkInMethod == AttendanceCheckInMethod.QrCode)
checkInToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
if (checkInMethod == AttendanceCheckInMethod.Location)
{
if (request.TargetLatitude is < -90 or > 90 ||
request.TargetLongitude is < -180 or > 180 ||
request.TargetLatitude is null ||
request.TargetLongitude is null)
return ConflictProblem("请获取有效的签到位置。");
if (request.LocationRadiusMeters is < 20 or > 1000)
return ConflictProblem("定位签到范围应为 20 至 1000 米。");
}
var sheet = new AttendanceSheet
{
TeachingTaskId = request.TeachingTaskId,
Name = request.Name.Trim(),
AttendanceDate = request.AttendanceDate,
CheckInMethod = checkInMethod,
CheckInToken = checkInToken,
CheckInStartsAt = checkInMethod == AttendanceCheckInMethod.Manual
? null
: now,
CheckInEndsAt = checkInEndsAt,
TargetLatitude = checkInMethod == AttendanceCheckInMethod.Location
? request.TargetLatitude
: null,
TargetLongitude = checkInMethod == AttendanceCheckInMethod.Location
? request.TargetLongitude
: null,
LocationRadiusMeters = checkInMethod == AttendanceCheckInMethod.Location
? request.LocationRadiusMeters
: null,
Notes = Normalize(request.Notes),
Records = studentIds.Select(studentId => new AttendanceRecord
{
StudentId = studentId
StudentId = studentId,
Status = checkInMethod == AttendanceCheckInMethod.Manual
? AttendanceStatus.Present
: AttendanceStatus.Absent
}).ToList()
};
db.AttendanceSheets.Add(sheet);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { sheet.Id });
return Created(string.Empty, new
{
sheet.Id,
sheet.CheckInMethod,
sheet.CheckInToken,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt
});
}
[HttpGet("sheets/{id:guid}")]
@@ -138,6 +194,13 @@ public sealed class AttendanceController(
x.Name,
x.AttendanceDate,
x.Status,
x.CheckInMethod,
x.CheckInToken,
x.CheckInStartsAt,
x.CheckInEndsAt,
x.TargetLatitude,
x.TargetLongitude,
x.LocationRadiusMeters,
x.Notes,
x.SubmittedAt,
TaskNumber = x.TeachingTask!.TaskNumber,
@@ -153,7 +216,11 @@ public sealed class AttendanceController(
r.Student.Name,
ClassName = r.Student.AdministrativeClass!.Name,
r.Status,
r.Notes
r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters
})
})
.FirstOrDefaultAsync(cancellationToken);
@@ -170,10 +237,11 @@ public sealed class AttendanceController(
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft &&
await CanManageTaskAsync(
sheet.TeachingTaskId,
cancellationToken);
var canManage = await CanManageTaskAsync(
sheet.TeachingTaskId,
cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
var now = DateTime.UtcNow;
return Ok(new
{
Sheet = new
@@ -183,6 +251,20 @@ public sealed class AttendanceController(
sheet.Name,
sheet.AttendanceDate,
sheet.Status,
sheet.CheckInMethod,
CheckInToken = canManage ? sheet.CheckInToken : null,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
sheet.TargetLatitude,
sheet.TargetLongitude,
sheet.LocationRadiusMeters,
IsCheckInOpen = IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now),
CheckedInCount = sheet.Records.Count(r => r.CheckInAt != null),
sheet.Notes,
sheet.SubmittedAt,
sheet.TaskNumber,
@@ -197,6 +279,10 @@ public sealed class AttendanceController(
r.ClassName,
r.Status,
r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId)
})
@@ -387,8 +473,231 @@ public sealed class AttendanceController(
return NoContent();
}
[HttpPost("sheets/{id:guid}/close-check-in")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> CloseCheckIn(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.Status != AttendanceSheetStatus.Draft)
return ConflictProblem("考勤表已提交,签到活动已经结束。");
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
return ConflictProblem("普通点名没有在线签到活动。");
var now = DateTime.UtcNow;
if (sheet.CheckInEndsAt is null || sheet.CheckInEndsAt > now)
sheet.CheckInEndsAt = now;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
// ═══════════════ Student endpoints ═══════════════
[HttpGet("check-in-info")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetCheckInInfo(
[FromQuery, MaxLength(64)] string token,
CancellationToken cancellationToken)
{
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
if (string.IsNullOrWhiteSpace(token))
return NotFound();
var activity = await db.AttendanceRecords.AsNoTracking()
.Where(x =>
x.StudentId == studentId.Value &&
x.AttendanceSheet!.CheckInToken == token.Trim())
.Select(x => new
{
SheetId = x.AttendanceSheetId,
SheetName = x.AttendanceSheet!.Name,
x.AttendanceSheet.AttendanceDate,
x.AttendanceSheet.Status,
x.AttendanceSheet.CheckInMethod,
x.AttendanceSheet.CheckInStartsAt,
x.AttendanceSheet.CheckInEndsAt,
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
x.CheckInAt
})
.FirstOrDefaultAsync(cancellationToken);
if (activity is null) return NotFound();
var now = DateTime.UtcNow;
return Ok(new
{
activity.SheetId,
activity.SheetName,
activity.AttendanceDate,
activity.CheckInMethod,
activity.CheckInStartsAt,
activity.CheckInEndsAt,
activity.CourseCode,
activity.CourseName,
activity.TaskNumber,
activity.CheckInAt,
IsOpen = IsCheckInOpen(
activity.Status,
activity.CheckInMethod,
activity.CheckInStartsAt,
activity.CheckInEndsAt,
now)
});
}
[HttpGet("open-check-ins")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetOpenCheckIns(
CancellationToken cancellationToken)
{
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
var now = DateTime.UtcNow;
return Ok(await db.AttendanceRecords.AsNoTracking()
.Where(x =>
x.StudentId == studentId.Value &&
x.AttendanceSheet!.Status == AttendanceSheetStatus.Draft &&
x.AttendanceSheet.CheckInMethod == AttendanceCheckInMethod.Location &&
x.AttendanceSheet.CheckInStartsAt <= now &&
x.AttendanceSheet.CheckInEndsAt >= now)
.OrderBy(x => x.AttendanceSheet!.CheckInEndsAt)
.Select(x => new
{
SheetId = x.AttendanceSheetId,
SheetName = x.AttendanceSheet!.Name,
x.AttendanceSheet.AttendanceDate,
x.AttendanceSheet.CheckInMethod,
x.AttendanceSheet.CheckInStartsAt,
x.AttendanceSheet.CheckInEndsAt,
x.AttendanceSheet.LocationRadiusMeters,
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
x.CheckInAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("check-in")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> CheckIn(
AttendanceCheckInRequest request,
CancellationToken cancellationToken)
{
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
var source = db.AttendanceRecords
.Include(x => x.AttendanceSheet)
.Where(x => x.StudentId == studentId.Value);
if (!string.IsNullOrWhiteSpace(request.Token))
{
var token = request.Token.Trim();
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
}
else if (request.AttendanceSheetId.HasValue)
{
source = source.Where(x =>
x.AttendanceSheetId == request.AttendanceSheetId.Value);
}
else
{
return ConflictProblem("缺少签到活动信息。");
}
var record = await source.FirstOrDefaultAsync(cancellationToken);
if (record?.AttendanceSheet is null) return NotFound();
var sheet = record.AttendanceSheet;
var now = DateTime.UtcNow;
if (record.CheckInAt.HasValue)
{
return Ok(new
{
AlreadyCheckedIn = true,
record.CheckInAt,
record.CheckInDistanceMeters
});
}
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return ConflictProblem("签到尚未开始或已经结束。");
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
return ConflictProblem("该考勤表不支持学生在线签到。");
double? distanceMeters = null;
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
{
if (string.IsNullOrWhiteSpace(request.Token) ||
!string.Equals(
sheet.CheckInToken,
request.Token.Trim(),
StringComparison.Ordinal))
return NotFound();
}
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
{
if (request.Latitude is < -90 or > 90 ||
request.Longitude is < -180 or > 180 ||
request.Latitude is null ||
request.Longitude is null)
return ConflictProblem("未获取到有效的当前位置。");
if (sheet.TargetLatitude is null ||
sheet.TargetLongitude is null ||
sheet.LocationRadiusMeters is null)
return ConflictProblem("签到活动没有配置有效的位置范围。");
distanceMeters = CalculateDistanceMeters(
(double)sheet.TargetLatitude.Value,
(double)sheet.TargetLongitude.Value,
(double)request.Latitude.Value,
(double)request.Longitude.Value);
if (distanceMeters > sheet.LocationRadiusMeters.Value)
{
return ConflictProblem(
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
}
}
record.Status = AttendanceStatus.Present;
record.CheckInAt = now;
record.CheckedInMethod = sheet.CheckInMethod;
record.CheckInLatitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
? request.Latitude
: null;
record.CheckInLongitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
? request.Longitude
: null;
record.CheckInAccuracyMeters = sheet.CheckInMethod == AttendanceCheckInMethod.Location
? request.AccuracyMeters
: null;
record.CheckInDistanceMeters = distanceMeters;
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
AlreadyCheckedIn = false,
record.CheckInAt,
record.CheckInDistanceMeters
});
}
[HttpGet("my-records")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyRecords(
@@ -415,6 +724,7 @@ public sealed class AttendanceController(
.Select(r => new
{
r.AttendanceSheetId,
r.AttendanceSheet!.TeachingTaskId,
SheetName = r.AttendanceSheet!.Name,
r.AttendanceSheet.AttendanceDate,
TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber,
@@ -657,9 +967,54 @@ public sealed class AttendanceController(
.AnyAsync(
x => x.TeachingTaskId == teachingTaskId &&
x.Teacher!.UserId == scope.UserId,
cancellationToken);
cancellationToken);
}
private async Task<Guid?> GetCurrentStudentIdAsync(
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
return await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
}
private static bool IsCheckInOpen(
AttendanceSheetStatus status,
AttendanceCheckInMethod method,
DateTime? startsAt,
DateTime? endsAt,
DateTime now) =>
status == AttendanceSheetStatus.Draft &&
method != AttendanceCheckInMethod.Manual &&
startsAt.HasValue &&
endsAt.HasValue &&
startsAt.Value <= now &&
endsAt.Value >= now;
internal static double CalculateDistanceMeters(
double latitude1,
double longitude1,
double latitude2,
double longitude2)
{
const double earthRadiusMeters = 6_371_000;
var latitudeDelta = DegreesToRadians(latitude2 - latitude1);
var longitudeDelta = DegreesToRadians(longitude2 - longitude1);
var startLatitude = DegreesToRadians(latitude1);
var endLatitude = DegreesToRadians(latitude2);
var haversine =
Math.Sin(latitudeDelta / 2) * Math.Sin(latitudeDelta / 2) +
Math.Cos(startLatitude) * Math.Cos(endLatitude) *
Math.Sin(longitudeDelta / 2) * Math.Sin(longitudeDelta / 2);
return earthRadiusMeters * 2 *
Math.Atan2(Math.Sqrt(haversine), Math.Sqrt(1 - haversine));
}
private static double DegreesToRadians(double degrees) =>
degrees * Math.PI / 180;
private async Task<AttendanceCourseStatistics?> LoadTaskStatisticsAsync(
Guid teachingTaskId,
CancellationToken cancellationToken)
@@ -993,7 +1348,12 @@ public sealed record AttendanceSheetRequest(
Guid TeachingTaskId,
[MaxLength(120)] string Name,
DateTime AttendanceDate,
[MaxLength(500)] string? Notes);
[MaxLength(500)] string? Notes,
AttendanceCheckInMethod? CheckInMethod,
[Range(1, 180)] int? CheckInDurationMinutes,
[Range(-90, 90)] decimal? TargetLatitude,
[Range(-180, 180)] decimal? TargetLongitude,
[Range(20, 1000)] int? LocationRadiusMeters);
public sealed record AttendanceRecordsRequest(
IReadOnlyCollection<AttendanceRecordRequest> Records);
@@ -1011,6 +1371,13 @@ public sealed record AttendanceAppealReviewRequest(
bool Approve,
[MaxLength(300)] string? Comment);
public sealed record AttendanceCheckInRequest(
Guid? AttendanceSheetId,
[MaxLength(64)] string? Token,
[Range(-90, 90)] decimal? Latitude,
[Range(-180, 180)] decimal? Longitude,
[Range(0, 5000)] double? AccuracyMeters);
public sealed record AttendanceCourseStatistics(
AttendanceStatisticsCourse Course,
AttendanceStatisticsSummary Summary,
@@ -9,6 +9,14 @@ public sealed class AttendanceSheet : EntityBase
public required string Name { get; set; }
public DateTime AttendanceDate { get; set; }
public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft;
public AttendanceCheckInMethod CheckInMethod { get; set; } =
AttendanceCheckInMethod.Manual;
public string? CheckInToken { get; set; }
public DateTime? CheckInStartsAt { get; set; }
public DateTime? CheckInEndsAt { get; set; }
public decimal? TargetLatitude { get; set; }
public decimal? TargetLongitude { get; set; }
public int? LocationRadiusMeters { get; set; }
public string? Notes { get; set; }
public DateTime? SubmittedAt { get; set; }
public ICollection<AttendanceRecord> Records { get; set; } = [];
@@ -22,6 +30,12 @@ public sealed class AttendanceRecord
public Student? Student { get; set; }
public AttendanceStatus Status { get; set; } = AttendanceStatus.Present;
public string? Notes { get; set; }
public DateTime? CheckInAt { get; set; }
public AttendanceCheckInMethod? CheckedInMethod { get; set; }
public decimal? CheckInLatitude { get; set; }
public decimal? CheckInLongitude { get; set; }
public double? CheckInAccuracyMeters { get; set; }
public double? CheckInDistanceMeters { get; set; }
public AttendanceAppealStatus AppealStatus { get; set; } = AttendanceAppealStatus.None;
public string? AppealReason { get; set; }
public DateTime? AppealSubmittedAt { get; set; }
@@ -44,6 +58,13 @@ public enum AttendanceStatus
Excused = 5
}
public enum AttendanceCheckInMethod
{
Manual = 1,
QrCode = 2,
Location = 3
}
public enum AttendanceAppealStatus
{
None = 0,
@@ -561,8 +561,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<AttendanceSheet>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.CheckInToken).HasMaxLength(64);
entity.Property(x => x.TargetLatitude).HasPrecision(10, 7);
entity.Property(x => x.TargetLongitude).HasPrecision(10, 7);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate });
entity.HasIndex(x => x.CheckInToken).IsUnique();
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
@@ -575,6 +579,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Notes).HasMaxLength(300);
entity.Property(x => x.AppealReason).HasMaxLength(500);
entity.Property(x => x.AppealReviewComment).HasMaxLength(300);
entity.Property(x => x.CheckInLatitude).HasPrecision(10, 7);
entity.Property(x => x.CheckInLongitude).HasPrecision(10, 7);
entity.HasIndex(x => x.AppealStatus);
entity.HasOne(x => x.AttendanceSheet)
.WithMany(x => x.Records)
@@ -40,6 +40,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_22_course_adjustments";
private const string AttendanceAppealMigration =
"20260725_23_attendance_appeal";
private const string AttendanceCheckInMigration =
"20260726_26_attendance_check_in";
private const string ApprovalTablesMigration =
"20260725_24_approval_tables";
private const string AcademicWarningsMigration =
@@ -275,6 +277,19 @@ public sealed class DevelopmentSqliteMigrator(
attendanceAppealExists ? [] : AttendanceAppealStatements,
cancellationToken);
var attendanceCheckInExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('AttendanceSheets')
WHERE name = 'CheckInMethod'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AttendanceCheckInMigration,
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
cancellationToken);
var approvalTablesExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
.AnyAsync(value => value > 0, cancellationToken);
@@ -1623,6 +1638,24 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AppealStatus" ON "AttendanceRecords" ("AppealStatus");"""
];
private static readonly string[] AttendanceCheckInStatements =
[
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInMethod" INTEGER NOT NULL DEFAULT 1;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInToken" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInStartsAt" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInEndsAt" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "TargetLatitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "TargetLongitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "LocationRadiusMeters" INTEGER NULL;""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_AttendanceSheets_CheckInToken" ON "AttendanceSheets" ("CheckInToken");""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInAt" TEXT NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckedInMethod" INTEGER NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInLatitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInLongitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInAccuracyMeters" REAL NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
];
private static readonly string[] ApprovalTableStatements =
[
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
@@ -0,0 +1,131 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
[DbContext(typeof(AppDbContext))]
[Migration("20260726022634_AttendanceCheckIn")]
public partial class AttendanceCheckIn : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CheckInMethod",
table: "AttendanceSheets",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<string>(
name: "CheckInToken",
table: "AttendanceSheets",
type: "varchar(64)",
maxLength: 64,
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<DateTime>(
name: "CheckInStartsAt",
table: "AttendanceSheets",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CheckInEndsAt",
table: "AttendanceSheets",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "TargetLatitude",
table: "AttendanceSheets",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "TargetLongitude",
table: "AttendanceSheets",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LocationRadiusMeters",
table: "AttendanceSheets",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CheckInAt",
table: "AttendanceRecords",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CheckedInMethod",
table: "AttendanceRecords",
type: "int",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CheckInLatitude",
table: "AttendanceRecords",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CheckInLongitude",
table: "AttendanceRecords",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<double>(
name: "CheckInAccuracyMeters",
table: "AttendanceRecords",
type: "double",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "CheckInDistanceMeters",
table: "AttendanceRecords",
type: "double",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_AttendanceSheets_CheckInToken",
table: "AttendanceSheets",
column: "CheckInToken",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_AttendanceSheets_CheckInToken",
table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInMethod", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInToken", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInStartsAt", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInEndsAt", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "TargetLatitude", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "TargetLongitude", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "LocationRadiusMeters", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInAt", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckedInMethod", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInLatitude", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInLongitude", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInAccuracyMeters", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInDistanceMeters", table: "AttendanceRecords");
}
}
@@ -154,6 +154,26 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime?>("AppealSubmittedAt")
.HasColumnType("datetime(6)");
b.Property<double?>("CheckInAccuracyMeters")
.HasColumnType("double");
b.Property<DateTime?>("CheckInAt")
.HasColumnType("datetime(6)");
b.Property<double?>("CheckInDistanceMeters")
.HasColumnType("double");
b.Property<decimal?>("CheckInLatitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<decimal?>("CheckInLongitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<int?>("CheckedInMethod")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
@@ -179,9 +199,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime>("AttendanceDate")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("CheckInEndsAt")
.HasColumnType("datetime(6)");
b.Property<int>("CheckInMethod")
.HasColumnType("int");
b.Property<DateTime?>("CheckInStartsAt")
.HasColumnType("datetime(6)");
b.Property<string>("CheckInToken")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("LocationRadiusMeters")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
@@ -197,6 +233,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime?>("SubmittedAt")
.HasColumnType("datetime(6)");
b.Property<decimal?>("TargetLatitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<decimal?>("TargetLongitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
@@ -205,6 +249,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasKey("Id");
b.HasIndex("CheckInToken")
.IsUnique();
b.HasIndex("TeachingTaskId", "AttendanceDate");
b.ToTable("AttendanceSheets");