修复相关漏洞
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
+10
@@ -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)
|
||||
|
||||
+8
@@ -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("入职任务不存在");
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+109
-17
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -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(
|
||||
|
||||
+15
@@ -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)
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+30
-1
@@ -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>()
|
||||
|
||||
+5
@@ -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("入职任务不存在");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
|
||||
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
|
||||
//
|
||||
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
|
||||
// 1.请不要删除和修改根目录下的LICENSE文件。
|
||||
@@ -45,26 +45,37 @@ public class BasePageInput : IValidatableObject
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
//配合小诺排序参数
|
||||
if (SortOrder == "descend")
|
||||
SortOrder = (string.IsNullOrWhiteSpace(SortOrder) ? "desc" : SortOrder.Trim().ToLowerInvariant()) switch
|
||||
{
|
||||
SortOrder = "desc";
|
||||
}
|
||||
else if (SortOrder == "ascend")
|
||||
{
|
||||
SortOrder = "asc";
|
||||
}
|
||||
if (!string.IsNullOrEmpty(SortField))
|
||||
{
|
||||
//分割排序字段
|
||||
var fields = SortField.Split(" ");
|
||||
if (fields.Length > 1)
|
||||
{
|
||||
yield return new ValidationResult("排序字段错误", new[]
|
||||
{
|
||||
nameof(SortField)
|
||||
});
|
||||
}
|
||||
}
|
||||
"ascend" => "asc",
|
||||
"descend" => "desc",
|
||||
var value => value
|
||||
};
|
||||
if (SortOrder != "asc" && SortOrder != "desc")
|
||||
yield return new ValidationResult("排序方向仅支持 asc/desc", new[] { nameof(SortOrder) });
|
||||
|
||||
if (!string.IsNullOrEmpty(SortField)
|
||||
&& !global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
SortField, @"\A[A-Za-z_][A-Za-z0-9_]{0,63}\z"))
|
||||
yield return new ValidationResult("排序字段格式不正确", new[] { nameof(SortField) });
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)//根据关键字查询
|
||||
.WhereIF(!string.IsNullOrEmpty(input.SearchKey), it => it.Name.Contains(input.SearchKey))//根据关键字查询
|
||||
.OrderByIF(!string.IsNullOrEmpty(input.SortField), $"{input.SortField} {input.SortOrder}")//排序
|
||||
.OrderByIF(!string.IsNullOrEmpty(input.SortField), input.GetSafeOrderBy("Id", "Name", "Engine", "Bucket", "Suffix", "SizeKb", "SizeInfo", "CreateTime", "UpdateTime", "CreateUser"))//排序
|
||||
.OrderBy(it => it.Id);
|
||||
var pageInfo = await query.ToPagedListAsync(input.PageNum, input.PageSize);//分页
|
||||
return pageInfo;
|
||||
|
||||
Binary file not shown.
@@ -8,6 +8,11 @@
|
||||
<None Update="ip2region.db">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<Content Include="..\SimpleAdmin.Web.Core\Upload\员工导出模版.xlsx">
|
||||
<Link>Upload\员工导出模版.xlsx</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SimpleAdmin.Web.Core\SimpleAdmin.Web.Core.csproj" />
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
//swagger设置
|
||||
"SpecificationDocumentSettings": {
|
||||
"DocumentTitle": "未病局后台管理系统",
|
||||
"DocumentTitle": "员工后台管理系统",
|
||||
"DocExpansionState": "None",
|
||||
"GroupOpenApiInfos": []
|
||||
},
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user