This commit is contained in:
2026-07-28 15:41:28 +08:00 Unverified
parent a61dacff1a
commit 2dae303cf1
17 changed files with 6796 additions and 90 deletions
@@ -57,6 +57,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
Set<AttendanceCheckInAttempt>();
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
Set<ExamArrangementJob>();
@@ -698,6 +700,29 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AttendanceCheckInAttempt>(entity =>
{
entity.Property(x => x.FailureCode).HasMaxLength(64);
entity.Property(x => x.DeviceIdentifierHash).HasMaxLength(64);
entity.Property(x => x.DevicePlatform).HasMaxLength(32);
entity.Property(x => x.IpAddress).HasMaxLength(64);
entity.Property(x => x.UserAgent).HasMaxLength(500);
entity.Property(x => x.RiskFlags).HasMaxLength(300);
entity.Property(x => x.Latitude).HasPrecision(10, 7);
entity.Property(x => x.Longitude).HasPrecision(10, 7);
entity.HasIndex(x => new { x.AttendanceSheetId, x.StudentId, x.CreatedAt });
entity.HasIndex(x => new { x.DeviceIdentifierHash, x.CreatedAt });
entity.HasIndex(x => new { x.IpAddress, x.CreatedAt });
entity.HasOne(x => x.AttendanceSheet)
.WithMany(x => x.CheckInAttempts)
.HasForeignKey(x => x.AttendanceSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamPlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -66,6 +66,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260727_35_academic_planning_prerequisites";
private const string ExamRoomMixingMigration =
"20260727_36_exam_room_mixing";
private const string AttendanceCheckInAuditMigration =
"20260728_37_attendance_check_in_audit";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -324,6 +326,18 @@ public sealed class DevelopmentSqliteMigrator(
AttendanceCheckInMigration,
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
cancellationToken);
var attendanceCheckInAttemptsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'AttendanceCheckInAttempts'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AttendanceCheckInAuditMigration,
attendanceCheckInAttemptsExist ? [] : AttendanceCheckInAuditStatements,
cancellationToken);
var approvalTablesExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
@@ -1929,6 +1943,47 @@ public sealed class DevelopmentSqliteMigrator(
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
];
private static readonly string[] AttendanceCheckInAuditStatements =
[
"""
CREATE TABLE IF NOT EXISTS "AttendanceCheckInAttempts" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AttendanceCheckInAttempts" PRIMARY KEY,
"AttendanceSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"CheckInMethod" INTEGER NOT NULL,
"IsSuccessful" INTEGER NOT NULL,
"FailureCode" TEXT NULL,
"DeviceIdentifierHash" TEXT NULL,
"DevicePlatform" TEXT NULL,
"IpAddress" TEXT NULL,
"UserAgent" TEXT NULL,
"RiskFlags" TEXT NULL,
"Latitude" TEXT NULL,
"Longitude" TEXT NULL,
"AccuracyMeters" REAL NULL,
"DistanceMeters" REAL NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId"
FOREIGN KEY ("AttendanceSheetId") REFERENCES "AttendanceSheets" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_AttendanceCheckInAttempts_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_CreatedAt"
ON "AttendanceCheckInAttempts" ("AttendanceSheetId", "StudentId", "CreatedAt");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt"
ON "AttendanceCheckInAttempts" ("DeviceIdentifierHash", "CreatedAt");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt"
ON "AttendanceCheckInAttempts" ("IpAddress", "CreatedAt");
"""
];
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,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AttendanceCheckInAudit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AttendanceCheckInAttempts",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AttendanceSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
CheckInMethod = table.Column<int>(type: "int", nullable: false),
IsSuccessful = table.Column<bool>(type: "tinyint(1)", nullable: false),
FailureCode = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
DeviceIdentifierHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
DevicePlatform = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: true),
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
RiskFlags = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
Latitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
Longitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
AccuracyMeters = table.Column<double>(type: "double", nullable: true),
DistanceMeters = table.Column<double>(type: "double", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AttendanceCheckInAttempts", x => x.Id);
table.ForeignKey(
name: "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId",
column: x => x.AttendanceSheetId,
principalTable: "AttendanceSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AttendanceCheckInAttempts_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_Create~",
table: "AttendanceCheckInAttempts",
columns: new[] { "AttendanceSheetId", "StudentId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt",
table: "AttendanceCheckInAttempts",
columns: new[] { "DeviceIdentifierHash", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt",
table: "AttendanceCheckInAttempts",
columns: new[] { "IpAddress", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_AttendanceCheckInAttempts_StudentId",
table: "AttendanceCheckInAttempts",
column: "StudentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AttendanceCheckInAttempts");
}
}
}
@@ -137,6 +137,81 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("AdministrativeClasses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceCheckInAttempt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<double?>("AccuracyMeters")
.HasColumnType("double");
b.Property<Guid>("AttendanceSheetId")
.HasColumnType("char(36)");
b.Property<int>("CheckInMethod")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DeviceIdentifierHash")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("DevicePlatform")
.HasMaxLength(32)
.HasColumnType("varchar(32)");
b.Property<double?>("DistanceMeters")
.HasColumnType("double");
b.Property<string>("FailureCode")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<bool>("IsSuccessful")
.HasColumnType("tinyint(1)");
b.Property<decimal?>("Latitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<decimal?>("Longitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<string>("RiskFlags")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.HasKey("Id");
b.HasIndex("StudentId");
b.HasIndex("DeviceIdentifierHash", "CreatedAt");
b.HasIndex("IpAddress", "CreatedAt");
b.HasIndex("AttendanceSheetId", "StudentId", "CreatedAt");
b.ToTable("AttendanceCheckInAttempts");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b =>
{
b.Property<Guid>("AttendanceSheetId")
@@ -3938,6 +4013,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Major");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceCheckInAttempt", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet")
.WithMany("CheckInAttempts")
.HasForeignKey("AttendanceSheetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("AttendanceSheet");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet")
@@ -5163,6 +5257,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b =>
{
b.Navigation("CheckInAttempts");
b.Navigation("Records");
});
@@ -0,0 +1,156 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.WebUtilities;
namespace Jiaowu.Api.Infrastructure.Teaching;
public static class AttendanceCheckInChallenge
{
public const int LifetimeSeconds = 20;
public const int RefreshSeconds = 10;
private const int MaximumClockSkewSeconds = 2;
private const string Base36Digits = "0123456789abcdefghijklmnopqrstuvwxyz";
public static AttendanceCheckInChallengeResult Create(
Guid attendanceSheetId,
string secret,
DateTime nowUtc)
{
var issuedAt = new DateTimeOffset(
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc));
var payload = CreatePayload(
attendanceSheetId,
ToBase36(issuedAt.ToUnixTimeSeconds()));
var signature = CreateSignature(payload, secret);
return new AttendanceCheckInChallengeResult(
$"{payload}.{signature}",
issuedAt.UtcDateTime,
issuedAt.AddSeconds(LifetimeSeconds).UtcDateTime,
issuedAt.AddSeconds(RefreshSeconds).UtcDateTime);
}
public static bool TryReadSheetId(string? token, out Guid attendanceSheetId)
{
attendanceSheetId = Guid.Empty;
if (!TryParse(token, out var sheetIdText, out _, out _))
return false;
return Guid.TryParseExact(sheetIdText, "N", out attendanceSheetId);
}
public static bool IsValid(
string? token,
Guid attendanceSheetId,
string? secret,
DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(secret) ||
!TryParse(token, out var sheetIdText, out var issuedAtText, out var signature) ||
!Guid.TryParseExact(sheetIdText, "N", out var tokenSheetId) ||
tokenSheetId != attendanceSheetId ||
!TryParseBase36(issuedAtText, out var issuedAtUnixSeconds))
{
return false;
}
var nowUnixSeconds = new DateTimeOffset(
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)).ToUnixTimeSeconds();
var ageSeconds = nowUnixSeconds - issuedAtUnixSeconds;
if (ageSeconds < -MaximumClockSkewSeconds || ageSeconds > LifetimeSeconds)
return false;
byte[] providedSignature;
try
{
providedSignature = WebEncoders.Base64UrlDecode(signature);
}
catch (FormatException)
{
return false;
}
var expectedSignature = WebEncoders.Base64UrlDecode(
CreateSignature(
CreatePayload(attendanceSheetId, issuedAtText),
secret));
return CryptographicOperations.FixedTimeEquals(
providedSignature,
expectedSignature);
}
private static string CreatePayload(Guid attendanceSheetId, string issuedAtText) =>
$"{attendanceSheetId:N}.{issuedAtText}";
private static string CreateSignature(string payload, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return WebEncoders.Base64UrlEncode(digest[..16]);
}
private static bool TryParse(
string? token,
out string sheetIdText,
out string issuedAtText,
out string signature)
{
sheetIdText = string.Empty;
issuedAtText = string.Empty;
signature = string.Empty;
if (string.IsNullOrWhiteSpace(token) || token.Length > 64)
return false;
var parts = token.Split('.');
if (parts.Length != 3 ||
parts[0].Length != 32 ||
parts[1].Length is < 1 or > 12 ||
parts[2].Length != 22)
{
return false;
}
sheetIdText = parts[0];
issuedAtText = parts[1];
signature = parts[2];
return true;
}
private static string ToBase36(long value)
{
if (value == 0) return "0";
Span<char> buffer = stackalloc char[13];
var index = buffer.Length;
while (value > 0)
{
buffer[--index] = Base36Digits[(int)(value % 36)];
value /= 36;
}
return new string(buffer[index..]);
}
private static bool TryParseBase36(string value, out long result)
{
result = 0;
foreach (var character in value)
{
var digit = Base36Digits.IndexOf(
char.ToLower(character, CultureInfo.InvariantCulture));
if (digit < 0) return false;
try
{
result = checked(result * 36 + digit);
}
catch (OverflowException)
{
return false;
}
}
return true;
}
}
public sealed record AttendanceCheckInChallengeResult(
string Token,
DateTime IssuedAt,
DateTime ExpiresAt,
DateTime RefreshAt);