From e9894538e8e1a62fa926a6ad8aed64123f291142 Mon Sep 17 00:00:00 2001
From: fuenmao <980740792@qq.com>
Date: Thu, 17 Sep 2026 09:53:50 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=9B=B8=E5=85=B3=E6=BC=8F?=
=?UTF-8?q?=E6=B4=9E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Audit/OnboardingAuditService.cs | 12 +-
.../Common/OnboardingConcurrency.cs | 169 ++++++++++++++++++
.../Common/OnboardingOperationGate.cs | 35 ++++
.../Company/OnboardingCompanyService.cs | 2 +-
.../OnboardingContractDocumentService.cs | 10 ++
.../OnboardingContractSnapshotService.cs | 8 +
.../OnboardingContractTemplateService.cs | 2 +-
.../Department/OnboardingDepartmentService.cs | 4 +-
.../OnboardingElectronicSignService.cs | 126 +++++++++++--
.../OnboardingSignStateGuard.cs | 23 +++
.../Employee/OnboardingEmployeeService.cs | 120 ++++++++++++-
.../OnboardingTaskLifecycleService.cs | 15 ++
.../OnboardingMaterialReviewService.cs | 5 +
.../Position/OnboardingPositionService.cs | 2 +-
.../OnboardingPublicSubmissionService.cs | 31 +++-
.../PublicTask/OnboardingPublicTaskService.cs | 21 +++
.../OnboardingTaskContractVariableService.cs | 5 +
.../TaskQuery/OnboardingTaskQueryService.cs | 5 +
SimpleAdmin.Core/BaseInput/BasePageInput.cs | 55 +++---
.../Services/Dev/File/FileService.cs | 2 +-
SimpleAdmin.Web.Core/Upload/员工导出模版.xlsx | Bin 0 -> 6188 bytes
.../SimpleAdmin.Web.Entry.csproj | 5 +
SimpleAdmin.Web.Entry/appsettings.json | 2 +-
tests/SecurityRegression/Program.cs | 90 ++++++++++
tests/SecurityRegression/README.md | 34 ++++
.../SecurityRegression.csproj | 15 ++
tests/SecurityRegression/Stubs.cs | 10 ++
tests/SecurityRegression/empty-feed/.gitkeep | 0
28 files changed, 757 insertions(+), 51 deletions(-)
create mode 100644 SimpleAdmin.Application/Services/Onboarding/Common/OnboardingConcurrency.cs
create mode 100644 SimpleAdmin.Application/Services/Onboarding/Common/OnboardingOperationGate.cs
create mode 100644 SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingSignStateGuard.cs
create mode 100644 SimpleAdmin.Web.Core/Upload/员工导出模版.xlsx
create mode 100644 tests/SecurityRegression/Program.cs
create mode 100644 tests/SecurityRegression/README.md
create mode 100644 tests/SecurityRegression/SecurityRegression.csproj
create mode 100644 tests/SecurityRegression/Stubs.cs
create mode 100644 tests/SecurityRegression/empty-feed/.gitkeep
diff --git a/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs b/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs
index 29606fa..2db0711 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs
@@ -1,4 +1,4 @@
-namespace SimpleAdmin.Application;
+namespace SimpleAdmin.Application;
///
/// 实现入职任务审核与退回业务能力。
@@ -28,6 +28,11 @@ public class OnboardingAuditService : IOnboardingAuditService
/// 审核通过入职任务,并在同一事务中创建正式员工档案。
///
public async Task Approve(OnboardingAuditActionInput input)
+ {
+ await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ApproveCore(input), protectIdentity: true);
+ }
+
+ private async Task ApproveCore(OnboardingAuditActionInput input)
{
var task = await GetAuditableTask(input.TaskId);
var employeeInfo = await _db.Queryable()
@@ -150,6 +155,11 @@ public class OnboardingAuditService : IOnboardingAuditService
/// 退回入职任务,保存审核意见并允许候选人补充资料。
///
public async Task Reject(OnboardingAuditActionInput input)
+ {
+ await OnboardingConcurrency.RunTask(_db, input.TaskId, () => RejectCore(input));
+ }
+
+ private async Task RejectCore(OnboardingAuditActionInput input)
{
if (string.IsNullOrWhiteSpace(input.AuditOpinion))
{
diff --git a/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingConcurrency.cs b/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingConcurrency.cs
new file mode 100644
index 0000000..6bb7179
--- /dev/null
+++ b/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingConcurrency.cs
@@ -0,0 +1,169 @@
+using System.Data.Common;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace SimpleAdmin.Application;
+
+///
+/// 所有修改同一任务的入口共用 MySQL 命名锁,适用于同一主库上的多个后端实例。
+/// 锁使用独立、非池化连接,不在第三方请求期间持有业务数据库事务。
+///
+internal static class OnboardingConcurrency
+{
+ public static Task RunTask(SqlSugarScope db, long taskId, Func action,
+ bool transactional = false, bool protectIdentity = false)
+ => RunTask(db, taskId, async () => { await action(); return true; },
+ transactional, protectIdentity);
+
+ public static Task RunTask(SqlSugarScope db, long taskId, Func> action,
+ bool transactional = false, bool protectIdentity = false)
+ => Run(db, $"task:{taskId}", () => protectIdentity
+ ? RunPerson(db, taskId, () => InTransaction(db, action, transactional))
+ : InTransaction(db, action, transactional));
+
+ public static Task RunEmployee(SqlSugarScope db, long employeeId, Func action)
+ => Run(db, $"employee:{employeeId}", async () => { await action(); return true; });
+
+ public static Task RunPublic(SqlSugarScope db, string token, Func action,
+ bool transactional = false, bool protectIdentity = false)
+ => RunPublic(db, token, async () => { await action(); return true; },
+ transactional, protectIdentity);
+
+ public static async Task RunPublic(SqlSugarScope db, string token, Func> action,
+ bool transactional = false, bool protectIdentity = false)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ throw Oops.Bah("入职链接 Token 不能为空");
+ var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
+ var link = await db.Queryable().FirstAsync(item => item.TokenHash == hash)
+ ?? throw Oops.Bah("链接不存在");
+ // 此处只定位锁;被保护的方法必须在锁内重新检查链接、任务状态和归属。
+ return await RunTask(db, link.TaskId, action, transactional, protectIdentity);
+ }
+
+ public static Task RunContract(SqlSugarScope db, long contractId, Func action,
+ bool transactional = false)
+ => RunContract(db, contractId, async () => { await action(); return true; }, transactional);
+
+ public static async Task RunContract(SqlSugarScope db, long contractId, Func> action,
+ bool transactional = false)
+ {
+ var contract = await db.Queryable().InSingleAsync(contractId)
+ ?? throw Oops.Bah("任务合同不存在");
+ return await RunTask(db, contract.TaskId, action, transactional);
+ }
+
+ public static async Task RunMaterial(SqlSugarScope db, long materialId, Func action)
+ {
+ var material = await db.Queryable().InSingleAsync(materialId)
+ ?? throw Oops.Bah("任务资料不存在");
+ await RunTask(db, material.TaskId, action, transactional: true);
+ }
+
+ private static async Task RunPerson(SqlSugarScope db, long taskId, Func> action)
+ {
+ var info = await db.Queryable().FirstAsync(item => item.TaskId == taskId)
+ ?? throw Oops.Bah("请先完整填写基本信息");
+ // 统一顺序:任务 -> 身份证 -> 手机;跨任务提交/建档的查重与写入也串行。
+ return await Run(db, "identity:" + info.IdCardNumber?.Trim().ToUpperInvariant(),
+ () => Run(db, "phone:" + info.Phone?.Trim(), action));
+ }
+
+ private static async Task InTransaction(SqlSugarScope db, Func> action, bool enabled)
+ {
+ if (!enabled || db.Ado.Transaction != null)
+ return await action();
+ await db.Ado.BeginTranAsync();
+ try
+ {
+ var result = await action();
+ await db.Ado.CommitTranAsync();
+ return result;
+ }
+ catch
+ {
+ await db.Ado.RollbackTranAsync();
+ throw;
+ }
+ }
+
+ private static Task Run(SqlSugarScope db, string resource, Func> action)
+ {
+ if (db.CurrentConnectionConfig.DbType != global::SqlSugar.DbType.MySql)
+ throw Oops.Bah("入职并发保护需要 MySQL 数据库");
+ // 使用已选择的业务主库,不能在只读副本/不同主库上分别申请锁。
+ var database = db.Ado.Connection.Database;
+ var key = Convert.ToHexString(SHA256.HashData(
+ Encoding.UTF8.GetBytes($"onboardx:{database}:{resource}")));
+ return OnboardingOperationGate.Run(key, () => Acquire(db, key), action);
+ }
+
+ private static async Task Acquire(SqlSugarScope db, string key)
+ {
+ var connection = (DbConnection)Activator.CreateInstance(db.Ado.Connection.GetType());
+ var builder = new DbConnectionStringBuilder
+ {
+ ConnectionString = db.CurrentConnectionConfig.ConnectionString
+ };
+ // 确保异常关闭时会真正关闭持锁会话,不把命名锁留在连接池里。
+ builder["Pooling"] = false;
+ connection.ConnectionString = builder.ConnectionString;
+ try
+ {
+ await connection.OpenAsync();
+ await using var command = connection.CreateCommand();
+ command.CommandText = "SELECT GET_LOCK(@lockName, 0)";
+ command.CommandTimeout = 5;
+ AddKey(command, key);
+ var result = await command.ExecuteScalarAsync();
+ if (result == null || result == DBNull.Value || Convert.ToInt32(result) != 1)
+ throw Oops.Bah("当前数据正在处理,请稍后刷新重试");
+ return new NamedLock(connection, key);
+ }
+ catch
+ {
+ await connection.DisposeAsync();
+ throw;
+ }
+ }
+
+ private static void AddKey(DbCommand command, string key)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = "@lockName";
+ parameter.Value = key;
+ command.Parameters.Add(parameter);
+ }
+
+ private sealed class NamedLock : IAsyncDisposable
+ {
+ private readonly DbConnection _connection;
+ private readonly string _key;
+
+ public NamedLock(DbConnection connection, string key)
+ {
+ _connection = connection;
+ _key = key;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ await using var command = _connection.CreateCommand();
+ command.CommandText = "SELECT RELEASE_LOCK(@lockName)";
+ command.CommandTimeout = 5;
+ AddKey(command, _key);
+ await command.ExecuteScalarAsync();
+ }
+ catch
+ {
+ // 非池化物理连接关闭会释放会话锁;释放错误不覆盖业务原异常。
+ }
+ finally
+ {
+ await _connection.DisposeAsync();
+ }
+ }
+ }
+}
diff --git a/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingOperationGate.cs b/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingOperationGate.cs
new file mode 100644
index 0000000..aaae251
--- /dev/null
+++ b/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingOperationGate.cs
@@ -0,0 +1,35 @@
+using System.Threading;
+
+namespace SimpleAdmin.Application;
+
+///
+/// 使同一异步调用链可重入,独立请求仍须取得外部互斥锁。
+/// 不在 Acquire 中设置 AsyncLocal,避免值不能流回调用方的问题。
+///
+internal static class OnboardingOperationGate
+{
+ private sealed record Frame(string Key, Frame Parent);
+ private static readonly AsyncLocal Held = new();
+
+ internal static async Task Run(
+ string key,
+ Func> acquire,
+ Func> action)
+ {
+ for (var frame = Held.Value; frame != null; frame = frame.Parent)
+ if (frame.Key == key)
+ return await action();
+
+ await using var lease = await acquire();
+ var previous = Held.Value;
+ Held.Value = new Frame(key, previous);
+ try
+ {
+ return await action();
+ }
+ finally
+ {
+ Held.Value = previous;
+ }
+ }
+}
diff --git a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs
index e54ebd3..518835e 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs
@@ -21,7 +21,7 @@ public class OnboardingCompanyService : DbRepository, IOnboardingComp
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyCode), it => it.CompanyCode.Contains(input.CompanyCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyName), it => it.CompanyName.Contains(input.CompanyName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
- .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
+ .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyCode", "CompanyName", "LegalName", "ContactPhone"))
.OrderBy(it => it.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize);
}
diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs
index 66fbf6c..3ef15f3 100644
--- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs
@@ -297,6 +297,11 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
/// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
///
public async Task Generate(OnboardingGenerateContractInput input)
+ {
+ return await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => GenerateCore(input));
+ }
+
+ private async Task GenerateCore(OnboardingGenerateContractInput input)
{
var contract = await _db.Queryable().InSingleAsync(input.TaskContractId)
?? throw Oops.Bah("任务合同不存在");
@@ -312,6 +317,11 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
///
public async Task GenerateForPublicAccess(long taskContractId, long taskId)
+ {
+ return await OnboardingConcurrency.RunTask(_db, taskId, () => GenerateForPublicAccessCore(taskContractId, taskId));
+ }
+
+ private async Task GenerateForPublicAccessCore(long taskContractId, long taskId)
{
var contract = await _db.Queryable()
.FirstAsync(item => item.Id == taskContractId && item.TaskId == taskId)
diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs
index 2e532d5..6045625 100644
--- a/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs
@@ -19,6 +19,14 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
long taskId,
ObTaskEmployeeInfo employeeInfo = null,
bool invalidateGeneratedPreview = false)
+ {
+ await OnboardingConcurrency.RunTask(_db, taskId, () => BuildCore(taskId, employeeInfo, invalidateGeneratedPreview), transactional: true);
+ }
+
+ private async Task BuildCore(
+ long taskId,
+ ObTaskEmployeeInfo employeeInfo = null,
+ bool invalidateGeneratedPreview = false)
{
var task = await _db.Queryable().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs
index 6ebde40..a73e131 100644
--- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs
@@ -40,7 +40,7 @@ public class OnboardingContractTemplateService : DbRepository it.CreateTime)
.OrderBy(it => it.TemplateName)
.ToPagedListAsync(input.PageNum, input.PageSize);
diff --git a/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs b/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs
index 5701d06..2c30cfb 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs
@@ -1,4 +1,4 @@
-namespace SimpleAdmin.Application;
+namespace SimpleAdmin.Application;
///
/// 实现入职业务部门业务能力。
@@ -22,7 +22,7 @@ public class OnboardingDepartmentService : DbRepository, IOnboardi
.WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentCode), it => it.DepartmentCode.Contains(input.DepartmentCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentName), it => it.DepartmentName.Contains(input.DepartmentName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
- .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
+ .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentCode", "DepartmentName", "SortCode"))
.OrderBy(it => it.SortCode)
.OrderBy(it => it.DepartmentName)
.ToPagedListAsync(input.PageNum, input.PageSize);
diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs
index d696ce1..cfa408c 100644
--- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs
@@ -48,6 +48,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
///
public async Task Initiate(
OnboardingElectronicSignTaskInput input)
+ {
+ return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => InitiateCore(input));
+ }
+
+ private async Task InitiateCore(
+ OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
ValidateTaskCanSign(task);
@@ -99,6 +105,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
///
public async Task Reinitiate(
OnboardingElectronicSignReinitiateInput input)
+ {
+ return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ReinitiateCore(input));
+ }
+
+ private async Task ReinitiateCore(
+ OnboardingElectronicSignReinitiateInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
ValidateTaskCanSign(task);
@@ -256,6 +268,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
///
public async Task Sync(
OnboardingElectronicSignTaskInput input)
+ {
+ return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => SyncCore(input));
+ }
+
+ private async Task SyncCore(
+ OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -266,6 +284,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
///
public async Task GetSignLink(
OnboardingElectronicSignTaskInput input)
+ {
+ return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => GetSignLinkCore(input));
+ }
+
+ private async Task GetSignLinkCore(
+ OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -289,6 +313,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
///
public async Task PreviewEmployeeSignedDocument(
OnboardingElectronicSignPreviewInput input)
+ {
+ return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => PreviewEmployeeSignedDocumentCore(input));
+ }
+
+ private async Task PreviewEmployeeSignedDocumentCore(
+ OnboardingElectronicSignPreviewInput input)
{
var task = await GetTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -346,6 +376,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
///
public async Task CompanySeal(
OnboardingElectronicSignTaskInput input)
+ {
+ return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => CompanySealCore(input));
+ }
+
+ private async Task CompanySealCore(
+ OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -378,7 +414,29 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
$"请先逐份查看员工已签文件:{string.Join("、", notPreviewed)}");
}
+ // 发出前持久化占用。进程退出/响应丢失也不能自动重发可能已送达的盖章命令。
+ var alreadySubmitted = await _db.Queryable()
+ .AnyAsync(item => item.SignProcessId == process.Id
+ && item.EventSource == AdminCompanySealEventSource
+ && (item.EventType == "company_seal_submitted"
+ || item.EventType == "company_seal_dispatch_started"));
+ if (alreadySubmitted)
+ throw Oops.Bah("企业盖章命令已发送或结果待确认,请先刷新签署状态并核实契约锁平台,系统不会重复盖章");
+
var companyName = await GetCompanyLegalName(task);
+ await RecordAdminOperation(
+ process,
+ AdminCompanySealEventSource,
+ "company_seal_dispatch_started",
+ JsonSerializer.Serialize(new
+ {
+ process.ProviderContractId,
+ companyName,
+ process.SealId,
+ operatorUserId = UserManager.UserId,
+ operatorName = UserManager.Name
+ }),
+ "已占用盖章请求;发送结果待确认,禁止自动重复发送");
try
{
await _qiyuesuoClient.CompanySign(
@@ -406,10 +464,10 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
return await BuildDetail(task, process);
}
- process.LastError = $"企业盖章失败:{LimitError(signException.Message)}";
+ process.LastError = $"企业盖章结果待核实,已阻止重复发送:{LimitError(signException.Message)}";
process.UpdateTime = DateTime.Now;
await _db.Updateable(process).ExecuteCommandAsync();
- throw;
+ throw Oops.Bah("企业盖章未确认成功,请刷新状态并核实契约锁平台;为避免重复盖章,不能直接重发。原因:" + LimitError(signException.Message));
}
await RecordAdminOperation(
@@ -441,6 +499,11 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
///
public async Task Notice(OnboardingElectronicSignTaskInput input)
+ {
+ await OnboardingConcurrency.RunTask(_db, input.TaskId, () => NoticeCore(input));
+ }
+
+ private async Task NoticeCore(OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -455,6 +518,11 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
///
public async Task Invalid(OnboardingElectronicSignInvalidInput input)
+ {
+ await OnboardingConcurrency.RunTask(_db, input.TaskId, () => InvalidCore(input));
+ }
+
+ private async Task InvalidCore(OnboardingElectronicSignInvalidInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -478,6 +546,21 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
///
public async Task HandleCallback(string payloadJson)
+ {
+ if (string.IsNullOrWhiteSpace(payloadJson))
+ throw Oops.Bah("契约锁回调内容为空");
+ using var json = JsonDocument.Parse(payloadJson);
+ var process = await FindProcess(FindString(json.RootElement, "contractId"), FindString(json.RootElement, "bizId"));
+ if (process == null)
+ {
+ await HandleCallbackCore(payloadJson);
+ return;
+ }
+ // 只在锁外定位任务;锁内重新读取事件和流程,重复回调不会同时执行归档。
+ await OnboardingConcurrency.RunTask(_db, process.TaskId, () => HandleCallbackCore(payloadJson, taskLockHeld: true));
+ }
+
+ private async Task HandleCallbackCore(string payloadJson, bool taskLockHeld = false)
{
if (string.IsNullOrWhiteSpace(payloadJson))
throw Oops.Bah("契约锁回调内容为空");
@@ -504,6 +587,13 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
"contractStatus",
"status");
var process = await FindProcess(providerContractId, bizId);
+ // 回调首次定位时流程可能还在创建;若此刻才匹配到,先取得任务锁再重新执行。
+ if (process != null && !taskLockHeld)
+ {
+ await OnboardingConcurrency.RunTask(_db, process.TaskId,
+ () => HandleCallbackCore(payloadJson, taskLockHeld: true));
+ return;
+ }
if (callbackEvent == null)
{
callbackEvent = new ObElectronicSignEvent
@@ -549,7 +639,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
try
{
process.LastCallbackAt = DateTime.Now;
- await _db.Updateable(process).ExecuteCommandAsync();
+ await _db.Updateable(process).UpdateColumns(item => item.LastCallbackAt).ExecuteCommandAsync();
// 明文回调只作为刷新触发器,最终状态重新向契约锁查询,伪造回调不能改变业务状态。
await SyncProcess(process, "callback");
callbackEvent.ProviderStatus = process.ProviderStatus;
@@ -939,7 +1029,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
ObElectronicSignProcess process,
QiyuesuoContractResult providerContract)
{
- var localStatus = ResolveSignStatus(providerContract);
+ var localStatus = OnboardingSignStateGuard.Resolve(process.SignStatus, ResolveSignStatus(providerContract));
process.ProviderStatus = providerContract.Status;
process.SignStatus = localStatus;
if (localStatus == ElectronicSignStatus.Completed
@@ -950,7 +1040,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
process.SignUrlExpireAt = null;
await _db.Updateable()
.SetColumns(item => item.ContractStatus == TaskContractStatus.ElectronicSigned)
- .Where(item => item.TaskId == process.TaskId)
+ .Where(item => item.TaskId == process.TaskId && process.IsCurrent == true
+ && item.ContractStatus != TaskContractStatus.Archived)
.ExecuteCommandAsync();
}
else if (IsTerminal(localStatus))
@@ -974,24 +1065,25 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
foreach (var document in documents)
{
- if (document.SignedPdfFileId.HasValue)
- continue;
- var signedPdf = await _qiyuesuoClient.DownloadDocument(
- document.ProviderDocumentId);
- var pdfFileId = await _fileService.StoreGeneratedFile(
- SysDictConst.FILE_ENGINE_ALIYUN_OSS,
- $"{SafeFileName(process.BizId)}_{document.DocumentSort:D2}_{SafeFileName(document.DocumentName)}_已签署.pdf",
- signedPdf);
- document.SignedPdfFileId = pdfFileId;
- document.UpdateTime = DateTime.Now;
- await _db.Updateable(document).ExecuteCommandAsync();
+ if (!document.SignedPdfFileId.HasValue)
+ {
+ var signedPdf = await _qiyuesuoClient.DownloadDocument(document.ProviderDocumentId);
+ document.SignedPdfFileId = await _fileService.StoreGeneratedFile(
+ SysDictConst.FILE_ENGINE_ALIYUN_OSS,
+ $"{SafeFileName(process.BizId)}_{document.DocumentSort:D2}_{SafeFileName(document.DocumentName)}_已签署.pdf",
+ signedPdf);
+ document.UpdateTime = DateTime.Now;
+ await _db.Updateable(document).ExecuteCommandAsync();
+ }
+ // 中断重试时仍回填合同指针,不能因文档已落库而跳过未完成的关联更新。
+ var pdfFileId = document.SignedPdfFileId.Value;
await _db.Updateable()
.SetColumns(item => new ObTaskContract
{
GeneratedPdfFileId = pdfFileId,
ContractStatus = TaskContractStatus.Archived
})
- .Where(item => item.Id == document.TaskContractId)
+ .Where(item => item.Id == document.TaskContractId && process.IsCurrent == true)
.ExecuteCommandAsync();
}
diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingSignStateGuard.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingSignStateGuard.cs
new file mode 100644
index 0000000..f1b967b
--- /dev/null
+++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingSignStateGuard.cs
@@ -0,0 +1,23 @@
+namespace SimpleAdmin.Application;
+
+/// 同步第三方状态时的单向迁移保护;未知/陈旧结果不能覆盖已确认的签署进度。
+internal static class OnboardingSignStateGuard
+{
+ internal static string Resolve(string current, string incoming)
+ {
+ if (current == ElectronicSignStatus.Archived && incoming == ElectronicSignStatus.Completed)
+ return ElectronicSignStatus.Archived;
+ if (IsTerminal(current) && current != incoming)
+ throw Oops.Bah("契约锁返回状态与本地终态不一致,已保留本地状态,请核实后重试");
+ if (current == ElectronicSignStatus.PendingCompanySeal
+ && incoming is not (ElectronicSignStatus.PendingCompanySeal
+ or ElectronicSignStatus.Completed or ElectronicSignStatus.Archived
+ or ElectronicSignStatus.Rejected or ElectronicSignStatus.Invalid or ElectronicSignStatus.Expired))
+ throw Oops.Bah("契约锁状态暂未同步,已保留员工签署结果,请稍后重试");
+ return incoming;
+ }
+
+ private static bool IsTerminal(string status)
+ => status is ElectronicSignStatus.Completed or ElectronicSignStatus.Archived
+ or ElectronicSignStatus.Rejected or ElectronicSignStatus.Invalid or ElectronicSignStatus.Expired;
+}
diff --git a/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs b/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs
index 5cd6d66..ea2e11f 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs
@@ -10,6 +10,7 @@ namespace SimpleAdmin.Application;
///
public class OnboardingEmployeeService : IOnboardingEmployeeService
{
+ private const string EmployeeExportTemplateFileName = "员工导出模版.xlsx";
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
@@ -110,12 +111,17 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
};
}).ToList();
- using var package = new ExcelPackage();
+ var templatePath = ResolveEmployeeExportTemplatePath();
+ using var package = new ExcelPackage(new FileInfo(templatePath));
package.Workbook.Properties.Title = "员工明细";
package.Workbook.Properties.Subject = "员工基本、人事状态及主要合同信息";
package.Workbook.Properties.Created = DateTime.Now;
- WriteEmployeeSheet(package.Workbook.Worksheets.Add("员工明细"), employeeRows);
+ var worksheet = package.Workbook.Worksheets.FirstOrDefault(item =>
+ item.Dimension != null
+ && string.Equals(item.Cells[1, 1].Text.Trim(), "姓名", StringComparison.OrdinalIgnoreCase))
+ ?? throw Oops.Bah("员工导出模板未找到以“姓名”开头的表头行,请检查模板内容");
+ WriteEmployeeSheet(worksheet, employeeRows);
return new OnboardingEmployeeExportFile
{
@@ -192,6 +198,11 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
///
public async Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input)
+ {
+ await OnboardingConcurrency.RunEmployee(_db, input.Id, () => UpdateHrInfoCore(input));
+ }
+
+ private async Task UpdateHrInfoCore(OnboardingEmployeeHrUpdateInput input)
{
ValidateEmploymentType(input.EmploymentType);
ValidateSocialInsuranceStatus(input.SocialInsuranceStatus);
@@ -275,6 +286,11 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
///
public async Task Regularize(OnboardingEmployeeRegularizeInput input)
+ {
+ await OnboardingConcurrency.RunEmployee(_db, input.Id, () => RegularizeCore(input));
+ }
+
+ private async Task RegularizeCore(OnboardingEmployeeRegularizeInput input)
{
if (!input.RegularizationDate.HasValue)
throw Oops.Bah("请选择转正日期");
@@ -370,6 +386,11 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
///
public async Task RevokeRegularization(OnboardingEmployeeRegularizationRevokeInput input)
+ {
+ await OnboardingConcurrency.RunEmployee(_db, input.Id, () => RevokeRegularizationCore(input));
+ }
+
+ private async Task RevokeRegularizationCore(OnboardingEmployeeRegularizationRevokeInput input)
{
if (string.IsNullOrWhiteSpace(input.Remark))
throw Oops.Bah("请填写撤销原因");
@@ -775,7 +796,100 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
TextColumn("工资卡开户行", row => row.Employee.BankName, 22)
};
- WriteWorksheet(worksheet, columns, rows);
+ WriteEmployeeTemplateWorksheet(worksheet, columns, rows);
+ }
+
+ private static void WriteEmployeeTemplateWorksheet(
+ ExcelWorksheet worksheet,
+ IReadOnlyList> columns,
+ IReadOnlyList rows)
+ {
+ var templateColumnCount = worksheet.Dimension?.End.Column ?? 0;
+ if (templateColumnCount == 0)
+ throw Oops.Bah("员工导出模板没有配置表头");
+
+ var columnsByHeader = columns.ToDictionary(
+ item => item.Header,
+ item => item,
+ StringComparer.OrdinalIgnoreCase);
+ var headerAliases = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["户口所在地"] = "户籍所在地"
+ };
+ var templateColumns = new List<(int Index, ExportColumn Column)>();
+ var mappedHeaders = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ for (var columnIndex = 1; columnIndex <= templateColumnCount; columnIndex++)
+ {
+ var templateHeader = worksheet.Cells[1, columnIndex].Text?.Trim();
+ if (string.IsNullOrWhiteSpace(templateHeader))
+ throw Oops.Bah($"员工导出模板第 {columnIndex} 列表头为空,请补充后重新导出");
+
+ var exportHeader = headerAliases.GetValueOrDefault(templateHeader, templateHeader);
+ if (!columnsByHeader.TryGetValue(exportHeader, out var exportColumn))
+ throw Oops.Bah($"员工导出模板包含无法识别的表头“{templateHeader}”,请检查模板");
+ if (!mappedHeaders.Add(exportHeader))
+ throw Oops.Bah($"员工导出模板表头“{templateHeader}”重复,请检查模板");
+
+ templateColumns.Add((columnIndex, exportColumn));
+ }
+
+ var missingHeaders = columns
+ .Where(item => !mappedHeaders.Contains(item.Header))
+ .Select(item => item.Header)
+ .ToList();
+ if (missingHeaders.Count > 0)
+ throw Oops.Bah($"员工导出模板缺少表头:{string.Join("、", missingHeaders)}");
+
+ var existingLastRow = worksheet.Dimension?.End.Row ?? 1;
+ for (var rowIndex = 2; rowIndex <= existingLastRow; rowIndex++)
+ {
+ for (var columnIndex = 1; columnIndex <= templateColumnCount; columnIndex++)
+ worksheet.Cells[rowIndex, columnIndex].Value = null;
+ }
+
+ for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
+ {
+ var excelRow = rowIndex + 2;
+ foreach (var templateColumn in templateColumns)
+ {
+ var cell = worksheet.Cells[excelRow, templateColumn.Index];
+ var value = templateColumn.Column.Value(rows[rowIndex]);
+ if (value != null)
+ cell.Value = value;
+
+ var templateNumberFormat = worksheet.Cells[1, templateColumn.Index]
+ .Style.Numberformat.Format;
+ cell.Style.Numberformat.Format = string.IsNullOrWhiteSpace(templateNumberFormat)
+ || string.Equals(
+ templateNumberFormat,
+ "General",
+ StringComparison.OrdinalIgnoreCase)
+ ? templateColumn.Column.NumberFormat
+ : templateNumberFormat;
+ }
+ }
+ }
+
+ private static string ResolveEmployeeExportTemplatePath()
+ {
+ var currentDirectory = Directory.GetCurrentDirectory();
+ var candidates = new[]
+ {
+ Path.Combine(AppContext.BaseDirectory, "Upload", EmployeeExportTemplateFileName),
+ Path.Combine(currentDirectory, "Upload", EmployeeExportTemplateFileName),
+ Path.Combine(currentDirectory, "SimpleAdmin.Web.Core", "Upload", EmployeeExportTemplateFileName),
+ Path.GetFullPath(Path.Combine(
+ currentDirectory,
+ "..",
+ "SimpleAdmin.Web.Core",
+ "Upload",
+ EmployeeExportTemplateFileName))
+ };
+
+ var templatePath = candidates.FirstOrDefault(File.Exists);
+ return templatePath
+ ?? throw Oops.Bah("员工导出模板文件不存在,请检查部署目录中的 Upload/员工导出模版.xlsx");
}
private static void WriteContractSheet(
diff --git a/SimpleAdmin.Application/Services/Onboarding/Lifecycle/OnboardingTaskLifecycleService.cs b/SimpleAdmin.Application/Services/Onboarding/Lifecycle/OnboardingTaskLifecycleService.cs
index 1a19244..fd85a2d 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Lifecycle/OnboardingTaskLifecycleService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Lifecycle/OnboardingTaskLifecycleService.cs
@@ -18,6 +18,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 归档已审核通过的入职任务。
///
public async Task Archive(OnboardingAuditActionInput input)
+ {
+ await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ArchiveCore(input));
+ }
+
+ private async Task ArchiveCore(OnboardingAuditActionInput input)
{
var task = await Authorize(input.TaskId);
if (task.TaskStatus != OnboardingTaskStatus.Approved)
@@ -57,6 +62,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 撤销入职任务并同步撤销仍然有效的员工填写链接。
///
public async Task Revoke(OnboardingAuditActionInput input)
+ {
+ await OnboardingConcurrency.RunTask(_db, input.TaskId, () => RevokeCore(input));
+ }
+
+ private async Task RevokeCore(OnboardingAuditActionInput input)
{
var task = await Authorize(input.TaskId);
if (task.TaskStatus == OnboardingTaskStatus.Approved
@@ -102,6 +112,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 删除已撤销的入职任务及其全部关联快照数据。
///
public async Task Delete(BaseIdInput input)
+ {
+ await OnboardingConcurrency.RunTask(_db, input.Id, () => DeleteCore(input));
+ }
+
+ private async Task DeleteCore(BaseIdInput input)
{
var task = await Authorize(input.Id);
if (task.TaskStatus != OnboardingTaskStatus.Revoked)
diff --git a/SimpleAdmin.Application/Services/Onboarding/MaterialReview/OnboardingMaterialReviewService.cs b/SimpleAdmin.Application/Services/Onboarding/MaterialReview/OnboardingMaterialReviewService.cs
index cc03113..5a94669 100644
--- a/SimpleAdmin.Application/Services/Onboarding/MaterialReview/OnboardingMaterialReviewService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/MaterialReview/OnboardingMaterialReviewService.cs
@@ -22,6 +22,11 @@ public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
/// 审核单份候选人上传材料并记录审核意见。
///
public async Task Review(OnboardingMaterialReviewInput input)
+ {
+ await OnboardingConcurrency.RunMaterial(_db, input.TaskMaterialId, () => ReviewCore(input));
+ }
+
+ private async Task ReviewCore(OnboardingMaterialReviewInput input)
{
if (input.ReviewStatus != TaskMaterialReviewStatus.Passed
&& input.ReviewStatus != TaskMaterialReviewStatus.NeedSupplement)
diff --git a/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs b/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs
index 69119f9..964ff1f 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs
@@ -23,7 +23,7 @@ public class OnboardingPositionService : DbRepository, IOnboardingPo
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionCode), it => it.PositionCode.Contains(input.PositionCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
- .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
+ .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentId", "PositionCode", "PositionName"))
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.PositionName)
.ToPagedListAsync(input.PageNum, input.PageSize);
diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs
index 54c442a..0fb0db1 100644
--- a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs
@@ -25,6 +25,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 关联已上传 OSS 文件与任务材料要求。
///
public async Task SaveMaterial(OnboardingSaveMaterialInput input)
+ {
+ await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveMaterialCore(input), transactional: true);
+ }
+
+ private async Task SaveMaterialCore(OnboardingSaveMaterialInput input)
{
var task = await GetEditableTask(input.Token);
var requirement = await _db.Queryable()
@@ -84,6 +89,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
///
public async Task DeleteMaterial(
OnboardingDeleteMaterialInput input)
+ {
+ await OnboardingConcurrency.RunPublic(_db, input.Token, () => DeleteMaterialCore(input), transactional: true);
+ }
+
+ private async Task DeleteMaterialCore(
+ OnboardingDeleteMaterialInput input)
{
var task = await GetEditableTask(input.Token);
var material = await _db.Queryable()
@@ -102,6 +113,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 记录候选人已经查看并确认动态生成的合同。
///
public async Task ConfirmContract(OnboardingConfirmContractInput input)
+ {
+ await OnboardingConcurrency.RunPublic(_db, input.Token, () => ConfirmContractCore(input), transactional: true);
+ }
+
+ private async Task ConfirmContractCore(OnboardingConfirmContractInput input)
{
var task = await GetEditableTask(input.Token);
var contract = await _db.Queryable()
@@ -119,6 +135,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。
///
public async Task Submit(OnboardingSubmitInput input)
+ {
+ await OnboardingConcurrency.RunPublic(_db, input.Token, () => SubmitCore(input), transactional: true, protectIdentity: true);
+ }
+
+ private async Task SubmitCore(OnboardingSubmitInput input)
{
var task = await GetEditableTask(input.Token);
var info = await _db.Queryable()
@@ -160,11 +181,19 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
var submittedAt = DateTime.Now;
var identityConfirmationHash = BuildIdentityConfirmationHash(info);
+ var originalStatus = task.TaskStatus;
+ var originalSubmitCount = task.SubmitCount;
task.TaskStatus = OnboardingTaskStatus.Reviewing;
task.SubmitCount++;
task.LastSubmittedAt = submittedAt;
task.RejectReason = null;
- await _db.Updateable(task).ExecuteCommandAsync();
+ var changed = await _db.Updateable(task)
+ .UpdateColumns(item => new { item.TaskStatus, item.SubmitCount, item.LastSubmittedAt, item.RejectReason })
+ .Where(item => item.Id == task.Id && item.TaskStatus == originalStatus
+ && item.SubmitCount == originalSubmitCount)
+ .ExecuteCommandAsync();
+ if (changed != 1)
+ throw Oops.Bah("任务状态已变更,请刷新后重试");
await _db.Insertable(new ObTaskAudit
{
diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs
index 9995f6c..df8b4ee 100644
--- a/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs
@@ -25,6 +25,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。
///
public async Task Detail(OnboardingTaskPublicAccessInput input)
+ {
+ return await OnboardingConcurrency.RunPublic(_db, input.Token, () => DetailCore(input));
+ }
+
+ private async Task DetailCore(OnboardingTaskPublicAccessInput input)
{
var link = await GetActiveLink(input.Token);
var task = await _db.Queryable().InSingleAsync(link.TaskId)
@@ -136,6 +141,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。
///
public async Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input)
+ {
+ await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveEmployeeInfoCore(input), transactional: true);
+ }
+
+ private async Task SaveEmployeeInfoCore(OnboardingTaskPublicSaveInput input)
{
var link = await GetActiveLink(input.Token);
var task = await _db.Queryable().InSingleAsync(link.TaskId)
@@ -192,6 +202,12 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
///
public async Task PrepareContract(
OnboardingPublicContractSourceInput input)
+ {
+ return await OnboardingConcurrency.RunPublic(_db, input.Token, () => PrepareContractCore(input));
+ }
+
+ private async Task PrepareContractCore(
+ OnboardingPublicContractSourceInput input)
{
var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable()
@@ -233,6 +249,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。
///
public async Task GetContractSourceFileId(OnboardingPublicContractSourceInput input)
+ {
+ return await OnboardingConcurrency.RunPublic(_db, input.Token, () => GetContractSourceFileIdCore(input));
+ }
+
+ private async Task GetContractSourceFileIdCore(OnboardingPublicContractSourceInput input)
{
var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable()
diff --git a/SimpleAdmin.Application/Services/Onboarding/TaskContractVariable/OnboardingTaskContractVariableService.cs b/SimpleAdmin.Application/Services/Onboarding/TaskContractVariable/OnboardingTaskContractVariableService.cs
index 97fdc6d..65a1e84 100644
--- a/SimpleAdmin.Application/Services/Onboarding/TaskContractVariable/OnboardingTaskContractVariableService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/TaskContractVariable/OnboardingTaskContractVariableService.cs
@@ -81,6 +81,11 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
/// 保存允许人事编辑的任务合同变量值。
///
public async Task Save(OnboardingTaskContractVariableSaveInput input)
+ {
+ await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => SaveCore(input), transactional: true);
+ }
+
+ private async Task SaveCore(OnboardingTaskContractVariableSaveInput input)
{
var contract = await _db.Queryable()
.InSingleAsync(input.TaskContractId)
diff --git a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs
index 591f981..0dbb22b 100644
--- a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs
@@ -157,6 +157,11 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
/// 获取或重新生成任务有效填写链接。
///
public async Task GetTaskLink(BaseIdInput input)
+ {
+ return await OnboardingConcurrency.RunTask(_db, input.Id, () => GetTaskLinkCore(input));
+ }
+
+ private async Task GetTaskLinkCore(BaseIdInput input)
{
var task = await _db.Queryable().InSingleAsync(input.Id)
?? throw Oops.Bah("入职任务不存在");
diff --git a/SimpleAdmin.Core/BaseInput/BasePageInput.cs b/SimpleAdmin.Core/BaseInput/BasePageInput.cs
index 8eb2057..584dbdf 100644
--- a/SimpleAdmin.Core/BaseInput/BasePageInput.cs
+++ b/SimpleAdmin.Core/BaseInput/BasePageInput.cs
@@ -1,4 +1,4 @@
-// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
+// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
//
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。
@@ -45,26 +45,37 @@ public class BasePageInput : IValidatableObject
public IEnumerable Validate(ValidationContext validationContext)
{
- //配合小诺排序参数
- if (SortOrder == "descend")
+ SortOrder = (string.IsNullOrWhiteSpace(SortOrder) ? "desc" : SortOrder.Trim().ToLowerInvariant()) switch
{
- SortOrder = "desc";
- }
- else if (SortOrder == "ascend")
- {
- SortOrder = "asc";
- }
- if (!string.IsNullOrEmpty(SortField))
- {
- //分割排序字段
- var fields = SortField.Split(" ");
- if (fields.Length > 1)
- {
- yield return new ValidationResult("排序字段错误", new[]
- {
- nameof(SortField)
- });
- }
- }
+ "ascend" => "asc",
+ "descend" => "desc",
+ var value => value
+ };
+ if (SortOrder != "asc" && SortOrder != "desc")
+ yield return new ValidationResult("排序方向仅支持 asc/desc", new[] { nameof(SortOrder) });
+
+ if (!string.IsNullOrEmpty(SortField)
+ && !global::System.Text.RegularExpressions.Regex.IsMatch(
+ SortField, @"\A[A-Za-z_][A-Za-z0-9_]{0,63}\z"))
+ yield return new ValidationResult("排序字段格式不正确", new[] { nameof(SortField) });
}
-}
+
+ ///
+ /// 将客户端字段映射为服务端白名单列,禁止把排序表达式传入 ORM。
+ /// 服务层再次校验,避免非 HTTP 调用绕过 MVC 参数校验。
+ ///
+ public string GetSafeOrderBy(params string[] allowedFields)
+ {
+ var error = Validate(new ValidationContext(this)).FirstOrDefault();
+ if (error != null)
+ throw Oops.Bah(error.ErrorMessage);
+ if (string.IsNullOrEmpty(SortField))
+ return string.Empty;
+ var field = allowedFields.FirstOrDefault(item =>
+ string.Equals(item, SortField, StringComparison.OrdinalIgnoreCase));
+ if (field == null)
+ throw Oops.Bah("不支持按此字段排序");
+ return $"{field} {SortOrder}";
+ }
+
+}
\ No newline at end of file
diff --git a/SimpleAdmin.System/Services/Dev/File/FileService.cs b/SimpleAdmin.System/Services/Dev/File/FileService.cs
index 1bffd96..9d15eb3 100644
--- a/SimpleAdmin.System/Services/Dev/File/FileService.cs
+++ b/SimpleAdmin.System/Services/Dev/File/FileService.cs
@@ -34,7 +34,7 @@ public class FileService : DbRepository, IFileService
{
var query = Context.Queryable().WhereIF(!string.IsNullOrEmpty(input.Engine), it => it.Engine == input.Engine)//根据关键字查询
.WhereIF(!string.IsNullOrEmpty(input.SearchKey), it => it.Name.Contains(input.SearchKey))//根据关键字查询
- .OrderByIF(!string.IsNullOrEmpty(input.SortField), $"{input.SortField} {input.SortOrder}")//排序
+ .OrderByIF(!string.IsNullOrEmpty(input.SortField), input.GetSafeOrderBy("Id", "Name", "Engine", "Bucket", "Suffix", "SizeKb", "SizeInfo", "CreateTime", "UpdateTime", "CreateUser"))//排序
.OrderBy(it => it.Id);
var pageInfo = await query.ToPagedListAsync(input.PageNum, input.PageSize);//分页
return pageInfo;
diff --git a/SimpleAdmin.Web.Core/Upload/员工导出模版.xlsx b/SimpleAdmin.Web.Core/Upload/员工导出模版.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..08b352cc48b0e1fccedc349d820f6488be7c7b9b
GIT binary patch
literal 6188
zcmdscXH-*d({AX!NL4zaia;RrF1<^yQUnr;G)Y2Jdap_c>0OBQj(`Y=^dcY-ItYj$
zQUnAMIML^<}%$pxn`oPiG@uC008g-6qW*(ojkkgJLr`(
zCICQ&KKrD<<;Z)dyrAwbV%*nlmQ^0v5JZSTqT5
zpILKMW)m~MYM6h2t4j9s!ft>EW$ObiQ6CH=JxuxPkfuxSmFy9s$P2f&qZx7?MSQNh
zmVp-mHv~4Nzo&4D6egGB3;pQT=2YT>^C(D92&<+@#zF8~BIPB%NdyzAnx;aITdo
zVlX#bV6=L>o8QG@KQSiqsTLNp@Pu+@Jw1@`YG%Jwp@^#`B$Ll!{P9ycT71AIRHrD1
zlKe+eNkHAyOJxs;h`U&SyiJ|xm7P;A@5n=^tO%otb~oX47snyFn(;o1qXU9A9o@fp
zAxpw0LE!=b{7L`-$v?cn5xy=^_%Bu#v+qBfRH5qYjDb$c8ZO4~(WcehomKPyte76D
zK~>R1#T|g(r^ekCxI{Dbt
zR*dYH)TgT~_~vjg6W0^l4(I#B96ioiRR)kVqo+{f7u(ZXFZ3W%0shK%Gdj6jOz!70
zL$fY)5n~9~At&ZN8dT|C^HW;F-G`GkcXo}Mq2iYF$hKU|gZYc9m;aB=>DuU&dw4z=v7^WOV
zE8*x
zdh}8Fh)U8T*6$3L#27(*1N03(G+I16$ck{KLTwcr)W(my)qFqBO7||*0BRh)rp{C>
zO9^@$UkSX=5ARwg_ML1HlOlZ@cR(qhHWW6>ukw(g&&i^+e|$_)v|JRV17w}b4jJ_{
zA+d@?n2dHWI+dwm4LV?0E)?3D@rD-U7UbcZNxM{Sde2R6qZGZ(haUj~QBP~W?Od1$;-Qyv{0QWvt<)J#uNXSE>I9tzPc;EvQRa57uzFgoz>fe)5K
z1(Oe3t%kVkp6=Wvmk^ZIo!aM-u}X;cbe#b^7g)zrWX9Lqn(;*5pr!Z`ce+w-SU3Pm
zMdSdj0iee}C&51NKc=U|!<<4Np1(w#(K{gME*!wK97xAyY$42M{-jXk*Y7usk%%<&pASPQ1IbfjABXQsC26LOKEINeboB(&kxX#yG
zD&HzNl4(iTzh|ew#eDFwjgKN{uSwyV|8k>xSb?p6TsH^7&p3dUCU!1WDVcOR8@}{y
zX_kHVanXnNeJTl)-k4_Z3u}P|b{pdo3MV=PuoSA&F+F|Ngreg8@nE*BL`X1K_-8Dg
zZG21}LITX{)*QYeQ=TKlgm2digHaN>oD@gt)r77hN?8tyyt&V{x%?GWLfE{xJ9-~#
zDzpLT4WzUj>SR%il85c_SUU3zrow#A`ow0d7`jHh^{cZHA4B{4Uz_2yB^mV`kbn-$
z@8=anj!A!B>KAm9=Xirs7khnt!=pM`^BZ}wI`8Sy;%`DWP0vQMOAGd+NX_YF6y)3m+ug&%88w-
z(5GaN9goTMoB~1U1DQG$PBVqfh6t*pf^^-O6T#Mn3$kG7YlT*S!$H4z*!dRXEMKeM
zA3GOpoxdOpguYg4^@j}lg~QIvQqJf$P72yDV^U_U6Aypf(YFfhE$MS-ZLOqo6=Idr
zYHwL5rJStPAxt$M+Rl0U6Jt62egF|R>W#dc7RBz6j>&~b-x9bTwoh`j@^QrlW6{?!Tm2KU$GAhscZ7Ls7{>uMfTl*%Q68Lq^Jo#?{98efPp5lT^~Lkv8kV8
zULGQwMpo2y^@`aXH|?cM36cOu`jTE>eGso5)F9fg3gd!V04i?7|O
z)Z6IWNZB#VsNs_EMz#p>=ziYLN2%(F{r=?fOFT6aD@
zZwB5W`P7qajk~-=x3o*f{F4T?Exy)sdHSBL=D_1Q-L5RtcXyIz@8qe|84$dd_{-J<
z9av%kfTXuK0w|n9L$5JTo`p2=#+|WA4z-l3FuRfv?kTZv`2KCaA)w%C1?G6o!%@W^q^i>EDm9%85<7s;M8j_~1!K75N
z&qZd1Sz{?Qx&rXcQfgDU;=yUQZM_xvq#89M@-$H1RJEeG60lQBEtQnaSjIwz@J_`P-gd-;NZfgSM7peVw
zuKeu5g{9Qv`TkqT4|V!^+H34flJ_84n1P)!@Xkbm|8k=umit0
zVVU!G;foAxg4lwUW&$Fkye_s<8!bb{Nf(ibjF7<>n!0giE6=;wTt0bXLcOjm$1eAU
zmdQ)~?yd!~l?K3mM&HBa#)j3h4r65g1StErnB1Yc!M2X;aYv4$_QC011bf{hYeDTv
z@U9V~JTPhtYq8b2&1+W=nE#2@yx+;!a(HCq6}Fh}8SG74n?Rg~J60;lL8g1!v|6}G
zJE6?a=>#9{d;#&or$_}=GTD^6HZ-C*)eK)ZoJ*K)zJtwUN5|lo?#Z;2-fD7A-6y1E
zU!P|Q__{s+!63TY=3&tY49~D3^D75_O(bNwci*JDiGaFpQ1<~c3Lb;G#fs;`()A=F
z)qz1p;a#vMHWz@Y6JJ+K&`ygB#{%D<)sedDX}&0#B9k^RZna$y0V-NZe$0aKBSF_C
z4JA-4NiUT(UNkJV%lu73*W(c)6IPRnFsZv(MX%TO7}nBR`YV4dAou)w6WS8Q{I}0`
z1USHKb3pnH
zhhy=&Cc0-}@z#?QtE(}t5a3d!MTzkiT4Ear`zEuhvt`ilZtlBTuWKYPh^!;oT8Ur0
z)4iL(^+33@p6lp?`38C~~J*>!sFgf87_(;W5&d_xDkI>T+kmoR=JAhCP`(
z#wFUFlNBJ^A{mpHX|B_@(-q23ugp2-&VgC}Ef;L{c9|T3*0wybc}wHZwsbJ=*IWS-
zQtzI)Ju>wiHNczEs*b4I&R9)S9ajza)yo=St=f3_PK`;KONXqB;nd7G*2Z@(qa791
zj&j)0mdR)2@XYeUwEu))U0L~f-+DK;r*6In^^-S{d%*J3a^}S0KK-NFet<}IvQjTBPDs#EY@nUa
zvGfo-_CQ}mBxaC519-Qe
zBwKNm`M9yhw)s6%F_TYeVHYz?
zM1y@rw4qp3yFzSOhK89%KZHhEnZLaI2sr`CvEtIR!+CEFObP+rrbje@lZ$rK^~4$zc0TE$vFa)Ej$DPrPlT)0%>gymi`A=>Tuj@0=K)I+9OYlC=&d
zpInAGr#)jb`!3ogg)$@CuDqqAE7)Za$b-AMRmr^UuVS69e!1a>S>P3t<0LftMU*Vz
zn~=+4I$xQH&v^G}vo#U3C|qHw>~bSHKJ(1|Z>Lt__lhr|o$8Ht>dim>72FXDMZnSi
zMW22rQrA=&_bGv!=$2w5M;tU_`Q()awYf$xjRq0b>~OE|4Rr>T8;Fev%l3iI*NCYC
z%@@D}UxiNG7`V*swa&Kz*~{Nd4|D>+%dRhzEC@@8%v7h~78*(&De8I-H#!797rDM_
z%V@4oHO%wm4`X#TntnX3A}m(=T(C5~wXDSp!nWPSV^GfH*1%@;4pD@y<6>?EUiRdw
zSX0(M5AiyF)6Esb4&0zEK_zeaCT2(%N*&qk8di}iCvI>STCIeo)M)>Z6>BF;vA994
z_be%wu=Ji|v*}4}t!i}Bo|aOhWv4|8e46Y6-pa~MRxAgUQToa{p}H;$#8Q%#U#9_*
z;eu}_vI6r8GbxprfUuZ;ho}XW^>4uobcs??eu4?Br|Sj9Y}^Fqk`dffM-+wQlXs4c
zn5aB4^&Gd4gYLcKHuNNXsbJzj9$vR}7iWC>zTt<+rX_i*Imt-whGQniG%{EJATjE>
zAL99wf`qQUg0Dhkr>J|H))cJ|Ue)?I&z6Z?exFbOdV)NEJb7_SLOJ8V+4=T7Z)S0^
z`g~q)@xymLV7=w!(<0-!eJuufDs>}giJG>OrGrK^9bHWhJ!;lWZGg)uu7au1Vqd9D
zmXKS$L^vx56)$>~(0i?_Mp7Bx8$(9|KRPP@Lqvdol~(O%Ll1id$dvYDJd|!y;)qY+jF;_HVl8NU!vCFkxaMV7to3lO-c(HzBYvD}riF!Ywoiah
z#G?4rrPkRCQzaUaQ_~;FF7OMha#+Jnt;7_8=@o~;u1AR}v_|?WLLz)VjbC4>Qpd$U
zl9-)Sqz{A$uDaaIwH^p;jjoKKZSl-Q1SmF}PS?!!^Wl5We2*BRPBzVZxG$>?VaqPO73=^u246`zW{^&z5>W%
zXajSAB4mJ|YeOd>i#kLGs1LOf5(NuDBYOK_C&h&2H_NYj!qY-
zZc`&mjW!G!UnvcC?zGtE
zDzv_Uxin$ezM+8CKWULJwlXCoykbi3%}9gHpVONn?s(;6pux8
zzr0^5`NZI#bEbeJZ4%Rn#Cczr8sUsoAY=Mqa1^>5P~F&WzMrD601=^a<;ZyOBeR|M
z6Ip?3TzW&^={Jlhk5;Ryj7@zArbd^~9rqv;UY{hkJ3f}IGnw)pL_4WOR+eeJcpXS8
zyyg_E)nK8c4vU
zZZzA*n5sZlr4aN7SsaQg4rfVi9S?eV$O*2HO8Iid%r7p{d>~{js#mEeDVv_X2udl~
zs|@DPNX3Jw3kH96#D1^`$(?YHrIj$f7}Q$X65lep^Zd;QBb{rp%$f(uZ4_w?SI(*Y
z6UXq(Y~YeOi|<;5#1VO>>VD0W9XX=)j8Ld*PX3ckh=;iEr#)1pl9i(Q=Ck6%G9BLf
zF*#$%iM0~{7=Pj6`;0(LT}=#3Dy;uJh((k7`&AtL>vV00TsOWxt^CIn04NT=iFVR2
zA^HDim)A|NH+g?d8SsCbUT^uX16-G_e*j|9N$uZ6>~)0e8tD(h19a#4e;@sSD5mQu
z*GtMD6c~E2{VyoLtL%S|;!XaqD8I|pAC!O3?0I?na|`3FY~9diG&TXi*Y&
PreserveNewest
+
+ Upload\员工导出模版.xlsx
+ PreserveNewest
+ PreserveNewest
+
diff --git a/SimpleAdmin.Web.Entry/appsettings.json b/SimpleAdmin.Web.Entry/appsettings.json
index 87c6c64..f7d09ee 100644
--- a/SimpleAdmin.Web.Entry/appsettings.json
+++ b/SimpleAdmin.Web.Entry/appsettings.json
@@ -18,7 +18,7 @@
//swagger设置
"SpecificationDocumentSettings": {
- "DocumentTitle": "未病局后台管理系统",
+ "DocumentTitle": "员工后台管理系统",
"DocExpansionState": "None",
"GroupOpenApiInfos": []
},
diff --git a/tests/SecurityRegression/Program.cs b/tests/SecurityRegression/Program.cs
new file mode 100644
index 0000000..d47f56c
--- /dev/null
+++ b/tests/SecurityRegression/Program.cs
@@ -0,0 +1,90 @@
+using System.Collections.Concurrent;
+using SimpleAdmin.Core;
+using SimpleAdmin.Application;
+using SqlSugar;
+
+var passed = 0;
+void Check(bool condition, string name) {
+ if (!condition) throw new Exception("FAIL: " + name);
+ passed++;
+ Console.WriteLine("PASS: " + name);
+}
+void Reject(Action action, string name) {
+ try { action(); } catch (InvalidOperationException) { Check(true, name); return; }
+ throw new Exception("FAIL: expected rejection: " + name);
+}
+
+// Uses the real BasePageInput and real ORM SQL generation; never opens a DB connection.
+using var db = new SqlSugarClient(new ConnectionConfig {
+ ConnectionString = "Server=127.0.0.1;Database=never_connected;Uid=unused;Pwd=unused;",
+ DbType = SqlSugar.DbType.MySql, IsAutoCloseConnection = true
+});
+foreach (var order in new[] { "asc, CASE WHEN 1=1 THEN 0 ELSE 1 END", "desc;DROP TABLE x", "asc--", "asc\tLIMIT 1", "random()" })
+ Reject(() => new BasePageInput { SortField = "Id", SortOrder = order }.GetSafeOrderBy("Id"), "reject sort direction: " + order);
+foreach (var field in new[] { "Id,(CASE\tWHEN\t1=1\tTHEN\t0\tELSE\t1\tEND)", "Id\tDESC", "Id/**/", "Id;SELECT", "Id\n", "u.Id", "`Id`", "Id,Name", new string('a', 65) })
+ Reject(() => new BasePageInput { SortField = field }.GetSafeOrderBy("Id"), "reject sort field: " + field.Replace("\n", ""));
+Reject(() => new BasePageInput { SortField = "NotAllowed" }.GetSafeOrderBy("Id"), "column whitelist enforced");
+foreach (var sample in new[] { ("ascend", "asc"), ("descend", "desc"), (" ASC ", "asc"), ("", "desc"), ((string)null, "desc") }) {
+ var input = new BasePageInput { SortField = "id", SortOrder = sample.Item1 };
+ var orderBy = input.GetSafeOrderBy("Id", "Name");
+ Check(orderBy == "Id " + sample.Item2, "normalizes direction and maps column: " + sample.Item1);
+ var sql = db.Queryable().OrderBy(orderBy).ToSql().Key;
+ Check(sql.Contains("ORDER BY", StringComparison.OrdinalIgnoreCase) && !sql.Contains("CASE"), "safe ORM SQL generated");
+}
+Check(new BasePageInput().GetSafeOrderBy("Id") == "", "no requested sort preserves default");
+
+// Uses the real state guard and production status constants.
+Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingEmployeeSign, ElectronicSignStatus.PendingCompanySeal)
+ == ElectronicSignStatus.PendingCompanySeal, "employee signed -> company seal");
+Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingCompanySeal, ElectronicSignStatus.Completed)
+ == ElectronicSignStatus.Completed, "company seal -> completed");
+Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.Archived, ElectronicSignStatus.Completed)
+ == ElectronicSignStatus.Archived, "completed provider result preserves local archive");
+foreach (var terminal in new[] { ElectronicSignStatus.Completed, ElectronicSignStatus.Archived, ElectronicSignStatus.Invalid, ElectronicSignStatus.Expired, ElectronicSignStatus.Rejected }) {
+ Check(OnboardingSignStateGuard.Resolve(terminal, terminal) == terminal, "terminal replay allowed: " + terminal);
+ Reject(() => OnboardingSignStateGuard.Resolve(terminal, ElectronicSignStatus.PendingEmployeeSign), "terminal rollback rejected: " + terminal);
+}
+foreach (var backward in new[] { ElectronicSignStatus.PendingEmployeeSign, ElectronicSignStatus.Draft, ElectronicSignStatus.Creating, ElectronicSignStatus.Failed })
+ Reject(() => OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingCompanySeal, backward), "signed progress cannot regress: " + backward);
+
+// Exercises the real async gate with a fake external lease. Does NOT claim MySQL GET_LOCK integration.
+var leases = new FakeLeases();
+Task Gate(string key, Func> action) => OnboardingOperationGate.Run(key, () => leases.Acquire(key), action);
+var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+var first = Gate("task:1", async () => { entered.SetResult(); await release.Task; return 1; });
+await entered.Task;
+var contenders = Enumerable.Range(0, 64).Select(async _ => {
+ try { await Gate("task:1", () => Task.FromResult(2)); return false; }
+ catch (InvalidOperationException) { return true; }
+}).ToArray();
+Check((await Task.WhenAll(contenders)).All(x => x), "64 independent same-task operations rejected while held");
+Check(await Gate("task:2", () => Task.FromResult(7)) == 7, "different tasks proceed independently");
+release.SetResult();
+await first;
+Check(await Gate("task:1", () => Task.FromResult(3)) == 3, "lock released after completion");
+var before = leases.AcquisitionCount;
+Check(await Gate("nested", () => Gate("nested", () => Gate("identity", () => Gate("nested", () => Task.FromResult(9))))) == 9,
+ "nested task/snapshot call is reentrant");
+Check(leases.AcquisitionCount - before == 2, "reentrant gate acquires each resource only once");
+try { await Gate("failure", () => throw new ApplicationException("synthetic fault")); } catch (ApplicationException) {}
+Check(await Gate("failure", () => Task.FromResult(4)) == 4, "lock released on exception");
+Check(leases.ActiveCount == 0, "no leaked leases or caller async context");
+Console.WriteLine("ALL " + passed + " CHECKS PASSED (offline; no business database or provider contacted)");
+
+[SugarTable("test_row")]
+public class TestRow { public long Id { get; set; } public string Name { get; set; } }
+public sealed class FakeLeases {
+ private readonly ConcurrentDictionary held = new();
+ private int acquisitions;
+ public int AcquisitionCount => acquisitions;
+ public int ActiveCount => held.Count;
+ public Task Acquire(string key) {
+ if (!held.TryAdd(key, 0)) throw new InvalidOperationException("busy");
+ Interlocked.Increment(ref acquisitions);
+ return Task.FromResult(new Lease(() => held.TryRemove(key, out _)));
+ }
+ private sealed class Lease(Action release) : IAsyncDisposable {
+ public ValueTask DisposeAsync() { release(); return ValueTask.CompletedTask; }
+ }
+}
diff --git a/tests/SecurityRegression/README.md b/tests/SecurityRegression/README.md
new file mode 100644
index 0000000..897220b
--- /dev/null
+++ b/tests/SecurityRegression/README.md
@@ -0,0 +1,34 @@
+# 第 2、5 项安全修复离线回归
+
+本测试直接链接生产代码的 BasePageInput、OnboardingOperationGate、OnboardingSignStateGuard 和状态常量。
+不启动业务程序、不连接业务数据库、不联系契约锁,也不查询在线漏洞库。
+SqlSugar 只生成 SQL。互斥测试使用模拟的外部租约,不代表 MySQL 集成测试。
+
+## 运行
+
+先在后端构建相应 net6.0 项目,确保 SimpleAdmin.Web.Entry/bin/Debug/net6.0 下已有 SqlSugar.dll 和 Newtonsoft.Json.dll。
+测试需要本机已安装 .NET 8 SDK/运行时。
+
+在后端目录执行:
+
+```powershell
+dotnet run --project tests/SecurityRegression/SecurityRegression.csproj -p:NuGetAudit=false -p:UseSharedCompilation=false
+```
+
+测试使用本地 empty-feed,不向外网发送依赖信息。无新增 NuGet 包。
+框架校验属性和友好异常由 Stubs.cs 替代;核心排序、异步重入和状态迁移逻辑直接引用源文件。
+
+## 覆盖
+
+- SQL 排序表达式、注释、分号、制表符、换行、未知字段被拒绝。
+- asc/desc/ascend/descend、大小写及空排序方向兼容。
+- 安全排序由本地 ORM 生成 SQL。
+- 签约终态、防回退、归档状态保持。
+- 64 个竞争调用与同任务互斥;不同任务互不阻塞。
+- 同调用链嵌套任务锁可重入;正常/异常结束释放锁;调用方上下文不泄漏。
+
+## 仍需测试环境验证
+
+MySQL GET_LOCK/RELEASE_LOCK 的多会话、多实例互斥、实际业务事务回滚、
+提交与审核/撤销竞争、转正与编辑竞争、重复/乱序契约锁回调、企业盖章网络断开及中断恢复。
+不得将这套离线用例当成真实平台压测或数据库集成测试结果。
diff --git a/tests/SecurityRegression/SecurityRegression.csproj b/tests/SecurityRegression/SecurityRegression.csproj
new file mode 100644
index 0000000..70324da
--- /dev/null
+++ b/tests/SecurityRegression/SecurityRegression.csproj
@@ -0,0 +1,15 @@
+
+
+ Exenet8.0
+ enablefalse
+ $(MSBuildThisFileDirectory)empty-feed
+
+
+ ../../SimpleAdmin.Web.Entry/bin/Debug/net6.0/SqlSugar.dll
+ ../../SimpleAdmin.Web.Entry/bin/Debug/net6.0/Newtonsoft.Json.dll
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/SecurityRegression/Stubs.cs b/tests/SecurityRegression/Stubs.cs
new file mode 100644
index 0000000..c0ffd50
--- /dev/null
+++ b/tests/SecurityRegression/Stubs.cs
@@ -0,0 +1,10 @@
+global using System.ComponentModel.DataAnnotations;
+namespace SimpleAdmin.Core {
+ [AttributeUsage(AttributeTargets.Property)]
+ public sealed class DataValidationAttribute : Attribute { public DataValidationAttribute(ValidationTypes type) {} }
+ public enum ValidationTypes { Numeric }
+ public static class Oops { public static Exception Bah(string text) => new InvalidOperationException(text); }
+}
+namespace SimpleAdmin.Application {
+ public static class Oops { public static Exception Bah(string text) => new InvalidOperationException(text); }
+}
diff --git a/tests/SecurityRegression/empty-feed/.gitkeep b/tests/SecurityRegression/empty-feed/.gitkeep
new file mode 100644
index 0000000..e69de29