using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace SimpleAdmin.Application; /// /// 实现入职合同“员工先签、后台核对、企业人工盖章”的契约锁电子签流程。 /// public class OnboardingElectronicSignService : IOnboardingElectronicSignService { private const string AdminPreviewEventSource = "admin_preview"; private const string AdminCompanySealEventSource = "admin_company_seal"; private const string PreviewEventTypePrefix = "employee_signed_preview:"; private readonly SqlSugarScope _db; private readonly IQiyuesuoClient _qiyuesuoClient; private readonly IOnboardingContractDocumentService _contractDocumentService; private readonly IFileService _fileService; private readonly IOnboardingDataPermissionService _dataPermission; private readonly QiyuesuoSettingOptions _setting; public OnboardingElectronicSignService( IQiyuesuoClient qiyuesuoClient, IOnboardingContractDocumentService contractDocumentService, IFileService fileService, IOnboardingDataPermissionService dataPermission) { _db = DbContext.DB; _qiyuesuoClient = qiyuesuoClient; _contractDocumentService = contractDocumentService; _fileService = fileService; _dataPermission = dataPermission; _setting = App.GetConfig( "QiyuesuoSetting", true) ?? new QiyuesuoSettingOptions(); } /// public async Task Detail( BaseIdInput input) { var task = await GetTask(input.Id); return await BuildDetail(task); } /// public async Task Initiate( OnboardingElectronicSignTaskInput input) { return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => InitiateCore(input)); } private async Task InitiateCore( OnboardingElectronicSignTaskInput input) { var task = await GetAuthorizedTask(input.TaskId); ValidateTaskCanSign(task); ValidateSettings(); var process = await GetLatestProcess(task.Id); var isNewProcess = process == null; var creatingAt = process?.UpdateTime ?? process?.StartedAt; if (process?.SignStatus == ElectronicSignStatus.Creating && creatingAt > DateTime.Now.AddMinutes(-5)) { return await BuildDetail(task, process); } if (process != null && process.SignStatus != ElectronicSignStatus.Failed && process.SignStatus != ElectronicSignStatus.Creating && process.SignStatus != ElectronicSignStatus.Draft) { return await BuildDetail(task, process); } var context = await GetInitiationContext(task); if (process == null) { var created = await CreateProcess( task, context.Employee, context.CategoryId, context.SealId); process = created.Process; isNewProcess = created.Created; } if (string.IsNullOrWhiteSpace(process.ProviderContractId)) { // 第三方合同尚未创建时允许采用公司资料中的最新配置,便于修正配置后直接重试。 process.CategoryId = context.CategoryId; process.SealId = context.SealId; } return await StartProcess( task, context.CompanyName, context.TaskContracts, process, isNewProcess); } /// public async Task Reinitiate( OnboardingElectronicSignReinitiateInput input) { return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ReinitiateCore(input)); } private async Task ReinitiateCore( OnboardingElectronicSignReinitiateInput input) { var task = await GetAuthorizedTask(input.TaskId); ValidateTaskCanSign(task); ValidateSettings(); var previousProcess = await GetLatestProcess(task.Id) ?? throw Oops.Bah("该入职任务尚未发起电子签,无需重新发起"); ValidateProcessCanReinitiate(previousProcess); var context = await GetInitiationContext(task); var process = await CreateReplacementProcess( task, context.Employee, context.CategoryId, context.SealId, previousProcess, input.Reason.Trim()); return await StartProcess( task, context.CompanyName, context.TaskContracts, process, true); } /// /// 加载并校验电子签发起所需的员工、公司和合同数据。 /// private async Task<( ObEmployee Employee, string CompanyName, string CategoryId, string SealId, List TaskContracts)> GetInitiationContext( ObOnboardingTask task) { var employee = await _db.Queryable() .InSingleAsync(task.EmployeeId.Value) ?? throw Oops.Bah("员工档案不存在,不能发起电子签"); if (string.IsNullOrWhiteSpace(employee.EmployeeName)) throw Oops.Bah("正式员工姓名为空,不能发起电子签"); if (string.IsNullOrWhiteSpace(employee.Phone)) throw Oops.Bah("正式员工手机号为空,不能发起电子签"); var employeeInfo = await _db.Queryable() .FirstAsync(item => item.TaskId == task.Id) ?? throw Oops.Bah("审核通过的入职登记资料不存在,不能发起电子签"); ValidateSignerIdentity(employee, employeeInfo); var company = await _db.Queryable() .InSingleAsync(task.CompanyId) ?? throw Oops.Bah("任务所属公司不存在,不能发起电子签"); var companyName = string.IsNullOrWhiteSpace(company.LegalName) ? company.CompanyName : company.LegalName; companyName = companyName?.Trim(); if (string.IsNullOrWhiteSpace(companyName)) throw Oops.Bah("任务所属公司法定名称为空,不能发起电子签"); var categoryId = company.QiyuesuoCategoryId?.Trim(); var sealId = company.QiyuesuoSealId?.Trim(); if (string.IsNullOrWhiteSpace(categoryId)) throw Oops.Bah($"公司“{companyName}”未配置契约锁默认业务分类 Id,请先到公司与岗位中编辑公司资料"); if (string.IsNullOrWhiteSpace(sealId)) throw Oops.Bah($"公司“{companyName}”未配置契约锁企业印章 Id,请先到公司与岗位中编辑公司资料"); if (!categoryId.All(char.IsDigit) || !sealId.All(char.IsDigit)) { throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或企业印章 Id 只能填写纯数字"); } if (!long.TryParse(categoryId, out _) || !long.TryParse(sealId, out _)) throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或企业印章 Id 超出有效范围(最大支持19位长整数)"); var taskContracts = await _db.Queryable() .Where(item => item.TaskId == task.Id) .OrderBy(item => item.Id) .ToListAsync(); if (taskContracts.Count == 0) throw Oops.Bah("入职任务没有可发起签约的合同"); var unconfirmed = taskContracts.FirstOrDefault(item => item.Required && !item.ConfirmedAt.HasValue); if (unconfirmed != null) throw Oops.Bah($"合同“{unconfirmed.ContractName}”尚未由员工确认"); return (employee, companyName, categoryId, sealId, taskContracts); } /// /// 从本地断点创建或继续契约锁流程,并统一保存失败原因。 /// private async Task StartProcess( ObOnboardingTask task, string companyName, List taskContracts, ObElectronicSignProcess process, bool isNewProcess) { var creatingAt = process.UpdateTime ?? process.StartedAt; if (!isNewProcess && process.SignStatus == ElectronicSignStatus.Creating && creatingAt > DateTime.Now.AddMinutes(-5)) { return await BuildDetail(task, process); } if (!isNewProcess) { var originalStatus = process.SignStatus; var originalUpdateTime = process.UpdateTime; var claimedAt = DateTime.Now; var claimedRows = await _db.Updateable() .SetColumns(item => new ObElectronicSignProcess { SignStatus = ElectronicSignStatus.Creating, LastError = null, UpdateTime = claimedAt, UpdateUserId = UserManager.UserId, UpdateUser = UserManager.Name }) .Where(item => item.Id == process.Id && item.IsCurrent == true && item.SignStatus == originalStatus && item.UpdateTime == originalUpdateTime) .ExecuteCommandAsync(); if (claimedRows != 1) throw Oops.Bah("电子签流程正在由其他请求处理,请稍后刷新"); process.SignStatus = ElectronicSignStatus.Creating; process.LastError = null; process.UpdateTime = claimedAt; process.UpdateUserId = UserManager.UserId; process.UpdateUser = UserManager.Name; } try { await StartOrResumeProcess( task, companyName, taskContracts, process); } catch (Exception exception) { // 第三方已经完成签署、仅归档下载失败时,保留“已完成”状态,方便后续通过同步重试归档。 process.SignStatus = process.CompletedAt.HasValue ? ElectronicSignStatus.Completed : ElectronicSignStatus.Failed; process.LastError = LimitError(exception.Message); process.RetryCount += 1; process.UpdateTime = DateTime.Now; await _db.Updateable(process).ExecuteCommandAsync(); throw; } return await BuildDetail(task, process); } /// public async Task Sync( OnboardingElectronicSignTaskInput input) { return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => SyncCore(input)); } private async Task SyncCore( OnboardingElectronicSignTaskInput input) { var task = await GetAuthorizedTask(input.TaskId); var process = await GetRequiredProcess(task.Id); await SyncProcess(process, "manual_sync"); return await BuildDetail(task, process); } /// public async Task GetSignLink( OnboardingElectronicSignTaskInput input) { return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => GetSignLinkCore(input)); } private async Task GetSignLinkCore( OnboardingElectronicSignTaskInput input) { var task = await GetAuthorizedTask(input.TaskId); var process = await GetRequiredProcess(task.Id); EnsureCanOpenEmployeeSignPage(process); var linkStillValid = !string.IsNullOrWhiteSpace(process.SignUrl) && process.SignUrlExpireAt > DateTime.Now.AddMinutes(1); if (!linkStillValid) { process.SignUrl = await CreateSignPageUrl(process); process.SignUrlExpireAt = DateTime.Now.AddMinutes( NormalizeSignUrlExpireMinutes()); process.LastError = null; process.UpdateTime = DateTime.Now; await _db.Updateable(process).ExecuteCommandAsync(); } return await BuildDetail(task, process); } /// public async Task PreviewEmployeeSignedDocument( OnboardingElectronicSignPreviewInput input) { return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => PreviewEmployeeSignedDocumentCore(input)); } private async Task PreviewEmployeeSignedDocumentCore( OnboardingElectronicSignPreviewInput input) { var task = await GetTask(input.TaskId); var process = await GetRequiredProcess(task.Id); // 不信任前端缓存状态。每次预览前重新查询契约锁,确保员工确实已完成签署。 await SyncProcess(process, "preview_employee_signed"); if (process.SignStatus != ElectronicSignStatus.PendingCompanySeal) throw Oops.Bah("只有员工已签、企业待盖章的合同才能在此处核对"); var document = await _db.Queryable() .FirstAsync(item => item.Id == input.SignDocumentId && item.SignProcessId == process.Id) ?? throw Oops.Bah("电子签文档不存在或不属于当前签约流程"); if (string.IsNullOrWhiteSpace(document.ProviderDocumentId)) throw Oops.Bah("契约锁文档尚未创建完成,不能预览"); var companyName = await GetCompanyLegalName(task); var previewUrl = await _qiyuesuoClient.GetViewPageUrl( new QiyuesuoViewPageInput { ContractId = process.ProviderContractId, DocumentId = document.ProviderDocumentId, CompanyName = companyName }); if (string.IsNullOrWhiteSpace(previewUrl) || !Uri.TryCreate(previewUrl, UriKind.Absolute, out var previewUri) || (previewUri.Scheme != Uri.UriSchemeHttps && previewUri.Scheme != Uri.UriSchemeHttp)) { throw Oops.Bah("契约锁返回的合同浏览地址无效"); } await RecordAdminOperation( process, AdminPreviewEventSource, BuildPreviewEventType(document.Id), JsonSerializer.Serialize(new { signDocumentId = document.Id, providerDocumentId = document.ProviderDocumentId, document.DocumentName, operatorUserId = UserManager.UserId, operatorName = UserManager.Name }), "已生成预览地址"); return new OnboardingElectronicSignPreviewOutput { SignDocumentId = document.Id, PreviewUrl = previewUrl }; } /// public async Task CompanySeal( OnboardingElectronicSignTaskInput input) { return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => CompanySealCore(input)); } private async Task CompanySealCore( OnboardingElectronicSignTaskInput input) { var task = await GetAuthorizedTask(input.TaskId); var process = await GetRequiredProcess(task.Id); // 先查第三方真实状态。重复点击时,如果契约锁已经完成,则直接返回最新详情。 await SyncProcess(process, "company_seal_precheck"); if (process.SignStatus is ElectronicSignStatus.Completed or ElectronicSignStatus.Archived) { return await BuildDetail(task, process); } if (process.SignStatus != ElectronicSignStatus.PendingCompanySeal) throw Oops.Bah("员工尚未完成签署,当前不能执行企业盖章"); var documents = await _db.Queryable() .Where(item => item.SignProcessId == process.Id) .OrderBy(item => item.DocumentSort) .ToListAsync(); if (documents.Count == 0) throw Oops.Bah("当前电子签流程没有可盖章的合同文件"); var previewedDocumentIds = await GetPreviewedDocumentIds(process.Id); var notPreviewed = documents .Where(item => !previewedDocumentIds.Contains(item.Id)) .Select(item => item.DocumentName) .ToList(); if (notPreviewed.Count > 0) { throw Oops.Bah( $"请先逐份查看员工已签文件:{string.Join("、", notPreviewed)}"); } // 发出前持久化占用。进程退出/响应丢失也不能自动重发可能已送达的盖章命令。 var alreadySubmitted = await _db.Queryable() .AnyAsync(item => item.SignProcessId == process.Id && item.EventSource == AdminCompanySealEventSource && (item.EventType == "company_seal_submitted" || item.EventType == "company_seal_dispatch_started")); if (alreadySubmitted) throw Oops.Bah("企业盖章命令已发送或结果待确认,请先刷新签署状态并核实契约锁平台,系统不会重复盖章"); var companyName = await GetCompanyLegalName(task); await RecordAdminOperation( process, AdminCompanySealEventSource, "company_seal_dispatch_started", JsonSerializer.Serialize(new { process.ProviderContractId, companyName, process.SealId, operatorUserId = UserManager.UserId, operatorName = UserManager.Name }), "已占用盖章请求;发送结果待确认,禁止自动重复发送"); try { await _qiyuesuoClient.CompanySign( new QiyuesuoCompanySignInput { ContractId = process.ProviderContractId, CompanyName = companyName, SealId = process.SealId }); } catch (Exception signException) { // POST 响应中断时结果可能不明确,立即反查一次,避免实际已盖章却提示用户反复重试。 try { await SyncProcess(process, "company_seal_verify"); } catch { // SyncProcess 已保存同步或归档错误,下面仍按内存中的真实状态判断是否已经盖章。 } if (process.SignStatus is ElectronicSignStatus.Completed or ElectronicSignStatus.Archived) { return await BuildDetail(task, process); } process.LastError = $"企业盖章结果待核实,已阻止重复发送:{LimitError(signException.Message)}"; process.UpdateTime = DateTime.Now; await _db.Updateable(process).ExecuteCommandAsync(); throw Oops.Bah("企业盖章未确认成功,请刷新状态并核实契约锁平台;为避免重复盖章,不能直接重发。原因:" + LimitError(signException.Message)); } await RecordAdminOperation( process, AdminCompanySealEventSource, "company_seal_submitted", JsonSerializer.Serialize(new { process.ProviderContractId, companyName, process.SealId, operatorUserId = UserManager.UserId, operatorName = UserManager.Name }), "企业盖章请求已提交"); // 盖章接口成功即代表请求已受理;后续查询短暂失败不能误导管理员再次盖章。 try { await SyncProcess(process, "company_seal_sync"); } catch { // LastError 已由 SyncProcess 保存,页面自动刷新或回调会继续完成归档。 } return await BuildDetail(task, process); } /// 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); if (process.SignStatus != ElectronicSignStatus.PendingEmployeeSign) throw Oops.Bah("当前流程不在员工待签状态,无需催签"); await _qiyuesuoClient.Notice(process.ProviderContractId); process.LastError = null; process.UpdateTime = DateTime.Now; await _db.Updateable(process).ExecuteCommandAsync(); } /// 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); if (IsTerminal(process.SignStatus)) throw Oops.Bah("已完成、已归档或已结束的电子签流程不能作废"); if (string.IsNullOrWhiteSpace(process.ProviderContractId)) throw Oops.Bah("契约锁流程尚未创建,不能调用作废接口"); await _qiyuesuoClient.Invalid( process.ProviderContractId, process.SealId, input.Reason.Trim()); process.SignStatus = ElectronicSignStatus.Invalid; process.ProviderStatus = "INVALIDED"; process.SignUrl = null; process.SignUrlExpireAt = null; process.LastError = null; process.UpdateTime = DateTime.Now; await _db.Updateable(process).ExecuteCommandAsync(); } /// 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("契约锁回调内容为空"); using var json = JsonDocument.Parse(payloadJson); var root = json.RootElement; var payloadHash = Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(payloadJson))) .ToLowerInvariant(); var callbackEvent = await _db.Queryable() .FirstAsync(item => item.PayloadHash == payloadHash); if (callbackEvent?.ProcessResult == "处理成功") return; var providerContractId = FindString(root, "contractId"); var bizId = FindString(root, "bizId"); var eventType = FindString( root, "callbackType", "eventType", "type"); var providerStatus = FindString( root, "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 { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), PayloadHash = payloadHash, EventSource = "callback", ReceivedAt = DateTime.Now, CreateTime = DateTime.Now, IsDelete = false }; try { await _db.Insertable(callbackEvent).ExecuteCommandAsync(); } catch { // 两次相同回调并发到达时,由唯一索引保证只保留一条事件记录。 callbackEvent = await _db.Queryable() .FirstAsync(item => item.PayloadHash == payloadHash); if (callbackEvent == null) throw; if (callbackEvent.ProcessResult == "处理成功") return; } } // 失败、未匹配的历史回调允许第三方用相同报文再次触发处理。 callbackEvent.SignProcessId = process?.Id; callbackEvent.EventType = eventType; callbackEvent.ProviderStatus = providerStatus; callbackEvent.PayloadJson = payloadJson; callbackEvent.ProcessResult = process == null ? "未匹配到本地电子签流程" : "处理中"; callbackEvent.ProcessedAt = null; await _db.Updateable(callbackEvent).ExecuteCommandAsync(); if (process == null) { callbackEvent.ProcessedAt = DateTime.Now; await _db.Updateable(callbackEvent).ExecuteCommandAsync(); return; } try { process.LastCallbackAt = DateTime.Now; await _db.Updateable(process).UpdateColumns(item => item.LastCallbackAt).ExecuteCommandAsync(); // 明文回调只作为刷新触发器,最终状态重新向契约锁查询,伪造回调不能改变业务状态。 await SyncProcess(process, "callback"); callbackEvent.ProviderStatus = process.ProviderStatus; callbackEvent.ProcessResult = "处理成功"; callbackEvent.ProcessedAt = DateTime.Now; await _db.Updateable(callbackEvent).ExecuteCommandAsync(); } catch (Exception exception) { callbackEvent.ProcessResult = LimitError(exception.Message, 480); callbackEvent.ProcessedAt = DateTime.Now; await _db.Updateable(callbackEvent).ExecuteCommandAsync(); throw; } } /// /// 创建任务的第一条本地电子签流程。 /// 当前流程唯一索引负责拦截并发双击,BizId 使用流程 Id 保证不会碰撞第三方历史合同。 /// private async Task<(ObElectronicSignProcess Process, bool Created)> CreateProcess( ObOnboardingTask task, ObEmployee employee, string categoryId, string sealId) { var process = BuildProcess(task, employee, categoryId, sealId); try { await _db.Insertable(process).ExecuteCommandAsync(); return (process, true); } catch { // 并发请求可能已经插入当前流程;读取胜出的记录,确保不会创建第二个收费流程。 var existing = await GetLatestProcess(task.Id); if (existing != null) return (existing, false); throw; } } /// /// 将已经结束的当前流程转为历史记录,并在同一事务中创建新的当前流程。 /// 数据库唯一索引保证多实例或连续点击时每个任务最多只有一条当前流程。 /// private async Task CreateReplacementProcess( ObOnboardingTask task, ObEmployee employee, string categoryId, string sealId, ObElectronicSignProcess previousProcess, string reason) { var process = BuildProcess(task, employee, categoryId, sealId); var now = DateTime.Now; await _db.Ado.BeginTranAsync(); try { var replacedRows = await _db.Updateable() .SetColumns(item => new ObElectronicSignProcess { IsCurrent = null, ReissueReason = LimitError(reason, 480), ReissuedAt = now, ReissuedByProcessId = process.Id, UpdateTime = now, UpdateUserId = UserManager.UserId, UpdateUser = UserManager.Name }) .Where(item => item.Id == previousProcess.Id && item.IsCurrent == true && item.SignStatus == previousProcess.SignStatus) .ExecuteCommandAsync(); if (replacedRows != 1) throw Oops.Bah("电子签流程状态已经变化,请刷新后再重新发起"); await _db.Insertable(process).ExecuteCommandAsync(); await _db.Ado.CommitTranAsync(); return process; } catch { await _db.Ado.RollbackTranAsync(); throw; } } /// /// 创建尚未发送到契约锁的当前流程快照。 /// private ObElectronicSignProcess BuildProcess( ObOnboardingTask task, ObEmployee employee, string categoryId, string sealId) { var now = DateTime.Now; var processId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); return new ObElectronicSignProcess { Id = processId, TaskId = task.Id, EmployeeId = employee.Id, Provider = ElectronicSignProvider.Qiyuesuo, BizId = $"{task.TaskNo}-SIGN-{processId}", IsCurrent = true, CategoryId = categoryId, SealId = sealId, Subject = $"{employee.EmployeeName}-{task.TaskNo}-入职合同", EmployeeName = employee.EmployeeName.Trim(), EmployeeMobile = employee.Phone.Trim(), EmployeeIdCardNumber = employee.IdCardNumber?.Trim(), CreatorName = _setting.CreatorName.Trim(), CreatorMobile = _setting.CreatorMobile.Trim(), SignStatus = ElectronicSignStatus.Creating, StartedAt = now, Status = CommonStatusConst.ENABLE, CreateTime = now, CreateUserId = UserManager.UserId, CreateUser = UserManager.Name, IsDelete = false }; } /// /// 从本地断点继续创建草稿、上传全部合同并发起一次多文档签约流程。 /// private async Task StartOrResumeProcess( ObOnboardingTask task, string companyName, List taskContracts, ObElectronicSignProcess process) { foreach (var contract in taskContracts) { contract.GeneratedDocxFileId ??= await _contractDocumentService.Generate( new OnboardingGenerateContractInput { TaskContractId = contract.Id }); } QiyuesuoContractResult providerContract; if (string.IsNullOrWhiteSpace(process.ProviderContractId)) { providerContract = await _qiyuesuoClient.CreateDraft( new QiyuesuoCreateDraftInput { BizId = process.BizId, Subject = process.Subject, CompanyName = companyName, EmployeeName = process.EmployeeName, EmployeeMobile = process.EmployeeMobile, CategoryId = process.CategoryId, SealId = process.SealId, CreatorName = process.CreatorName, CreatorMobile = process.CreatorMobile, CallbackUrl = _setting.CallbackUrl, RequiresCompanySign = taskContracts.Any(item => QiyuesuoContractRequestBuilder.RequiresCompanySign( item.CompanySealEnabled, item.AcrossPageSealEnabled, _setting)) }); process.ProviderContractId = GetContractId(providerContract); process.ProviderStatus = providerContract.Status ?? "DRAFT"; // ProviderStatus 可为 DRAFT,但本地仍保持“正在创建”,避免并发点击重复上传或发送。 process.SignStatus = ElectronicSignStatus.Creating; process.UpdateTime = DateTime.Now; await _db.Updateable(process).ExecuteCommandAsync(); } else { providerContract = await _qiyuesuoClient.GetContractDetail( process.ProviderContractId); var providerState = ResolveSignStatus(providerContract); if (providerState != ElectronicSignStatus.Draft) { await SyncProcess(process, "resume"); return; } } var documents = await _db.Queryable() .Where(item => item.SignProcessId == process.Id) .ToListAsync(); for (var index = 0; index < taskContracts.Count; index++) { var taskContract = taskContracts[index]; if (documents.Any(item => item.TaskContractId == taskContract.Id)) continue; var originalFileId = taskContract.GeneratedDocxFileId.Value; var fileBytes = await ReadFile(originalFileId); var documentName = string.IsNullOrWhiteSpace(taskContract.ContractName) ? $"合同{index + 1}" : taskContract.ContractName.Trim(); var uploaded = await _qiyuesuoClient.AddDocument( new QiyuesuoAddDocumentInput { ContractId = process.ProviderContractId, BizId = process.BizId, Title = documentName, DocumentSort = index + 1, FileName = $"{SafeFileName(documentName)}.docx", FileBytes = fileBytes }); var document = new ObElectronicSignDocument { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), SignProcessId = process.Id, TaskContractId = taskContract.Id, ProviderDocumentId = GetDocumentId(uploaded), DocumentName = documentName, DocumentSort = index + 1, OriginalDocxFileId = originalFileId, EmployeeSignKeyword = _setting.EmployeeSignKeyword.Trim(), CompanySealKeyword = _setting.CompanySealKeyword?.Trim(), CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(taskContract.CompanySealEnabled), AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(taskContract.AcrossPageSealEnabled, _setting), Status = CommonStatusConst.ENABLE, CreateTime = DateTime.Now, CreateUserId = process.CreateUserId, CreateUser = process.CreateUser, IsDelete = false }; await _db.Insertable(document).ExecuteCommandAsync(); documents.Add(document); } await SendProcess(process, companyName, documents); await _db.Updateable() .SetColumns(item => item.ContractStatus == TaskContractStatus.ElectronicSigning) .Where(item => item.TaskId == task.Id) .ExecuteCommandAsync(); } /// /// 全部文档配置员工签名,按每份文件的快照配置企业盖章及骑缝章位置。 /// private async Task SendProcess( ObElectronicSignProcess process, string companyName, List documents) { var providerContract = await _qiyuesuoClient.GetContractDetail( process.ProviderContractId); var personalSignatory = providerContract.Signatories.FirstOrDefault(item => string.Equals(item.TenantType, "PERSONAL", StringComparison.OrdinalIgnoreCase)) ?? throw Oops.Bah("契约锁草稿未返回员工签署方"); var orderedDocuments = documents.OrderBy(item => item.DocumentSort).ToList(); var requiresCompanySign = orderedDocuments.Any(item => QiyuesuoContractRequestBuilder.RequiresCompanySign( item.CompanySealEnabled, item.AcrossPageSealEnabled, _setting)); var companyAction = providerContract.Signatories .Where(item => string.Equals(item.TenantType, "COMPANY", StringComparison.OrdinalIgnoreCase)) .SelectMany(item => item.Actions) .FirstOrDefault(item => string.Equals(item.Type, "COMPANY", StringComparison.OrdinalIgnoreCase)); if (requiresCompanySign && companyAction == null) throw Oops.Bah("契约锁草稿未返回企业盖章动作"); if (!requiresCompanySign && companyAction != null) throw Oops.Bah("本次材料仅需员工签字,但契约锁业务分类仍预设企业盖章节点,请先调整该业务分类的签署流程"); var personalSignatoryId = GetSignatoryId(personalSignatory); var companyActionId = companyAction == null ? null : GetActionId(companyAction); var signingDocuments = orderedDocuments.Select(item => new OnboardingSigningDocument( item.ProviderDocumentId, item.EmployeeSignKeyword, item.CompanySealKeyword, item.CompanySealEnabled, item.AcrossPageSealEnabled)).ToList(); var stampers = QiyuesuoContractRequestBuilder.BuildStampers( signingDocuments, personalSignatoryId, companyActionId, process.SealId, _setting); foreach (var document in orderedDocuments) { document.EmployeeActionId = personalSignatoryId; document.CompanyActionId = QiyuesuoContractRequestBuilder.RequiresCompanySign( document.CompanySealEnabled, document.AcrossPageSealEnabled, _setting) ? companyActionId : null; document.UpdateTime = DateTime.Now; await _db.Updateable(document).ExecuteCommandAsync(); } var sent = await _qiyuesuoClient.Send(new QiyuesuoSendContractInput { ContractId = process.ProviderContractId, BizId = process.BizId, CompanyName = companyName, Stampers = stampers }); process.ProviderStatus = sent.Status ?? "SIGNING"; process.SignStatus = ElectronicSignStatus.PendingEmployeeSign; process.SentAt ??= DateTime.Now; process.LastSyncedAt = DateTime.Now; process.LastError = null; process.UpdateTime = DateTime.Now; await _db.Updateable(process).ExecuteCommandAsync(); // 签署页获取失败不回滚已收费流程,管理员可通过“刷新签署链接”安全重试。 try { process.SignUrl = await CreateSignPageUrl(process); process.SignUrlExpireAt = DateTime.Now.AddMinutes( NormalizeSignUrlExpireMinutes()); await _db.Updateable(process).ExecuteCommandAsync(); } catch (Exception exception) { process.LastError = $"签约已发起,但签署链接获取失败:{LimitError(exception.Message)}"; await _db.Updateable(process).ExecuteCommandAsync(); } } /// /// 主动查询第三方详情并更新本地状态。 /// private async Task SyncProcess( ObElectronicSignProcess process, string eventSource) { if (string.IsNullOrWhiteSpace(process.ProviderContractId)) throw Oops.Bah("契约锁流程尚未创建完成,可点击重新发起继续处理"); try { var providerContract = await _qiyuesuoClient.GetContractDetail( process.ProviderContractId); await ApplyProviderDetail(process, providerContract); process.LastSyncedAt = DateTime.Now; process.LastError = null; process.UpdateTime = DateTime.Now; await _db.Updateable(process).ExecuteCommandAsync(); if (process.SignStatus == ElectronicSignStatus.Completed) await ArchiveCompletedProcess(process); } catch (Exception exception) { process.LastError = $"{eventSource}:{LimitError(exception.Message)}"; process.LastSyncedAt = DateTime.Now; process.UpdateTime = DateTime.Now; await _db.Updateable(process).ExecuteCommandAsync(); throw; } } /// /// 将契约锁状态转换为本系统可展示的业务状态。 /// private async Task ApplyProviderDetail( ObElectronicSignProcess process, QiyuesuoContractResult providerContract) { var localStatus = OnboardingSignStateGuard.Resolve(process.SignStatus, ResolveSignStatus(providerContract)); process.ProviderStatus = providerContract.Status; process.SignStatus = localStatus; if (localStatus == ElectronicSignStatus.Completed || localStatus == ElectronicSignStatus.Archived) { process.CompletedAt ??= DateTime.Now; process.SignUrl = null; process.SignUrlExpireAt = null; await _db.Updateable() .SetColumns(item => item.ContractStatus == TaskContractStatus.ElectronicSigned) .Where(item => item.TaskId == process.TaskId && process.IsCurrent == true && item.ContractStatus != TaskContractStatus.Archived) .ExecuteCommandAsync(); } else if (IsTerminal(localStatus)) { process.SignUrl = null; process.SignUrlExpireAt = null; } } /// /// 下载每份签署 PDF 和包含签署日志的 ZIP,并保存到业务 OSS。 /// private async Task ArchiveCompletedProcess(ObElectronicSignProcess process) { var documents = await _db.Queryable() .Where(item => item.SignProcessId == process.Id) .OrderBy(item => item.DocumentSort) .ToListAsync(); if (documents.Count == 0) throw Oops.Bah("电子签流程没有文档映射,无法归档"); foreach (var document in documents) { if (!document.SignedPdfFileId.HasValue) { var signedPdf = await _qiyuesuoClient.DownloadDocument(document.ProviderDocumentId); document.SignedPdfFileId = await _fileService.StoreGeneratedFile( SysDictConst.FILE_ENGINE_ALIYUN_OSS, $"{SafeFileName(process.BizId)}_{document.DocumentSort:D2}_{SafeFileName(document.DocumentName)}_已签署.pdf", signedPdf); document.UpdateTime = DateTime.Now; await _db.Updateable(document).ExecuteCommandAsync(); } // 中断重试时仍回填合同指针,不能因文档已落库而跳过未完成的关联更新。 var pdfFileId = document.SignedPdfFileId.Value; await _db.Updateable() .SetColumns(item => new ObTaskContract { GeneratedPdfFileId = pdfFileId, ContractStatus = TaskContractStatus.Archived }) .Where(item => item.Id == document.TaskContractId && process.IsCurrent == true) .ExecuteCommandAsync(); } if (!process.ArchiveFileId.HasValue) { var archive = await _qiyuesuoClient.DownloadArchive( process.ProviderContractId); process.ArchiveFileId = await _fileService.StoreGeneratedFile( SysDictConst.FILE_ENGINE_ALIYUN_OSS, $"{SafeFileName(process.BizId)}_电子签完整归档.zip", archive); } process.SignStatus = ElectronicSignStatus.Archived; process.ArchivedAt ??= DateTime.Now; process.LastError = null; process.UpdateTime = DateTime.Now; await _db.Updateable(process).ExecuteCommandAsync(); } /// /// 组装管理端需要的当前流程、合同文件和历史签约记录。 /// private async Task BuildDetail( ObOnboardingTask task, ObElectronicSignProcess process = null) { process ??= await GetLatestProcess(task.Id); var historyProcesses = await _db.Queryable() .Where(item => item.TaskId == task.Id && item.IsCurrent == null) .OrderBy(item => item.StartedAt, OrderByType.Desc) .ToListAsync(); var taskAllowed = task.EmployeeId.HasValue && (task.TaskStatus == OnboardingTaskStatus.Approved || task.TaskStatus == OnboardingTaskStatus.Archived); var creatingAt = process?.UpdateTime ?? process?.StartedAt; var processAllowed = process == null || process.SignStatus == ElectronicSignStatus.Failed || process.SignStatus == ElectronicSignStatus.Draft || (process.SignStatus == ElectronicSignStatus.Creating && creatingAt <= DateTime.Now.AddMinutes(-5)); var reinitiateAllowed = process != null && CanCreateReplacementProcess(process); var result = new OnboardingElectronicSignDetailOutput { TaskId = task.Id, TaskNo = task.TaskNo, CanInitiate = taskAllowed && processAllowed, CannotInitiateReason = ResolveCannotInitiateReason( task, taskAllowed, processAllowed), CanReinitiate = taskAllowed && reinitiateAllowed, CannotReinitiateReason = ResolveCannotReinitiateReason( task, process, taskAllowed, reinitiateAllowed), HasProcess = process != null, HistoryProcesses = historyProcesses.Select(item => new OnboardingElectronicSignHistoryOutput { ProcessId = item.Id, BizId = item.BizId, ProviderContractId = item.ProviderContractId, SignStatus = item.SignStatus, ProviderStatus = item.ProviderStatus, EmployeeName = item.EmployeeName, EmployeeMobile = item.EmployeeMobile, StartedAt = item.StartedAt, ReissuedAt = item.ReissuedAt, ReissueReason = item.ReissueReason, LastError = item.LastError }).ToList() }; if (process == null) { var pendingContracts = await _db.Queryable() .Where(item => item.TaskId == task.Id) .OrderBy(item => item.Id) .ToListAsync(); result.Documents = pendingContracts.Select((item, index) => new OnboardingElectronicSignDocumentOutput { TaskContractId = item.Id, DocumentName = item.ContractName, DocumentSort = index + 1, OriginalDocxFileId = item.GeneratedDocxFileId ?? 0, SignedPdfFileId = item.GeneratedPdfFileId, CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(item.CompanySealEnabled), AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(item.AcrossPageSealEnabled, _setting) }).ToList(); return result; } var documents = await _db.Queryable() .Where(item => item.SignProcessId == process.Id) .OrderBy(item => item.DocumentSort) .ToListAsync(); var previewedDocumentIds = await GetPreviewedDocumentIds(process.Id); result.ProcessId = process.Id; result.ProviderContractId = process.ProviderContractId; result.BizId = process.BizId; result.Subject = process.Subject; result.SignStatus = process.SignStatus; result.ProviderStatus = process.ProviderStatus; result.EmployeeName = process.EmployeeName; result.EmployeeMobile = process.EmployeeMobile; result.SignUrl = process.SignUrl; result.SignUrlExpireAt = process.SignUrlExpireAt; result.SignUrlValid = !string.IsNullOrWhiteSpace(process.SignUrl) && process.SignUrlExpireAt > DateTime.Now.AddMinutes(1); result.StartedAt = process.StartedAt; result.SentAt = process.SentAt; result.CompletedAt = process.CompletedAt; result.ArchivedAt = process.ArchivedAt; result.ArchiveFileId = process.ArchiveFileId; result.LastError = process.LastError; if (documents.Count > 0) { result.Documents = documents.Select(item => new OnboardingElectronicSignDocumentOutput { Id = item.Id, TaskContractId = item.TaskContractId, ProviderDocumentId = item.ProviderDocumentId, DocumentName = item.DocumentName, DocumentSort = item.DocumentSort, OriginalDocxFileId = item.OriginalDocxFileId, SignedPdfFileId = item.SignedPdfFileId, CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(item.CompanySealEnabled), AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(item.AcrossPageSealEnabled, _setting), EmployeeSignedPreviewed = previewedDocumentIds.Contains(item.Id) }).ToList(); } else { // 草稿创建前失败时尚无电子签文档记录,仍回显任务合同,允许管理员从断点继续重试。 var pendingContracts = await _db.Queryable() .Where(item => item.TaskId == task.Id) .OrderBy(item => item.Id) .ToListAsync(); result.Documents = pendingContracts.Select((item, index) => new OnboardingElectronicSignDocumentOutput { TaskContractId = item.Id, DocumentName = item.ContractName, DocumentSort = index + 1, OriginalDocxFileId = item.GeneratedDocxFileId ?? 0, SignedPdfFileId = item.GeneratedPdfFileId, CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(item.CompanySealEnabled), AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(item.AcrossPageSealEnabled, _setting) }).ToList(); } return result; } /// /// 读取任务当前所属公司的法定名称。集团共用令牌时,该名称用于明确子公司主体。 /// private async Task GetCompanyLegalName(ObOnboardingTask task) { var company = await _db.Queryable() .InSingleAsync(task.CompanyId) ?? throw Oops.Bah("任务所属公司不存在"); var companyName = string.IsNullOrWhiteSpace(company.LegalName) ? company.CompanyName : company.LegalName; if (string.IsNullOrWhiteSpace(companyName)) throw Oops.Bah("任务所属公司法定名称为空"); return companyName.Trim(); } /// /// 返回当前流程中已由管理员生成过员工签后预览的文档 Id。 /// private async Task> GetPreviewedDocumentIds(long processId) { var eventTypes = await _db.Queryable() .Where(item => item.SignProcessId == processId && item.EventSource == AdminPreviewEventSource && item.ProcessResult == "已生成预览地址") .Select(item => item.EventType) .ToListAsync(); var result = new HashSet(); foreach (var eventType in eventTypes) { if (!string.IsNullOrWhiteSpace(eventType) && eventType.StartsWith( PreviewEventTypePrefix, StringComparison.Ordinal) && long.TryParse( eventType[PreviewEventTypePrefix.Length..], out var documentId)) { result.Add(documentId); } } return result; } /// /// 将管理员预览、确认盖章等关键操作写入现有电子签事件表。 /// 固定操作键生成固定哈希,可防止双击或重试留下重复审计记录。 /// private async Task RecordAdminOperation( ObElectronicSignProcess process, string eventSource, string eventType, string payloadJson, string processResult) { var eventKey = $"{eventSource}:{process.Id}:{eventType}"; var payloadHash = Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(eventKey))) .ToLowerInvariant(); var existing = await _db.Queryable() .FirstAsync(item => item.PayloadHash == payloadHash); if (existing != null) return; var now = DateTime.Now; var operationEvent = new ObElectronicSignEvent { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), SignProcessId = process.Id, PayloadHash = payloadHash, EventSource = eventSource, EventType = eventType, ProviderStatus = process.ProviderStatus, PayloadJson = payloadJson, ProcessResult = processResult, ReceivedAt = now, ProcessedAt = now, CreateTime = now, CreateUserId = UserManager.UserId, CreateUser = UserManager.Name, IsDelete = false }; try { await _db.Insertable(operationEvent).ExecuteCommandAsync(); } catch { // 并发重复操作由 PayloadHash 唯一索引兜底;其他插入错误继续抛出。 existing = await _db.Queryable() .FirstAsync(item => item.PayloadHash == payloadHash); if (existing == null) throw; } } private static string BuildPreviewEventType(long documentId) { return $"{PreviewEventTypePrefix}{documentId}"; } /// /// 电子签操作遵循任务创建人/任务管理全部权限。 /// private async Task GetAuthorizedTask(long taskId) { var task = await GetTask(taskId); await _dataPermission.EnsureCanManage( task.CreateUserId, OnboardingPermissionCodes.TaskManageAll, "入职任务"); return task; } private async Task GetTask(long taskId) { return await _db.Queryable().InSingleAsync(taskId) ?? throw Oops.Bah("入职任务不存在"); } private async Task GetLatestProcess(long taskId) { return await _db.Queryable() .Where(item => item.TaskId == taskId && item.IsCurrent == true) .OrderBy(item => item.StartedAt, OrderByType.Desc) .FirstAsync(); } private async Task GetRequiredProcess(long taskId) { return await GetLatestProcess(taskId) ?? throw Oops.Bah("该入职任务尚未发起电子签"); } private async Task FindProcess( string providerContractId, string bizId) { if (!string.IsNullOrWhiteSpace(providerContractId)) { var byContract = await _db.Queryable() .FirstAsync(item => item.ProviderContractId == providerContractId); if (byContract != null) return byContract; } if (string.IsNullOrWhiteSpace(bizId)) return null; return await _db.Queryable() .FirstAsync(item => item.BizId == bizId); } private async Task ReadFile(long fileId) { var file = await _fileService.Download(new BaseIdInput { Id = fileId }) ?? throw Oops.Bah($"系统文件 {fileId} 不存在"); await using var stream = file.FileStream; using var memory = new MemoryStream(); await stream.CopyToAsync(memory); return memory.ToArray(); } private Task CreateSignPageUrl(ObElectronicSignProcess process) { return _qiyuesuoClient.GetSignPageUrl(new QiyuesuoSignPageInput { ContractId = process.ProviderContractId, EmployeeMobile = process.EmployeeMobile, CallbackPage = _setting.SignPageCallbackUrl }); } private static string ResolveSignStatus( QiyuesuoContractResult providerContract) { var status = (providerContract.Status ?? string.Empty) .Trim() .ToUpperInvariant(); if (status is "COMPLETE" or "COMPLETED" or "FINISHED") return ElectronicSignStatus.Completed; if (status is "REJECTED" or "REFUSED") return ElectronicSignStatus.Rejected; if (status is "INVALID" or "INVALIDING" or "INVALIDED" or "RECALLED") return ElectronicSignStatus.Invalid; if (status == "EXPIRED") return ElectronicSignStatus.Expired; if (status == "DRAFT") return ElectronicSignStatus.Draft; return QiyuesuoContractRequestBuilder.ResolvePendingStatus(providerContract); } private static bool IsTerminal(string status) { return status == ElectronicSignStatus.Completed || status == ElectronicSignStatus.Archived || status == ElectronicSignStatus.Rejected || status == ElectronicSignStatus.Invalid || status == ElectronicSignStatus.Expired; } private static string GetContractId(QiyuesuoContractResult result) { var id = result?.Id ?? result?.ContractId; return !string.IsNullOrWhiteSpace(id) ? id : throw Oops.Bah("契约锁创建草稿后未返回合同流程 Id"); } private static string GetDocumentId(QiyuesuoDocumentResult result) { var id = result?.Id ?? result?.DocumentId; return !string.IsNullOrWhiteSpace(id) ? id : throw Oops.Bah("契约锁上传文档后未返回文档 Id"); } private static string GetSignatoryId(QiyuesuoSignatoryResult result) { var id = result?.Id ?? result?.SignatoryId; return !string.IsNullOrWhiteSpace(id) ? id : throw Oops.Bah("契约锁未返回员工签署方 Id"); } private static string GetActionId(QiyuesuoActionResult result) { var id = result?.Id ?? result?.ActionId; return !string.IsNullOrWhiteSpace(id) ? id : throw Oops.Bah("契约锁未返回企业盖章动作 Id"); } /// /// 校验正式员工主档与审核通过的入职登记资料是否属于同一实名人员。 /// 原始登记资料用于审计追溯,不允许在电子签发起时静默覆盖。 /// private static void ValidateSignerIdentity( ObEmployee employee, ObTaskEmployeeInfo employeeInfo) { var differentFields = new List(); if (!string.Equals( NormalizeIdentityValue(employee.EmployeeName), NormalizeIdentityValue(employeeInfo.EmployeeName), StringComparison.Ordinal)) { differentFields.Add("姓名"); } if (!string.Equals( NormalizeIdentityValue(employee.Phone), NormalizeIdentityValue(employeeInfo.Phone), StringComparison.Ordinal)) { differentFields.Add("手机号"); } if (!string.Equals( NormalizeIdentityValue(employee.IdCardNumber).ToUpperInvariant(), NormalizeIdentityValue(employeeInfo.IdCardNumber).ToUpperInvariant(), StringComparison.Ordinal)) { differentFields.Add("身份证号"); } if (differentFields.Count > 0) throw Oops.Bah($"员工档案的{string.Join("、", differentFields)}与审核通过的入职登记资料不一致,请先核对并修正后再发起电子签"); } private static string NormalizeIdentityValue(string value) { return value?.Trim() ?? string.Empty; } /// /// 判断当前流程能否在保留历史记录的前提下重新创建。 /// private static bool CanCreateReplacementProcess( ObElectronicSignProcess process) { if (process.SignStatus == ElectronicSignStatus.Invalid || process.SignStatus == ElectronicSignStatus.Rejected || process.SignStatus == ElectronicSignStatus.Expired) { return true; } return process.SignStatus == ElectronicSignStatus.Failed && string.IsNullOrWhiteSpace(process.ProviderContractId); } /// /// 拦截可能造成重复收费或破坏已完成档案的重新发起操作。 /// private static void ValidateProcessCanReinitiate( ObElectronicSignProcess process) { if (CanCreateReplacementProcess(process)) return; if (process.SignStatus == ElectronicSignStatus.Completed || process.SignStatus == ElectronicSignStatus.Archived) { throw Oops.Bah("已经签署完成的电子签流程不能重新发起"); } if (process.SignStatus == ElectronicSignStatus.Failed && !string.IsNullOrWhiteSpace(process.ProviderContractId)) { throw Oops.Bah("当前失败流程已经在契约锁创建,请先重试或同步状态;仍需重开时请先作废原流程"); } throw Oops.Bah("当前电子签流程仍在进行中,请先作废原流程后再重新发起"); } private static void ValidateTaskCanSign(ObOnboardingTask task) { if (!task.EmployeeId.HasValue) throw Oops.Bah("入职任务尚未生成员工档案,不能发起电子签"); if (task.TaskStatus != OnboardingTaskStatus.Approved && task.TaskStatus != OnboardingTaskStatus.Archived) { throw Oops.Bah("仅审核通过或已归档的入职任务可以发起电子签"); } } private void ValidateSettings() { if (!_setting.Enabled) throw Oops.Bah("契约锁电子签功能尚未启用"); if (string.IsNullOrWhiteSpace(_setting.AppToken)) throw Oops.Bah("请先配置契约锁 AppToken"); if (string.IsNullOrWhiteSpace(_setting.AppSecret)) throw Oops.Bah("请先配置契约锁 AppSecret"); if (string.IsNullOrWhiteSpace(_setting.CreatorName) || string.IsNullOrWhiteSpace(_setting.CreatorMobile)) { throw Oops.Bah("请配置契约锁企业成员姓名和手机号"); } if (string.IsNullOrWhiteSpace(_setting.EmployeeSignKeyword)) { throw Oops.Bah("请配置员工签名定位关键字"); } if (_setting.CompanySealOffsetX <= -1m || _setting.CompanySealOffsetX >= 1m || _setting.CompanySealOffsetY <= -1m || _setting.CompanySealOffsetY >= 1m) { throw Oops.Bah("企业盖章 X/Y 偏移量必须在 -1 与 1 之间"); } if (_setting.EnableAcrossPageSeal && !string.Equals(_setting.AcrossPageSealType, "ALL_DOCUMENT", StringComparison.OrdinalIgnoreCase) && !string.Equals(_setting.AcrossPageSealType, "SINGLE_DOCUMENT", StringComparison.OrdinalIgnoreCase)) { throw Oops.Bah("契约锁骑缝章模式只能配置为 ALL_DOCUMENT 或 SINGLE_DOCUMENT"); } } private static void EnsureCanOpenEmployeeSignPage( ObElectronicSignProcess process) { if (process.SignStatus != ElectronicSignStatus.PendingEmployeeSign) throw Oops.Bah("当前流程不在员工待签状态,不能获取员工签署链接"); } private int NormalizeSignUrlExpireMinutes() { // 契约锁签署页面地址最长有效 10 分钟,本地不能继续返回已经失效的缓存链接。 return Math.Clamp(_setting.SignUrlExpireMinutes, 5, 10); } private static string ResolveCannotInitiateReason( ObOnboardingTask task, bool taskAllowed, bool processAllowed) { if (!task.EmployeeId.HasValue) return "审核通过并生成员工档案后才可发起电子签"; if (!taskAllowed) return "当前入职任务状态不能发起电子签"; if (!processAllowed) return "该任务已经存在电子签流程"; return null; } private static string ResolveCannotReinitiateReason( ObOnboardingTask task, ObElectronicSignProcess process, bool taskAllowed, bool reinitiateAllowed) { if (!task.EmployeeId.HasValue) return "审核通过并生成员工档案后才可重新发起电子签"; if (!taskAllowed) return "当前入职任务状态不能重新发起电子签"; if (process == null) return "该任务尚未发起电子签"; if (reinitiateAllowed) return null; if (process.SignStatus == ElectronicSignStatus.Completed || process.SignStatus == ElectronicSignStatus.Archived) { return "已经签署完成的流程不能重新发起"; } if (process.SignStatus == ElectronicSignStatus.Failed && !string.IsNullOrWhiteSpace(process.ProviderContractId)) { return "当前失败流程已在契约锁创建,请先重试或同步状态"; } return "当前流程仍在进行中,请先作废后再重新发起"; } /// /// 在未知回调层级中递归读取常见字段,兼容契约锁不同事件的数据包装。 /// private static string FindString( JsonElement element, params string[] propertyNames) { if (element.ValueKind == JsonValueKind.Object) { foreach (var property in element.EnumerateObject()) { if (propertyNames.Any(name => string.Equals( name, property.Name, StringComparison.OrdinalIgnoreCase)) && property.Value.ValueKind != JsonValueKind.Null) { return property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : property.Value.ToString(); } } foreach (var property in element.EnumerateObject()) { var nested = FindString(property.Value, propertyNames); if (!string.IsNullOrWhiteSpace(nested)) return nested; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (var item in element.EnumerateArray()) { var nested = FindString(item, propertyNames); if (!string.IsNullOrWhiteSpace(nested)) return nested; } } return null; } private static string SafeFileName(string value) { var source = string.IsNullOrWhiteSpace(value) ? "合同" : value.Trim(); var invalidChars = Path.GetInvalidFileNameChars(); return new string(source .Select(character => invalidChars.Contains(character) ? '_' : character) .ToArray()); } private static string LimitError(string message, int maxLength = 1800) { var normalized = string.IsNullOrWhiteSpace(message) ? "未知错误" : message.Trim(); return normalized.Length <= maxLength ? normalized : normalized[..maxLength]; } }