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 0000000..08b352c Binary files /dev/null and b/SimpleAdmin.Web.Core/Upload/员工导出模版.xlsx differ diff --git a/SimpleAdmin.Web.Entry/SimpleAdmin.Web.Entry.csproj b/SimpleAdmin.Web.Entry/SimpleAdmin.Web.Entry.csproj index 51ac635..0b80a63 100644 --- a/SimpleAdmin.Web.Entry/SimpleAdmin.Web.Entry.csproj +++ b/SimpleAdmin.Web.Entry/SimpleAdmin.Web.Entry.csproj @@ -8,6 +8,11 @@ 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