update
This commit is contained in:
1 parent
a61dacff1a
commit
2dae303cf1
17 files changed
+6794
-88
No files matched your search
@@ -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
Reference in new issue
Block a user