修复相关漏洞

This commit is contained in:
fuenmao
2026-09-17 09:53:50 +08:00
parent 20ea1bc70c
commit e9894538e8
28 changed files with 757 additions and 51 deletions
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职任务审核与退回业务能力。
@@ -28,6 +28,11 @@ public class OnboardingAuditService : IOnboardingAuditService
/// 审核通过入职任务,并在同一事务中创建正式员工档案。
/// </summary>
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<ObTaskEmployeeInfo>()
@@ -150,6 +155,11 @@ public class OnboardingAuditService : IOnboardingAuditService
/// 退回入职任务,保存审核意见并允许候选人补充资料。
/// </summary>
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))
{
@@ -0,0 +1,169 @@
using System.Data.Common;
using System.Security.Cryptography;
using System.Text;
namespace SimpleAdmin.Application;
/// <summary>
/// 所有修改同一任务的入口共用 MySQL 命名锁,适用于同一主库上的多个后端实例。
/// 锁使用独立、非池化连接,不在第三方请求期间持有业务数据库事务。
/// </summary>
internal static class OnboardingConcurrency
{
public static Task RunTask(SqlSugarScope db, long taskId, Func<Task> action,
bool transactional = false, bool protectIdentity = false)
=> RunTask(db, taskId, async () => { await action(); return true; },
transactional, protectIdentity);
public static Task<T> RunTask<T>(SqlSugarScope db, long taskId, Func<Task<T>> 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<Task> action)
=> Run(db, $"employee:{employeeId}", async () => { await action(); return true; });
public static Task RunPublic(SqlSugarScope db, string token, Func<Task> action,
bool transactional = false, bool protectIdentity = false)
=> RunPublic(db, token, async () => { await action(); return true; },
transactional, protectIdentity);
public static async Task<T> RunPublic<T>(SqlSugarScope db, string token, Func<Task<T>> 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<ObTaskLink>().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<Task> action,
bool transactional = false)
=> RunContract(db, contractId, async () => { await action(); return true; }, transactional);
public static async Task<T> RunContract<T>(SqlSugarScope db, long contractId, Func<Task<T>> action,
bool transactional = false)
{
var contract = await db.Queryable<ObTaskContract>().InSingleAsync(contractId)
?? throw Oops.Bah("任务合同不存在");
return await RunTask(db, contract.TaskId, action, transactional);
}
public static async Task RunMaterial(SqlSugarScope db, long materialId, Func<Task> action)
{
var material = await db.Queryable<ObTaskMaterial>().InSingleAsync(materialId)
?? throw Oops.Bah("任务资料不存在");
await RunTask(db, material.TaskId, action, transactional: true);
}
private static async Task<T> RunPerson<T>(SqlSugarScope db, long taskId, Func<Task<T>> action)
{
var info = await db.Queryable<ObTaskEmployeeInfo>().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<T> InTransaction<T>(SqlSugarScope db, Func<Task<T>> 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<T> Run<T>(SqlSugarScope db, string resource, Func<Task<T>> 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<IAsyncDisposable> 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();
}
}
}
}
@@ -0,0 +1,35 @@
using System.Threading;
namespace SimpleAdmin.Application;
/// <summary>
/// 使同一异步调用链可重入,独立请求仍须取得外部互斥锁。
/// 不在 Acquire 中设置 AsyncLocal,避免值不能流回调用方的问题。
/// </summary>
internal static class OnboardingOperationGate
{
private sealed record Frame(string Key, Frame Parent);
private static readonly AsyncLocal<Frame> Held = new();
internal static async Task<T> Run<T>(
string key,
Func<Task<IAsyncDisposable>> acquire,
Func<Task<T>> 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;
}
}
}
@@ -21,7 +21,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, 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);
}
@@ -297,6 +297,11 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
/// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
/// </summary>
public async Task<long> Generate(OnboardingGenerateContractInput input)
{
return await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => GenerateCore(input));
}
private async Task<long> GenerateCore(OnboardingGenerateContractInput input)
{
var contract = await _db.Queryable<ObTaskContract>().InSingleAsync(input.TaskContractId)
?? throw Oops.Bah("任务合同不存在");
@@ -312,6 +317,11 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
/// <inheritdoc />
public async Task<long> GenerateForPublicAccess(long taskContractId, long taskId)
{
return await OnboardingConcurrency.RunTask(_db, taskId, () => GenerateForPublicAccessCore(taskContractId, taskId));
}
private async Task<long> GenerateForPublicAccessCore(long taskContractId, long taskId)
{
var contract = await _db.Queryable<ObTaskContract>()
.FirstAsync(item => item.Id == taskContractId && item.TaskId == taskId)
@@ -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<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
@@ -40,7 +40,7 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
}
var page = await query
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentId", "TemplateCode", "TemplateName", "ContractType"))
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.TemplateName)
.ToPagedListAsync(input.PageNum, input.PageSize);
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职业务部门业务能力。
@@ -22,7 +22,7 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, 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);
@@ -48,6 +48,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> Initiate(
OnboardingElectronicSignTaskInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => InitiateCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> InitiateCore(
OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
ValidateTaskCanSign(task);
@@ -99,6 +105,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> Reinitiate(
OnboardingElectronicSignReinitiateInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ReinitiateCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> ReinitiateCore(
OnboardingElectronicSignReinitiateInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
ValidateTaskCanSign(task);
@@ -256,6 +268,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> Sync(
OnboardingElectronicSignTaskInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => SyncCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> SyncCore(
OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -266,6 +284,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> GetSignLink(
OnboardingElectronicSignTaskInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => GetSignLinkCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> GetSignLinkCore(
OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -289,6 +313,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
OnboardingElectronicSignPreviewInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => PreviewEmployeeSignedDocumentCore(input));
}
private async Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocumentCore(
OnboardingElectronicSignPreviewInput input)
{
var task = await GetTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -346,6 +376,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> CompanySeal(
OnboardingElectronicSignTaskInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => CompanySealCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> 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<ObElectronicSignEvent>()
.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
/// <inheritdoc />
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
/// <inheritdoc />
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
/// <inheritdoc />
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<ObTaskContract>()
.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<ObTaskContract>()
.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();
}
@@ -0,0 +1,23 @@
namespace SimpleAdmin.Application;
/// <summary>同步第三方状态时的单向迁移保护;未知/陈旧结果不能覆盖已确认的签署进度。</summary>
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;
}
@@ -10,6 +10,7 @@ namespace SimpleAdmin.Application;
/// </summary>
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
/// <inheritdoc />
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
/// <inheritdoc />
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
/// <inheritdoc />
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<ExportColumn<EmployeeExportContext>> columns,
IReadOnlyList<EmployeeExportContext> 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<string, string>(StringComparer.OrdinalIgnoreCase)
{
["户口所在地"] = "户籍所在地"
};
var templateColumns = new List<(int Index, ExportColumn<EmployeeExportContext> Column)>();
var mappedHeaders = new HashSet<string>(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(
@@ -18,6 +18,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 归档已审核通过的入职任务。
/// </summary>
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
/// 撤销入职任务并同步撤销仍然有效的员工填写链接。
/// </summary>
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
/// 删除已撤销的入职任务及其全部关联快照数据。
/// </summary>
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)
@@ -22,6 +22,11 @@ public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
/// 审核单份候选人上传材料并记录审核意见。
/// </summary>
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)
@@ -23,7 +23,7 @@ public class OnboardingPositionService : DbRepository<ObPosition>, 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);
@@ -25,6 +25,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 关联已上传 OSS 文件与任务材料要求。
/// </summary>
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<ObTaskMaterialRequirement>()
@@ -84,6 +89,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// </summary>
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<ObTaskMaterial>()
@@ -102,6 +113,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 记录候选人已经查看并确认动态生成的合同。
/// </summary>
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<ObTaskContract>()
@@ -119,6 +135,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。
/// </summary>
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<ObTaskEmployeeInfo>()
@@ -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
{
@@ -25,6 +25,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。
/// </summary>
public async Task<OnboardingTaskPublicDetailOutput> Detail(OnboardingTaskPublicAccessInput input)
{
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => DetailCore(input));
}
private async Task<OnboardingTaskPublicDetailOutput> DetailCore(OnboardingTaskPublicAccessInput input)
{
var link = await GetActiveLink(input.Token);
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
@@ -136,6 +141,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。
/// </summary>
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<ObOnboardingTask>().InSingleAsync(link.TaskId)
@@ -192,6 +202,12 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// </summary>
public async Task<OnboardingPublicContractPrepareOutput> PrepareContract(
OnboardingPublicContractSourceInput input)
{
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => PrepareContractCore(input));
}
private async Task<OnboardingPublicContractPrepareOutput> PrepareContractCore(
OnboardingPublicContractSourceInput input)
{
var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>()
@@ -233,6 +249,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。
/// </summary>
public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input)
{
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => GetContractSourceFileIdCore(input));
}
private async Task<long> GetContractSourceFileIdCore(OnboardingPublicContractSourceInput input)
{
var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>()
@@ -81,6 +81,11 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
/// 保存允许人事编辑的任务合同变量值。
/// </summary>
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<ObTaskContract>()
.InSingleAsync(input.TaskContractId)
@@ -157,6 +157,11 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
/// 获取或重新生成任务有效填写链接。
/// </summary>
public async Task<OnboardingTaskCreatedOutput> GetTaskLink(BaseIdInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.Id, () => GetTaskLinkCore(input));
}
private async Task<OnboardingTaskCreatedOutput> GetTaskLinkCore(BaseIdInput input)
{
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(input.Id)
?? throw Oops.Bah("入职任务不存在");