This commit is contained in:
biss committed 2026-07-28 15:41:28 +08:00
1 parent a61dacff1a
commit 2dae303cf1
17 files changed
+6794 -88

No files matched your search

+20
View File
@@ -47,6 +47,26 @@ dotnet run --project src/Jiaowu.Api
访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。 访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。
## Capacitor Android App
`web/.env.capacitor` 配置 App 使用的 HTTPS API 与公开站点地址。生成或更新
Android 工程前先构建并同步原生插件:
```powershell
Set-Location web
npm ci
npm run build:capacitor
npm run cap:sync
npm run cap:open:android
```
学生在 App 的“我的考勤”中可调用原生相机扫描教师展示的签到二维码;二维码由服务端
签名、每 10 秒刷新并在 20 秒后失效,扫码后先显示课程和签到时限,仍需学生确认才
提交。教师可直接在手机 App 发起定位签到,以教师手机的原生精确位置作为签到点;
教室电脑没有定位模块时不影响该流程。服务端校验课程名单、签到时间、距离和定位精度,
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
最低版本为 API 26;相机和精确位置权限均按需申请。
## MySQL 8.4 生产部署 ## MySQL 8.4 生产部署
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立: 非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text;
using ClosedXML.Excel; using ClosedXML.Excel;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
@@ -180,7 +181,6 @@ public sealed class AttendanceController(
{ {
sheet.Id, sheet.Id,
sheet.CheckInMethod, sheet.CheckInMethod,
sheet.CheckInToken,
sheet.CheckInStartsAt, sheet.CheckInStartsAt,
sheet.CheckInEndsAt sheet.CheckInEndsAt
}); });
@@ -200,7 +200,6 @@ public sealed class AttendanceController(
x.AttendanceDate, x.AttendanceDate,
x.Status, x.Status,
x.CheckInMethod, x.CheckInMethod,
x.CheckInToken,
x.CheckInStartsAt, x.CheckInStartsAt,
x.CheckInEndsAt, x.CheckInEndsAt,
x.TargetLatitude, x.TargetLatitude,
@@ -247,6 +246,103 @@ public sealed class AttendanceController(
cancellationToken); cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage; var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
.Where(x => x.AttendanceSheetId == id)
.Select(x => new
{
x.StudentId,
x.CreatedAt,
x.IsSuccessful,
x.FailureCode,
x.DeviceIdentifierHash,
x.DevicePlatform,
x.IpAddress,
x.RiskFlags
})
.ToListAsync(cancellationToken);
var attemptsByStudent = attempts
.GroupBy(x => x.StudentId)
.ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
var deviceHashes = attempts
.Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
.Select(x => x.DeviceIdentifierHash!)
.Distinct(StringComparer.Ordinal)
.ToList();
var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
if (deviceHashes.Count > 0)
{
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
.Where(x =>
x.IsSuccessful &&
x.CreatedAt >= now.AddHours(-24) &&
x.DeviceIdentifierHash != null &&
deviceHashes.Contains(x.DeviceIdentifierHash))
.GroupBy(x => x.DeviceIdentifierHash!)
.Select(x => new
{
DeviceIdentifierHash = x.Key,
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
})
.ToListAsync(cancellationToken);
deviceReuseCounts = reuseRows.ToDictionary(
x => x.DeviceIdentifierHash,
x => x.StudentCount,
StringComparer.Ordinal);
}
var responseRecords = sheet.Records.Select(r =>
{
var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
var riskFlags = studentAttempts
.SelectMany(x => ParseRiskFlags(x.RiskFlags))
.ToHashSet(StringComparer.Ordinal);
if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
riskFlags.Add("RepeatedFailures");
if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
riskFlags.Add("HighFrequency");
var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
? deviceReuseCounts.GetValueOrDefault(deviceHash)
: 0;
if (sharedDeviceStudentCount > 1)
riskFlags.Add("SharedDevice");
var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
? attempts
.Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
.Select(x => x.StudentId)
.Distinct()
.Count()
: 0;
return new
{
r.StudentId,
r.StudentNumber,
r.Name,
r.ClassName,
r.Status,
r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId),
CheckInAudit = latestSuccess is null
? null
: new
{
latestSuccess.IpAddress,
latestSuccess.DevicePlatform,
DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
SharedDeviceStudentCount = sharedDeviceStudentCount,
SameIpStudentCount = sameIpStudentCount
},
AttemptCount = studentAttempts.Count,
FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
RiskFlags = riskFlags.OrderBy(x => x).ToArray()
};
}).ToList();
return Ok(new return Ok(new
{ {
Sheet = new Sheet = new
@@ -257,7 +353,6 @@ public sealed class AttendanceController(
sheet.AttendanceDate, sheet.AttendanceDate,
sheet.Status, sheet.Status,
sheet.CheckInMethod, sheet.CheckInMethod,
CheckInToken = canManage ? sheet.CheckInToken : null,
sheet.CheckInStartsAt, sheet.CheckInStartsAt,
sheet.CheckInEndsAt, sheet.CheckInEndsAt,
sheet.TargetLatitude, sheet.TargetLatitude,
@@ -276,21 +371,16 @@ public sealed class AttendanceController(
sheet.TaskName, sheet.TaskName,
sheet.CourseCode, sheet.CourseCode,
sheet.CourseName, sheet.CourseName,
Records = sheet.Records.Select(r => new Records = responseRecords,
RiskSummary = new
{ {
r.StudentId, RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
r.StudentNumber, SharedDeviceStudentCount = responseRecords.Count(
r.Name, x => x.RiskFlags.Contains("SharedDevice")),
r.ClassName, FrequentAttemptStudentCount = responseRecords.Count(
r.Status, x => x.RiskFlags.Contains("HighFrequency") ||
r.Notes, x.RiskFlags.Contains("RepeatedFailures"))
r.CheckInAt, }
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId)
})
}, },
CanEdit = canEdit CanEdit = canEdit
}); });
@@ -503,6 +593,39 @@ public sealed class AttendanceController(
return NoContent(); return NoContent();
} }
[HttpGet("sheets/{id:guid}/qr-challenge")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> GetQrChallenge(
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.CheckInMethod != AttendanceCheckInMethod.QrCode ||
string.IsNullOrWhiteSpace(sheet.CheckInToken))
return ConflictProblem("该考勤表不是扫码签到。");
var now = DateTime.UtcNow;
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return ConflictProblem("签到尚未开始或已经结束。");
var challenge = AttendanceCheckInChallenge.Create(
sheet.Id,
sheet.CheckInToken,
now);
return Ok(challenge);
}
// ═══════════════ Student endpoints ═══════════════ // ═══════════════ Student endpoints ═══════════════
[HttpGet("check-in-info")] [HttpGet("check-in-info")]
@@ -516,11 +639,13 @@ public sealed class AttendanceController(
return ConflictProblem("当前账号未关联学生档案。"); return ConflictProblem("当前账号未关联学生档案。");
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
return NotFound(); return NotFound();
if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
return NotFound();
var activity = await db.AttendanceRecords.AsNoTracking() var activity = await db.AttendanceRecords.AsNoTracking()
.Where(x => .Where(x =>
x.StudentId == studentId.Value && x.StudentId == studentId.Value &&
x.AttendanceSheet!.CheckInToken == token.Trim()) x.AttendanceSheetId == sheetId)
.Select(x => new .Select(x => new
{ {
SheetId = x.AttendanceSheetId, SheetId = x.AttendanceSheetId,
@@ -530,6 +655,7 @@ public sealed class AttendanceController(
x.AttendanceSheet.CheckInMethod, x.AttendanceSheet.CheckInMethod,
x.AttendanceSheet.CheckInStartsAt, x.AttendanceSheet.CheckInStartsAt,
x.AttendanceSheet.CheckInEndsAt, x.AttendanceSheet.CheckInEndsAt,
x.AttendanceSheet.CheckInToken,
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code, CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
CourseName = x.AttendanceSheet.TeachingTask.Course.Name, CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber, TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
@@ -539,6 +665,13 @@ public sealed class AttendanceController(
if (activity is null) return NotFound(); if (activity is null) return NotFound();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
!AttendanceCheckInChallenge.IsValid(
token,
activity.SheetId,
activity.CheckInToken,
now))
return NotFound();
return Ok(new return Ok(new
{ {
activity.SheetId, activity.SheetId,
@@ -610,8 +743,11 @@ public sealed class AttendanceController(
.Where(x => x.StudentId == studentId.Value); .Where(x => x.StudentId == studentId.Value);
if (!string.IsNullOrWhiteSpace(request.Token)) if (!string.IsNullOrWhiteSpace(request.Token))
{ {
var token = request.Token.Trim(); if (!AttendanceCheckInChallenge.TryReadSheetId(
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token); request.Token.Trim(),
out var tokenSheetId))
return NotFound();
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
} }
else if (request.AttendanceSheetId.HasValue) else if (request.AttendanceSheetId.HasValue)
{ {
@@ -627,8 +763,59 @@ public sealed class AttendanceController(
if (record?.AttendanceSheet is null) return NotFound(); if (record?.AttendanceSheet is null) return NotFound();
var sheet = record.AttendanceSheet; var sheet = record.AttendanceSheet;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
async Task<ActionResult> RejectAttemptAsync(
string failureCode,
string detail,
double? distanceMeters = null)
{
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
false,
failureCode,
distanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return ConflictProblem(detail);
}
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
!AttendanceCheckInChallenge.IsValid(
request.Token,
sheet.Id,
sheet.CheckInToken,
now))
return await RejectAttemptAsync(
"InvalidQrChallenge",
"签到二维码已失效,请重新扫描教师当前展示的二维码。");
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return await RejectAttemptAsync(
"CheckInClosed",
"签到尚未开始或已经结束。");
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
return await RejectAttemptAsync(
"ManualSheet",
"该考勤表不支持学生在线签到。");
if (record.CheckInAt.HasValue) if (record.CheckInAt.HasValue)
{ {
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
true,
null,
record.CheckInDistanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return Ok(new return Ok(new
{ {
AlreadyCheckedIn = true, AlreadyCheckedIn = true,
@@ -636,37 +823,35 @@ public sealed class AttendanceController(
record.CheckInDistanceMeters 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; double? distanceMeters = null;
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode) if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
{
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 || if (request.Latitude is < -90 or > 90 ||
request.Longitude is < -180 or > 180 || request.Longitude is < -180 or > 180 ||
request.Latitude is null || request.Latitude is null ||
request.Longitude is null) request.Longitude is null)
return ConflictProblem("未获取到有效的当前位置。"); return await RejectAttemptAsync(
"InvalidLocation",
"未获取到有效的当前位置。");
if (sheet.TargetLatitude is null || if (sheet.TargetLatitude is null ||
sheet.TargetLongitude is null || sheet.TargetLongitude is null ||
sheet.LocationRadiusMeters is null) sheet.LocationRadiusMeters is null)
return ConflictProblem("签到活动没有配置有效的位置范围。"); return await RejectAttemptAsync(
"LocationNotConfigured",
"签到活动没有配置有效的位置范围。");
var maximumAllowedAccuracyMeters = Math.Min(
100d,
sheet.LocationRadiusMeters.Value);
if (request.AccuracyMeters is null ||
!double.IsFinite(request.AccuracyMeters.Value) ||
request.AccuracyMeters <= 0 ||
request.AccuracyMeters > maximumAllowedAccuracyMeters)
{
return await RejectAttemptAsync(
"InsufficientAccuracy",
$"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
}
distanceMeters = CalculateDistanceMeters( distanceMeters = CalculateDistanceMeters(
(double)sheet.TargetLatitude.Value, (double)sheet.TargetLatitude.Value,
@@ -675,8 +860,10 @@ public sealed class AttendanceController(
(double)request.Longitude.Value); (double)request.Longitude.Value);
if (distanceMeters > sheet.LocationRadiusMeters.Value) if (distanceMeters > sheet.LocationRadiusMeters.Value)
{ {
return ConflictProblem( return await RejectAttemptAsync(
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。"); "OutsideGeofence",
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
distanceMeters);
} }
} }
@@ -693,6 +880,15 @@ public sealed class AttendanceController(
? request.AccuracyMeters ? request.AccuracyMeters
: null; : null;
record.CheckInDistanceMeters = distanceMeters; record.CheckInDistanceMeters = distanceMeters;
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
true,
null,
distanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Ok(new return Ok(new
@@ -986,6 +1182,103 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
} }
private async Task AddCheckInAttemptAsync(
AttendanceSheet sheet,
Guid studentId,
AttendanceCheckInRequest request,
bool isSuccessful,
string? failureCode,
double? distanceMeters,
DateTime now,
CancellationToken cancellationToken)
{
var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
var riskFlags = new HashSet<string>(StringComparer.Ordinal);
if (deviceIdentifierHash is null)
{
riskFlags.Add("MissingDeviceId");
}
else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
x =>
x.IsSuccessful &&
x.StudentId != studentId &&
x.DeviceIdentifierHash == deviceIdentifierHash &&
x.CreatedAt >= now.AddHours(-24),
cancellationToken))
{
riskFlags.Add("SharedDevice");
}
var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
.CountAsync(
x => x.StudentId == studentId &&
x.CreatedAt >= now.AddMinutes(-2),
cancellationToken);
if (recentAttemptCount >= 5)
riskFlags.Add("HighFrequency");
if (!isSuccessful)
{
var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
.CountAsync(
x => x.StudentId == studentId &&
!x.IsSuccessful &&
x.CreatedAt >= now.AddMinutes(-5),
cancellationToken);
if (recentFailureCount >= 2)
riskFlags.Add("RepeatedFailures");
}
var context = ControllerContext.HttpContext;
db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
{
AttendanceSheetId = sheet.Id,
StudentId = studentId,
CheckInMethod = sheet.CheckInMethod,
IsSuccessful = isSuccessful,
FailureCode = failureCode,
DeviceIdentifierHash = deviceIdentifierHash,
DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
IpAddress = Limit(
context?.Connection.RemoteIpAddress?.ToString(),
64),
UserAgent = Limit(
context?.Request.Headers.UserAgent.ToString(),
500),
RiskFlags = riskFlags.Count == 0
? null
: string.Join(',', riskFlags.OrderBy(x => x)),
Latitude = request.Latitude,
Longitude = request.Longitude,
AccuracyMeters = request.AccuracyMeters,
DistanceMeters = distanceMeters,
CreatedAt = now,
UpdatedAt = now
});
}
private static string? HashDeviceIdentifier(string? deviceId)
{
var normalized = Normalize(deviceId)?.ToLowerInvariant();
return normalized is null
? null
: Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
}
private static IEnumerable<string> ParseRiskFlags(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(
',',
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries);
private static string? Limit(string? value, int maximumLength) =>
value is null || value.Length <= maximumLength
? value
: value[..maximumLength];
private static bool IsCheckInOpen( private static bool IsCheckInOpen(
AttendanceSheetStatus status, AttendanceSheetStatus status,
AttendanceCheckInMethod method, AttendanceCheckInMethod method,
@@ -1380,7 +1673,9 @@ public sealed record AttendanceCheckInRequest(
[MaxLength(64)] string? Token, [MaxLength(64)] string? Token,
[Range(-90, 90)] decimal? Latitude, [Range(-90, 90)] decimal? Latitude,
[Range(-180, 180)] decimal? Longitude, [Range(-180, 180)] decimal? Longitude,
[Range(0, 5000)] double? AccuracyMeters); [Range(0, 5000)] double? AccuracyMeters,
[MaxLength(128)] string? DeviceId = null,
[MaxLength(32)] string? DevicePlatform = null);
public sealed record AttendanceCourseStatistics( public sealed record AttendanceCourseStatistics(
AttendanceStatisticsCourse Course, AttendanceStatisticsCourse Course,
@@ -20,6 +20,7 @@ public sealed class AttendanceSheet : EntityBase
public string? Notes { get; set; } public string? Notes { get; set; }
public DateTime? SubmittedAt { get; set; } public DateTime? SubmittedAt { get; set; }
public ICollection<AttendanceRecord> Records { get; set; } = []; public ICollection<AttendanceRecord> Records { get; set; } = [];
public ICollection<AttendanceCheckInAttempt> CheckInAttempts { get; set; } = [];
} }
public sealed class AttendanceRecord public sealed class AttendanceRecord
@@ -43,6 +44,26 @@ public sealed class AttendanceRecord
public DateTime? AppealReviewedAt { get; set; } public DateTime? AppealReviewedAt { get; set; }
} }
public sealed class AttendanceCheckInAttempt : EntityBase
{
public Guid AttendanceSheetId { get; set; }
public AttendanceSheet? AttendanceSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public AttendanceCheckInMethod CheckInMethod { get; set; }
public bool IsSuccessful { get; set; }
public string? FailureCode { get; set; }
public string? DeviceIdentifierHash { get; set; }
public string? DevicePlatform { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? RiskFlags { get; set; }
public decimal? Latitude { get; set; }
public decimal? Longitude { get; set; }
public double? AccuracyMeters { get; set; }
public double? DistanceMeters { get; set; }
}
public enum AttendanceSheetStatus public enum AttendanceSheetStatus
{ {
Draft = 1, Draft = 1,
Loaded 3 of 17 files, more files were not shown because too many files have changed in this diff. Show more