修复相关漏洞

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> /// <summary>
/// 实现入职任务审核与退回业务能力。 /// 实现入职任务审核与退回业务能力。
@@ -28,6 +28,11 @@ public class OnboardingAuditService : IOnboardingAuditService
/// 审核通过入职任务,并在同一事务中创建正式员工档案。 /// 审核通过入职任务,并在同一事务中创建正式员工档案。
/// </summary> /// </summary>
public async Task Approve(OnboardingAuditActionInput input) 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 task = await GetAuditableTask(input.TaskId);
var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>() var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
@@ -150,6 +155,11 @@ public class OnboardingAuditService : IOnboardingAuditService
/// 退回入职任务,保存审核意见并允许候选人补充资料。 /// 退回入职任务,保存审核意见并允许候选人补充资料。
/// </summary> /// </summary>
public async Task Reject(OnboardingAuditActionInput input) 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)) 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.CompanyCode), it => it.CompanyCode.Contains(input.CompanyCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyName), it => it.CompanyName.Contains(input.CompanyName)) .WhereIF(!string.IsNullOrWhiteSpace(input.CompanyName), it => it.CompanyName.Contains(input.CompanyName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .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) .OrderBy(it => it.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
} }
@@ -297,6 +297,11 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
/// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。 /// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
/// </summary> /// </summary>
public async Task<long> Generate(OnboardingGenerateContractInput input) 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) var contract = await _db.Queryable<ObTaskContract>().InSingleAsync(input.TaskContractId)
?? throw Oops.Bah("任务合同不存在"); ?? throw Oops.Bah("任务合同不存在");
@@ -312,6 +317,11 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
/// <inheritdoc /> /// <inheritdoc />
public async Task<long> GenerateForPublicAccess(long taskContractId, long taskId) 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>() var contract = await _db.Queryable<ObTaskContract>()
.FirstAsync(item => item.Id == taskContractId && item.TaskId == taskId) .FirstAsync(item => item.Id == taskContractId && item.TaskId == taskId)
@@ -19,6 +19,14 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
long taskId, long taskId,
ObTaskEmployeeInfo employeeInfo = null, ObTaskEmployeeInfo employeeInfo = null,
bool invalidateGeneratedPreview = false) 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) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
@@ -40,7 +40,7 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
} }
var page = await query 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) .OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.TemplateName) .OrderBy(it => it.TemplateName)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <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.DepartmentCode), it => it.DepartmentCode.Contains(input.DepartmentCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentName), it => it.DepartmentName.Contains(input.DepartmentName)) .WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentName), it => it.DepartmentName.Contains(input.DepartmentName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .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.SortCode)
.OrderBy(it => it.DepartmentName) .OrderBy(it => it.DepartmentName)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
@@ -48,6 +48,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc /> /// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> Initiate( public async Task<OnboardingElectronicSignDetailOutput> Initiate(
OnboardingElectronicSignTaskInput input) OnboardingElectronicSignTaskInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => InitiateCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> InitiateCore(
OnboardingElectronicSignTaskInput input)
{ {
var task = await GetAuthorizedTask(input.TaskId); var task = await GetAuthorizedTask(input.TaskId);
ValidateTaskCanSign(task); ValidateTaskCanSign(task);
@@ -99,6 +105,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc /> /// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> Reinitiate( public async Task<OnboardingElectronicSignDetailOutput> Reinitiate(
OnboardingElectronicSignReinitiateInput input) OnboardingElectronicSignReinitiateInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ReinitiateCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> ReinitiateCore(
OnboardingElectronicSignReinitiateInput input)
{ {
var task = await GetAuthorizedTask(input.TaskId); var task = await GetAuthorizedTask(input.TaskId);
ValidateTaskCanSign(task); ValidateTaskCanSign(task);
@@ -256,6 +268,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc /> /// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> Sync( public async Task<OnboardingElectronicSignDetailOutput> Sync(
OnboardingElectronicSignTaskInput input) 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 task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id); var process = await GetRequiredProcess(task.Id);
@@ -266,6 +284,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc /> /// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> GetSignLink( public async Task<OnboardingElectronicSignDetailOutput> GetSignLink(
OnboardingElectronicSignTaskInput input) 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 task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id); var process = await GetRequiredProcess(task.Id);
@@ -289,6 +313,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc /> /// <inheritdoc />
public async Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument( public async Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
OnboardingElectronicSignPreviewInput input) 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 task = await GetTask(input.TaskId);
var process = await GetRequiredProcess(task.Id); var process = await GetRequiredProcess(task.Id);
@@ -346,6 +376,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc /> /// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> CompanySeal( public async Task<OnboardingElectronicSignDetailOutput> CompanySeal(
OnboardingElectronicSignTaskInput input) 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 task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id); var process = await GetRequiredProcess(task.Id);
@@ -378,7 +414,29 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
$"请先逐份查看员工已签文件:{string.Join("、", notPreviewed)}"); $"请先逐份查看员工已签文件:{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); 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 try
{ {
await _qiyuesuoClient.CompanySign( await _qiyuesuoClient.CompanySign(
@@ -406,10 +464,10 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
return await BuildDetail(task, process); return await BuildDetail(task, process);
} }
process.LastError = $"企业盖章失败:{LimitError(signException.Message)}"; process.LastError = $"企业盖章结果待核实,已阻止重复发送:{LimitError(signException.Message)}";
process.UpdateTime = DateTime.Now; process.UpdateTime = DateTime.Now;
await _db.Updateable(process).ExecuteCommandAsync(); await _db.Updateable(process).ExecuteCommandAsync();
throw; throw Oops.Bah("企业盖章未确认成功,请刷新状态并核实契约锁平台;为避免重复盖章,不能直接重发。原因:" + LimitError(signException.Message));
} }
await RecordAdminOperation( await RecordAdminOperation(
@@ -441,6 +499,11 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc /> /// <inheritdoc />
public async Task Notice(OnboardingElectronicSignTaskInput input) 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 task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id); var process = await GetRequiredProcess(task.Id);
@@ -455,6 +518,11 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc /> /// <inheritdoc />
public async Task Invalid(OnboardingElectronicSignInvalidInput input) 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 task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id); var process = await GetRequiredProcess(task.Id);
@@ -478,6 +546,21 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc /> /// <inheritdoc />
public async Task HandleCallback(string payloadJson) 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)) if (string.IsNullOrWhiteSpace(payloadJson))
throw Oops.Bah("契约锁回调内容为空"); throw Oops.Bah("契约锁回调内容为空");
@@ -504,6 +587,13 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
"contractStatus", "contractStatus",
"status"); "status");
var process = await FindProcess(providerContractId, bizId); var process = await FindProcess(providerContractId, bizId);
// 回调首次定位时流程可能还在创建;若此刻才匹配到,先取得任务锁再重新执行。
if (process != null && !taskLockHeld)
{
await OnboardingConcurrency.RunTask(_db, process.TaskId,
() => HandleCallbackCore(payloadJson, taskLockHeld: true));
return;
}
if (callbackEvent == null) if (callbackEvent == null)
{ {
callbackEvent = new ObElectronicSignEvent callbackEvent = new ObElectronicSignEvent
@@ -549,7 +639,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
try try
{ {
process.LastCallbackAt = DateTime.Now; process.LastCallbackAt = DateTime.Now;
await _db.Updateable(process).ExecuteCommandAsync(); await _db.Updateable(process).UpdateColumns(item => item.LastCallbackAt).ExecuteCommandAsync();
// 明文回调只作为刷新触发器,最终状态重新向契约锁查询,伪造回调不能改变业务状态。 // 明文回调只作为刷新触发器,最终状态重新向契约锁查询,伪造回调不能改变业务状态。
await SyncProcess(process, "callback"); await SyncProcess(process, "callback");
callbackEvent.ProviderStatus = process.ProviderStatus; callbackEvent.ProviderStatus = process.ProviderStatus;
@@ -939,7 +1029,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
ObElectronicSignProcess process, ObElectronicSignProcess process,
QiyuesuoContractResult providerContract) QiyuesuoContractResult providerContract)
{ {
var localStatus = ResolveSignStatus(providerContract); var localStatus = OnboardingSignStateGuard.Resolve(process.SignStatus, ResolveSignStatus(providerContract));
process.ProviderStatus = providerContract.Status; process.ProviderStatus = providerContract.Status;
process.SignStatus = localStatus; process.SignStatus = localStatus;
if (localStatus == ElectronicSignStatus.Completed if (localStatus == ElectronicSignStatus.Completed
@@ -950,7 +1040,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
process.SignUrlExpireAt = null; process.SignUrlExpireAt = null;
await _db.Updateable<ObTaskContract>() await _db.Updateable<ObTaskContract>()
.SetColumns(item => item.ContractStatus == TaskContractStatus.ElectronicSigned) .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(); .ExecuteCommandAsync();
} }
else if (IsTerminal(localStatus)) else if (IsTerminal(localStatus))
@@ -974,24 +1065,25 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
foreach (var document in documents) foreach (var document in documents)
{ {
if (document.SignedPdfFileId.HasValue) if (!document.SignedPdfFileId.HasValue)
continue; {
var signedPdf = await _qiyuesuoClient.DownloadDocument( var signedPdf = await _qiyuesuoClient.DownloadDocument(document.ProviderDocumentId);
document.ProviderDocumentId); document.SignedPdfFileId = await _fileService.StoreGeneratedFile(
var pdfFileId = await _fileService.StoreGeneratedFile( SysDictConst.FILE_ENGINE_ALIYUN_OSS,
SysDictConst.FILE_ENGINE_ALIYUN_OSS, $"{SafeFileName(process.BizId)}_{document.DocumentSort:D2}_{SafeFileName(document.DocumentName)}_已签署.pdf",
$"{SafeFileName(process.BizId)}_{document.DocumentSort:D2}_{SafeFileName(document.DocumentName)}_已签署.pdf", signedPdf);
signedPdf); document.UpdateTime = DateTime.Now;
document.SignedPdfFileId = pdfFileId; await _db.Updateable(document).ExecuteCommandAsync();
document.UpdateTime = DateTime.Now; }
await _db.Updateable(document).ExecuteCommandAsync(); // 中断重试时仍回填合同指针,不能因文档已落库而跳过未完成的关联更新。
var pdfFileId = document.SignedPdfFileId.Value;
await _db.Updateable<ObTaskContract>() await _db.Updateable<ObTaskContract>()
.SetColumns(item => new ObTaskContract .SetColumns(item => new ObTaskContract
{ {
GeneratedPdfFileId = pdfFileId, GeneratedPdfFileId = pdfFileId,
ContractStatus = TaskContractStatus.Archived ContractStatus = TaskContractStatus.Archived
}) })
.Where(item => item.Id == document.TaskContractId) .Where(item => item.Id == document.TaskContractId && process.IsCurrent == true)
.ExecuteCommandAsync(); .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> /// </summary>
public class OnboardingEmployeeService : IOnboardingEmployeeService public class OnboardingEmployeeService : IOnboardingEmployeeService
{ {
private const string EmployeeExportTemplateFileName = "员工导出模版.xlsx";
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission; private readonly IOnboardingDataPermissionService _dataPermission;
@@ -110,12 +111,17 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
}; };
}).ToList(); }).ToList();
using var package = new ExcelPackage(); var templatePath = ResolveEmployeeExportTemplatePath();
using var package = new ExcelPackage(new FileInfo(templatePath));
package.Workbook.Properties.Title = "员工明细"; package.Workbook.Properties.Title = "员工明细";
package.Workbook.Properties.Subject = "员工基本、人事状态及主要合同信息"; package.Workbook.Properties.Subject = "员工基本、人事状态及主要合同信息";
package.Workbook.Properties.Created = DateTime.Now; 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 return new OnboardingEmployeeExportFile
{ {
@@ -192,6 +198,11 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
/// <inheritdoc /> /// <inheritdoc />
public async Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input) public async Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input)
{
await OnboardingConcurrency.RunEmployee(_db, input.Id, () => UpdateHrInfoCore(input));
}
private async Task UpdateHrInfoCore(OnboardingEmployeeHrUpdateInput input)
{ {
ValidateEmploymentType(input.EmploymentType); ValidateEmploymentType(input.EmploymentType);
ValidateSocialInsuranceStatus(input.SocialInsuranceStatus); ValidateSocialInsuranceStatus(input.SocialInsuranceStatus);
@@ -275,6 +286,11 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
/// <inheritdoc /> /// <inheritdoc />
public async Task Regularize(OnboardingEmployeeRegularizeInput input) public async Task Regularize(OnboardingEmployeeRegularizeInput input)
{
await OnboardingConcurrency.RunEmployee(_db, input.Id, () => RegularizeCore(input));
}
private async Task RegularizeCore(OnboardingEmployeeRegularizeInput input)
{ {
if (!input.RegularizationDate.HasValue) if (!input.RegularizationDate.HasValue)
throw Oops.Bah("请选择转正日期"); throw Oops.Bah("请选择转正日期");
@@ -370,6 +386,11 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
/// <inheritdoc /> /// <inheritdoc />
public async Task RevokeRegularization(OnboardingEmployeeRegularizationRevokeInput input) 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)) if (string.IsNullOrWhiteSpace(input.Remark))
throw Oops.Bah("请填写撤销原因"); throw Oops.Bah("请填写撤销原因");
@@ -775,7 +796,100 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
TextColumn("工资卡开户行", row => row.Employee.BankName, 22) 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( private static void WriteContractSheet(
@@ -18,6 +18,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 归档已审核通过的入职任务。 /// 归档已审核通过的入职任务。
/// </summary> /// </summary>
public async Task Archive(OnboardingAuditActionInput input) 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); var task = await Authorize(input.TaskId);
if (task.TaskStatus != OnboardingTaskStatus.Approved) if (task.TaskStatus != OnboardingTaskStatus.Approved)
@@ -57,6 +62,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 撤销入职任务并同步撤销仍然有效的员工填写链接。 /// 撤销入职任务并同步撤销仍然有效的员工填写链接。
/// </summary> /// </summary>
public async Task Revoke(OnboardingAuditActionInput input) 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); var task = await Authorize(input.TaskId);
if (task.TaskStatus == OnboardingTaskStatus.Approved if (task.TaskStatus == OnboardingTaskStatus.Approved
@@ -102,6 +112,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 删除已撤销的入职任务及其全部关联快照数据。 /// 删除已撤销的入职任务及其全部关联快照数据。
/// </summary> /// </summary>
public async Task Delete(BaseIdInput input) 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); var task = await Authorize(input.Id);
if (task.TaskStatus != OnboardingTaskStatus.Revoked) if (task.TaskStatus != OnboardingTaskStatus.Revoked)
@@ -22,6 +22,11 @@ public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
/// 审核单份候选人上传材料并记录审核意见。 /// 审核单份候选人上传材料并记录审核意见。
/// </summary> /// </summary>
public async Task Review(OnboardingMaterialReviewInput input) 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 if (input.ReviewStatus != TaskMaterialReviewStatus.Passed
&& input.ReviewStatus != TaskMaterialReviewStatus.NeedSupplement) && 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.PositionCode), it => it.PositionCode.Contains(input.PositionCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName)) .WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .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) .OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.PositionName) .OrderBy(it => it.PositionName)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
@@ -25,6 +25,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 关联已上传 OSS 文件与任务材料要求。 /// 关联已上传 OSS 文件与任务材料要求。
/// </summary> /// </summary>
public async Task SaveMaterial(OnboardingSaveMaterialInput input) 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 task = await GetEditableTask(input.Token);
var requirement = await _db.Queryable<ObTaskMaterialRequirement>() var requirement = await _db.Queryable<ObTaskMaterialRequirement>()
@@ -84,6 +89,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// </summary> /// </summary>
public async Task DeleteMaterial( public async Task DeleteMaterial(
OnboardingDeleteMaterialInput input) 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 task = await GetEditableTask(input.Token);
var material = await _db.Queryable<ObTaskMaterial>() var material = await _db.Queryable<ObTaskMaterial>()
@@ -102,6 +113,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 记录候选人已经查看并确认动态生成的合同。 /// 记录候选人已经查看并确认动态生成的合同。
/// </summary> /// </summary>
public async Task ConfirmContract(OnboardingConfirmContractInput input) 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 task = await GetEditableTask(input.Token);
var contract = await _db.Queryable<ObTaskContract>() var contract = await _db.Queryable<ObTaskContract>()
@@ -119,6 +135,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。 /// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。
/// </summary> /// </summary>
public async Task Submit(OnboardingSubmitInput input) 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 task = await GetEditableTask(input.Token);
var info = await _db.Queryable<ObTaskEmployeeInfo>() var info = await _db.Queryable<ObTaskEmployeeInfo>()
@@ -160,11 +181,19 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
var submittedAt = DateTime.Now; var submittedAt = DateTime.Now;
var identityConfirmationHash = BuildIdentityConfirmationHash(info); var identityConfirmationHash = BuildIdentityConfirmationHash(info);
var originalStatus = task.TaskStatus;
var originalSubmitCount = task.SubmitCount;
task.TaskStatus = OnboardingTaskStatus.Reviewing; task.TaskStatus = OnboardingTaskStatus.Reviewing;
task.SubmitCount++; task.SubmitCount++;
task.LastSubmittedAt = submittedAt; task.LastSubmittedAt = submittedAt;
task.RejectReason = null; 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 await _db.Insertable(new ObTaskAudit
{ {
@@ -25,6 +25,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。 /// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。
/// </summary> /// </summary>
public async Task<OnboardingTaskPublicDetailOutput> Detail(OnboardingTaskPublicAccessInput input) 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 link = await GetActiveLink(input.Token);
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
@@ -136,6 +141,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。 /// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。
/// </summary> /// </summary>
public async Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input) 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 link = await GetActiveLink(input.Token);
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
@@ -192,6 +202,12 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// </summary> /// </summary>
public async Task<OnboardingPublicContractPrepareOutput> PrepareContract( public async Task<OnboardingPublicContractPrepareOutput> PrepareContract(
OnboardingPublicContractSourceInput input) 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 link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>() var contract = await _db.Queryable<ObTaskContract>()
@@ -233,6 +249,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。 /// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。
/// </summary> /// </summary>
public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input) 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 link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>() var contract = await _db.Queryable<ObTaskContract>()
@@ -81,6 +81,11 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
/// 保存允许人事编辑的任务合同变量值。 /// 保存允许人事编辑的任务合同变量值。
/// </summary> /// </summary>
public async Task Save(OnboardingTaskContractVariableSaveInput input) 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>() var contract = await _db.Queryable<ObTaskContract>()
.InSingleAsync(input.TaskContractId) .InSingleAsync(input.TaskContractId)
@@ -157,6 +157,11 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
/// 获取或重新生成任务有效填写链接。 /// 获取或重新生成任务有效填写链接。
/// </summary> /// </summary>
public async Task<OnboardingTaskCreatedOutput> GetTaskLink(BaseIdInput input) 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) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(input.Id)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
+33 -22
View File
@@ -1,4 +1,4 @@
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛 // Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
// //
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款: // SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。 // 1.请不要删除和修改根目录下的LICENSE文件。
@@ -45,26 +45,37 @@ public class BasePageInput : IValidatableObject
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{ {
//配合小诺排序参数 SortOrder = (string.IsNullOrWhiteSpace(SortOrder) ? "desc" : SortOrder.Trim().ToLowerInvariant()) switch
if (SortOrder == "descend")
{ {
SortOrder = "desc"; "ascend" => "asc",
} "descend" => "desc",
else if (SortOrder == "ascend") var value => value
{ };
SortOrder = "asc"; if (SortOrder != "asc" && SortOrder != "desc")
} yield return new ValidationResult("排序方向仅支持 asc/desc", new[] { nameof(SortOrder) });
if (!string.IsNullOrEmpty(SortField))
{ if (!string.IsNullOrEmpty(SortField)
//分割排序字段 && !global::System.Text.RegularExpressions.Regex.IsMatch(
var fields = SortField.Split(" "); SortField, @"\A[A-Za-z_][A-Za-z0-9_]{0,63}\z"))
if (fields.Length > 1) yield return new ValidationResult("排序字段格式不正确", new[] { nameof(SortField) });
{
yield return new ValidationResult("排序字段错误", new[]
{
nameof(SortField)
});
}
}
} }
}
/// <summary>
/// 将客户端字段映射为服务端白名单列,禁止把排序表达式传入 ORM。
/// 服务层再次校验,避免非 HTTP 调用绕过 MVC 参数校验。
/// </summary>
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}";
}
}
@@ -34,7 +34,7 @@ public class FileService : DbRepository<SysFile>, IFileService
{ {
var query = Context.Queryable<SysFile>().WhereIF(!string.IsNullOrEmpty(input.Engine), it => it.Engine == input.Engine)//根据关键字查询 var query = Context.Queryable<SysFile>().WhereIF(!string.IsNullOrEmpty(input.Engine), it => it.Engine == input.Engine)//根据关键字查询
.WhereIF(!string.IsNullOrEmpty(input.SearchKey), it => it.Name.Contains(input.SearchKey))//根据关键字查询 .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); .OrderBy(it => it.Id);
var pageInfo = await query.ToPagedListAsync(input.PageNum, input.PageSize);//分页 var pageInfo = await query.ToPagedListAsync(input.PageNum, input.PageSize);//分页
return pageInfo; return pageInfo;
Binary file not shown.
@@ -8,6 +8,11 @@
<None Update="ip2region.db"> <None Update="ip2region.db">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<Content Include="..\SimpleAdmin.Web.Core\Upload\员工导出模版.xlsx">
<Link>Upload\员工导出模版.xlsx</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SimpleAdmin.Web.Core\SimpleAdmin.Web.Core.csproj" /> <ProjectReference Include="..\SimpleAdmin.Web.Core\SimpleAdmin.Web.Core.csproj" />
+1 -1
View File
@@ -18,7 +18,7 @@
//swagger设置 //swagger设置
"SpecificationDocumentSettings": { "SpecificationDocumentSettings": {
"DocumentTitle": "未病局后台管理系统", "DocumentTitle": "员工后台管理系统",
"DocExpansionState": "None", "DocExpansionState": "None",
"GroupOpenApiInfos": [] "GroupOpenApiInfos": []
}, },
+90
View File
@@ -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", "<newline>"));
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<TestRow>().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<int> Gate(string key, Func<Task<int>> 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<string, byte> held = new();
private int acquisitions;
public int AcquisitionCount => acquisitions;
public int ActiveCount => held.Count;
public Task<IAsyncDisposable> Acquire(string key) {
if (!held.TryAdd(key, 0)) throw new InvalidOperationException("busy");
Interlocked.Increment(ref acquisitions);
return Task.FromResult<IAsyncDisposable>(new Lease(() => held.TryRemove(key, out _)));
}
private sealed class Lease(Action release) : IAsyncDisposable {
public ValueTask DisposeAsync() { release(); return ValueTask.CompletedTask; }
}
}
+34
View File
@@ -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 的多会话、多实例互斥、实际业务事务回滚、
提交与审核/撤销竞争、转正与编辑竞争、重复/乱序契约锁回调、企业盖章网络断开及中断恢复。
不得将这套离线用例当成真实平台压测或数据库集成测试结果。
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks></TargetFrameworks><OutputType>Exe</OutputType><TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings><NuGetAudit>false</NuGetAudit>
<RestoreSources>$(MSBuildThisFileDirectory)empty-feed</RestoreSources>
</PropertyGroup>
<ItemGroup>
<Reference Include="SqlSugar"><HintPath>../../SimpleAdmin.Web.Entry/bin/Debug/net6.0/SqlSugar.dll</HintPath></Reference>
<Reference Include="Newtonsoft.Json"><HintPath>../../SimpleAdmin.Web.Entry/bin/Debug/net6.0/Newtonsoft.Json.dll</HintPath></Reference>
<Compile Include="../../SimpleAdmin.Core/BaseInput/BasePageInput.cs" Link="BasePageInput.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/Common/OnboardingOperationGate.cs" Link="OnboardingOperationGate.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingSignStateGuard.cs" Link="OnboardingSignStateGuard.cs"/>
<Compile Include="../../SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs" Link="OnboardingStatus.cs"/>
</ItemGroup>
</Project>
+10
View File
@@ -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); }
}