完善第三方签署

This commit is contained in:
fuenmao
2026-09-08 17:48:30 +08:00
parent da777327f5
commit c015bc356c
22 changed files with 961 additions and 62 deletions
@@ -5,10 +5,14 @@ using System.Text.Json;
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职合同“员工先签、企业自动盖章”的契约锁电子签流程。
/// 实现入职合同“员工先签、后台核对、企业人工盖章”的契约锁电子签流程。
/// </summary>
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;
@@ -68,11 +72,22 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
var context = await GetInitiationContext(task);
if (process == null)
{
var created = await CreateProcess(task, context.Employee);
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,
@@ -97,6 +112,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
var process = await CreateReplacementProcess(
task,
context.Employee,
context.CategoryId,
context.SealId,
previousProcess,
input.Reason.Trim());
@@ -114,6 +131,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
private async Task<(
ObEmployee Employee,
string CompanyName,
string CategoryId,
string SealId,
List<ObTaskContract> TaskContracts)> GetInitiationContext(
ObOnboardingTask task)
{
@@ -140,6 +159,15 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
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 (!long.TryParse(categoryId, out _) || !long.TryParse(sealId, out _))
throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或印章 Id 格式不正确");
var taskContracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id)
.OrderBy(item => item.Id)
@@ -152,7 +180,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
if (unconfirmed != null)
throw Oops.Bah($"合同“{unconfirmed.ContractName}”尚未由员工确认");
return (employee, companyName, taskContracts);
return (employee, companyName, categoryId, sealId, taskContracts);
}
/// <summary>
@@ -258,6 +286,159 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
return await BuildDetail(task, process);
}
/// <inheritdoc />
public async Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
OnboardingElectronicSignPreviewInput input)
{
var task = await GetAuthorizedTask(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<ObElectronicSignDocument>()
.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
};
}
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> CompanySeal(
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<ObElectronicSignDocument>()
.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 companyName = await GetCompanyLegalName(task);
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;
}
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);
}
/// <inheritdoc />
public async Task Notice(OnboardingElectronicSignTaskInput input)
{
@@ -284,6 +465,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
await _qiyuesuoClient.Invalid(
process.ProviderContractId,
process.SealId,
input.Reason.Trim());
process.SignStatus = ElectronicSignStatus.Invalid;
process.ProviderStatus = "INVALIDED";
@@ -390,9 +572,11 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// </summary>
private async Task<(ObElectronicSignProcess Process, bool Created)> CreateProcess(
ObOnboardingTask task,
ObEmployee employee)
ObEmployee employee,
string categoryId,
string sealId)
{
var process = BuildProcess(task, employee);
var process = BuildProcess(task, employee, categoryId, sealId);
try
{
await _db.Insertable(process).ExecuteCommandAsync();
@@ -415,10 +599,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
private async Task<ObElectronicSignProcess> CreateReplacementProcess(
ObOnboardingTask task,
ObEmployee employee,
string categoryId,
string sealId,
ObElectronicSignProcess previousProcess,
string reason)
{
var process = BuildProcess(task, employee);
var process = BuildProcess(task, employee, categoryId, sealId);
var now = DateTime.Now;
await _db.Ado.BeginTranAsync();
@@ -458,7 +644,9 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// </summary>
private ObElectronicSignProcess BuildProcess(
ObOnboardingTask task,
ObEmployee employee)
ObEmployee employee,
string categoryId,
string sealId)
{
var now = DateTime.Now;
var processId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
@@ -470,8 +658,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
Provider = ElectronicSignProvider.Qiyuesuo,
BizId = $"{task.TaskNo}-SIGN-{processId}",
IsCurrent = true,
CategoryId = _setting.CategoryId.Trim(),
SealId = _setting.SealId.Trim(),
CategoryId = categoryId,
SealId = sealId,
Subject = $"{employee.EmployeeName}-{task.TaskNo}-入职合同",
EmployeeName = employee.EmployeeName.Trim(),
EmployeeMobile = employee.Phone.Trim(),
@@ -612,11 +800,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
?? throw Oops.Bah("契约锁草稿未返回企业签署方");
var companyAction = companySignatory.Actions.FirstOrDefault(item =>
string.Equals(item.Type, "COMPANY", StringComparison.OrdinalIgnoreCase))
?? throw Oops.Bah("契约锁草稿未返回企业自动盖章动作");
?? throw Oops.Bah("契约锁草稿未返回企业盖章动作");
var personalSignatoryId = GetSignatoryId(personalSignatory);
var companyActionId = GetActionId(companyAction);
var stampers = new List<QiyuesuoStamperInput>();
foreach (var document in documents.OrderBy(item => item.DocumentSort))
var orderedDocuments = documents.OrderBy(item => item.DocumentSort).ToList();
foreach (var document in orderedDocuments)
{
document.EmployeeActionId = personalSignatoryId;
document.CompanyActionId = companyActionId;
@@ -637,10 +826,50 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
DocumentId = document.ProviderDocumentId,
SealId = process.SealId,
Keyword = document.CompanySealKeyword,
KeywordIndex = Math.Max(1, _setting.CompanySealKeywordIndex)
KeywordIndex = Math.Max(1, _setting.CompanySealKeywordIndex),
OffsetX = _setting.CompanySealOffsetX,
OffsetY = _setting.CompanySealOffsetY
});
}
if (_setting.EnableAcrossPageSeal)
{
var providerDocumentIds = orderedDocuments
.Select(item => item.ProviderDocumentId)
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList();
if (providerDocumentIds.Count == 0)
throw Oops.Bah("契约锁未返回可用于骑缝章的合同文档 Id");
var acrossType = _setting.AcrossPageSealType.Trim().ToUpperInvariant();
if (acrossType == "ALL_DOCUMENT")
{
stampers.Add(new QiyuesuoStamperInput
{
ActionId = companyActionId,
Type = "ACROSS_PAGE",
DocumentId = providerDocumentIds[0],
DocumentIds = string.Join(",", providerDocumentIds),
SealId = process.SealId,
AcrossType = acrossType
});
}
else
{
foreach (var providerDocumentId in providerDocumentIds)
{
stampers.Add(new QiyuesuoStamperInput
{
ActionId = companyActionId,
Type = "ACROSS_PAGE",
DocumentId = providerDocumentId,
SealId = process.SealId,
AcrossType = acrossType
});
}
}
}
var sent = await _qiyuesuoClient.Send(new QiyuesuoSendContractInput
{
ContractId = process.ProviderContractId,
@@ -861,6 +1090,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
.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;
@@ -890,7 +1120,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
DocumentName = item.DocumentName,
DocumentSort = item.DocumentSort,
OriginalDocxFileId = item.OriginalDocxFileId,
SignedPdfFileId = item.SignedPdfFileId
SignedPdfFileId = item.SignedPdfFileId,
EmployeeSignedPreviewed = previewedDocumentIds.Contains(item.Id)
}).ToList();
}
else
@@ -913,6 +1144,108 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
return result;
}
/// <summary>
/// 读取任务当前所属公司的法定名称。集团共用令牌时,该名称用于明确子公司主体。
/// </summary>
private async Task<string> GetCompanyLegalName(ObOnboardingTask task)
{
var company = await _db.Queryable<ObCompany>()
.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();
}
/// <summary>
/// 返回当前流程中已由管理员生成过员工签后预览的文档 Id。
/// </summary>
private async Task<HashSet<long>> GetPreviewedDocumentIds(long processId)
{
var eventTypes = await _db.Queryable<ObElectronicSignEvent>()
.Where(item =>
item.SignProcessId == processId
&& item.EventSource == AdminPreviewEventSource
&& item.ProcessResult == "已生成预览地址")
.Select(item => item.EventType)
.ToListAsync();
var result = new HashSet<long>();
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;
}
/// <summary>
/// 将管理员预览、确认盖章等关键操作写入现有电子签事件表。
/// 固定操作键生成固定哈希,可防止双击或重试留下重复审计记录。
/// </summary>
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<ObElectronicSignEvent>()
.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<ObElectronicSignEvent>()
.FirstAsync(item => item.PayloadHash == payloadHash);
if (existing == null)
throw;
}
}
private static string BuildPreviewEventType(long documentId)
{
return $"{PreviewEventTypePrefix}{documentId}";
}
/// <summary>
/// 电子签管理沿用入职审核人权限,不新增另一套权限来源。
/// </summary>
@@ -1151,10 +1484,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
throw Oops.Bah("请先配置契约锁 AppToken");
if (string.IsNullOrWhiteSpace(_setting.AppSecret))
throw Oops.Bah("请先配置契约锁 AppSecret");
if (string.IsNullOrWhiteSpace(_setting.CategoryId))
throw Oops.Bah("请先配置契约锁默认业务分类 Id");
if (string.IsNullOrWhiteSpace(_setting.SealId))
throw Oops.Bah("请先配置契约锁企业印章 Id");
if (string.IsNullOrWhiteSpace(_setting.CreatorName)
|| string.IsNullOrWhiteSpace(_setting.CreatorMobile))
{
@@ -1165,6 +1495,17 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
{
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(