完善第三方签署

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
@@ -17,17 +17,19 @@
"AppToken": "rMKU99gkts",
"AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O",
"SignatureVersion": "",
"CategoryId": "3533041076673835142",
"SealId": "3533379903657587306",
"CreatorName": "袁林",
"CreatorMobile": "15123135384",
"CallbackUrl": "https://undiffusively-hemielytral-myrtle.ngrok-free.dev",
"CallbackUrl": "https://undiffusively-hemielytral-myrtle.ngrok-free.dev/api/open/onboarding/qiyuesuo/callback",
"SignPageCallbackUrl": "",
"SignUrlExpireMinutes": 10,
"EmployeeSignKeyword": "乙方(劳动者)签字:",
"EmployeeSignKeywordIndex": 1,
"CompanySealKeyword": "甲方(盖章):",
"CompanySealKeywordIndex": 1,
"CompanySealOffsetX": 0,
"CompanySealOffsetY": -0.05,
"EnableAcrossPageSeal": true,
"AcrossPageSealType": "ALL_DOCUMENT",
"TimeoutSeconds": 90
}
}
@@ -17,8 +17,6 @@
"AppToken": "rMKU99gkts",
"AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O",
"SignatureVersion": "",
"CategoryId": "3533041076673835142",
"SealId": "3533042752893948009",
"CreatorName": "袁林",
"CreatorMobile": "15123135384",
"CallbackUrl": "https://onboardx-api.cnzhyx.cn/api/open/onboarding/qiyuesuo/callback",
@@ -28,6 +26,10 @@
"EmployeeSignKeywordIndex": 1,
"CompanySealKeyword": "甲方(盖章):",
"CompanySealKeywordIndex": 1,
"CompanySealOffsetX": 0,
"CompanySealOffsetY": -0.12,
"EnableAcrossPageSeal": true,
"AcrossPageSealType": "ALL_DOCUMENT",
"TimeoutSeconds": 90
}
}
@@ -52,6 +52,16 @@ public class ObCompany : BaseEntity
/// </summary>
public string ContactPhone { get; set; }
/// <summary>
/// 契约锁默认业务分类 Id;每个签约公司独立配置。
/// </summary>
public string QiyuesuoCategoryId { get; set; }
/// <summary>
/// 契约锁企业印章 Id;每个签约公司独立配置。
/// </summary>
public string QiyuesuoSealId { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
@@ -176,7 +176,7 @@ public static class ElectronicSignStatus
public const string PendingEmployeeSign = "pending_employee_sign";
/// <summary>
/// 员工已签,等待企业自动盖章或平台完成流程。
/// 员工已签,等待后台管理员核对文件并确认企业盖章。
/// </summary>
public const string PendingCompanySeal = "pending_company_seal";
@@ -30,16 +30,6 @@ public class QiyuesuoSettingOptions
/// </summary>
public string SignatureVersion { get; set; }
/// <summary>
/// 默认业务分类 Id。
/// </summary>
public string CategoryId { get; set; }
/// <summary>
/// 企业自动盖章使用的印章 Id。
/// </summary>
public string SealId { get; set; }
/// <summary>
/// 契约锁企业成员中的合同创建人姓名。
/// </summary>
@@ -85,6 +75,26 @@ public class QiyuesuoSettingOptions
/// </summary>
public int CompanySealKeywordIndex { get; set; } = 1;
/// <summary>
/// 企业盖章相对关键字的横向偏移量,范围 (-1, 1),正数向右、负数向左。
/// </summary>
public decimal CompanySealOffsetX { get; set; }
/// <summary>
/// 企业盖章相对关键字的纵向偏移量,范围 (-1, 1),正数向上、负数向下。
/// </summary>
public decimal CompanySealOffsetY { get; set; }
/// <summary>
/// 是否在后台确认企业盖章时同时加盖骑缝章。
/// </summary>
public bool EnableAcrossPageSeal { get; set; } = true;
/// <summary>
/// 骑缝章文档范围:ALL_DOCUMENT(全部文档合并)或 SINGLE_DOCUMENT(每份文档单独)。
/// </summary>
public string AcrossPageSealType { get; set; } = "ALL_DOCUMENT";
/// <summary>
/// 单次 HTTP 请求超时秒数。
/// </summary>
@@ -43,6 +43,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
/// </summary>
public async Task Add(OnboardingCompanyAddInput input)
{
NormalizeAndValidateQiyuesuoConfig(input);
await EnsureCodeUnique(input.CompanyCode, 0);
await InsertAsync(input.Adapt<ObCompany>());
}
@@ -52,6 +53,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
/// </summary>
public async Task Edit(OnboardingCompanyEditInput input)
{
NormalizeAndValidateQiyuesuoConfig(input);
await Detail(new BaseIdInput { Id = input.Id });
await EnsureCodeUnique(input.CompanyCode, input.Id);
await UpdateAsync(input.Adapt<ObCompany>());
@@ -98,6 +100,24 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
}
private static void NormalizeAndValidateQiyuesuoConfig(ObCompany company)
{
company.QiyuesuoCategoryId = company.QiyuesuoCategoryId?.Trim();
company.QiyuesuoSealId = company.QiyuesuoSealId?.Trim();
var hasCategoryId = !string.IsNullOrWhiteSpace(company.QiyuesuoCategoryId);
var hasSealId = !string.IsNullOrWhiteSpace(company.QiyuesuoSealId);
if (hasCategoryId != hasSealId)
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写");
if (!hasCategoryId)
return;
if (!long.TryParse(company.QiyuesuoCategoryId, out _)
|| !long.TryParse(company.QiyuesuoSealId, out _))
{
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写数字");
}
}
private async Task EnsureCodeUnique(string code, long id)
{
var exists = await Context.Queryable<ObCompany>()
@@ -17,25 +17,39 @@ public static class DocxTemplateProcessor
/// </summary>
public static List<string> ExtractVariableCodes(byte[] docx)
{
using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive(
input,
global::System.IO.Compression.ZipArchiveMode.Read);
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
ValidateDocxHeader(docx);
try
{
using var stream = entry.Open();
var document = global::System.Xml.Linq.XDocument.Load(stream);
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
{
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value));
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text))
codes.Add(match.Groups["code"].Value.Trim());
}
}
using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive(
input,
global::System.IO.Compression.ZipArchiveMode.Read);
if (archive.GetEntry("word/document.xml") == null)
throw Oops.Bah("上传文件不是有效的 DOCX 文档,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
return codes.OrderBy(code => code).ToList();
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
{
using var stream = entry.Open();
var document = global::System.Xml.Linq.XDocument.Load(stream);
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
{
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value));
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text))
codes.Add(match.Groups["code"].Value.Trim());
}
}
return codes.OrderBy(code => code).ToList();
}
catch (global::System.IO.InvalidDataException)
{
throw Oops.Bah("DOCX 文件结构已经损坏或上传不完整,请重新另存为 DOCX 后上传");
}
catch (global::System.Xml.XmlException)
{
throw Oops.Bah("DOCX 文件内部内容损坏,Word 可以尝试修复该文件;请修复并重新另存为 DOCX 后上传");
}
}
/// <summary>
@@ -43,6 +57,7 @@ public static class DocxTemplateProcessor
/// </summary>
public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values)
{
ValidateDocxHeader(docx);
using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive(
input,
@@ -157,4 +172,26 @@ public static class DocxTemplateProcessor
|| entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase))
&& entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));
}
}
/// <summary>
/// 根据文件头识别真实格式,避免只修改扩展名的旧版 DOC 在解压时变成系统异常。
/// </summary>
private static void ValidateDocxHeader(byte[] docx)
{
if (docx == null || docx.Length < 4)
throw Oops.Bah("上传的 DOCX 文件为空或上传不完整,请重新选择文件上传");
// 旧版 Word 97-2003 二进制 DOC 使用 OLE Compound File 文件头:D0 CF 11 E0。
if (docx[0] == 0xD0
&& docx[1] == 0xCF
&& docx[2] == 0x11
&& docx[3] == 0xE0)
{
throw Oops.Bah("上传文件实际是旧版 DOC 格式,不能直接修改后缀名;请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
}
// DOCX 是 Open XML ZIP 包,正常文件以 PK 开头。
if (docx[0] != 0x50 || docx[1] != 0x4B)
throw Oops.Bah("上传文件的真实格式不是 DOCX,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
}
}
@@ -37,6 +37,7 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
var page = await query
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.TemplateName)
.ToPagedListAsync(input.PageNum, input.PageSize);
await PopulateScopeDisplay(page.List.ToList());
@@ -14,6 +14,18 @@ public class OnboardingElectronicSignTaskInput
public long TaskId { get; set; }
}
/// <summary>
/// 管理员查看一份员工已签合同的参数。
/// </summary>
public class OnboardingElectronicSignPreviewInput : OnboardingElectronicSignTaskInput
{
/// <summary>
/// 本地电子签文档 Id。
/// </summary>
[IdNotNull(ErrorMessage = "电子签文档Id不能为空")]
public long SignDocumentId { get; set; }
}
/// <summary>
/// 作废入职电子签流程的参数。
/// </summary>
@@ -85,6 +85,18 @@ public class OnboardingElectronicSignDetailOutput
public List<OnboardingElectronicSignHistoryOutput> HistoryProcesses { get; set; } = new();
}
/// <summary>
/// 契约锁员工已签文件只读预览结果。
/// </summary>
public class OnboardingElectronicSignPreviewOutput
{
/// <summary>本地电子签文档 Id。</summary>
public long SignDocumentId { get; set; }
/// <summary>契约锁生成的只读浏览页面地址。</summary>
public string PreviewUrl { get; set; }
}
/// <summary>
/// 已被新流程替代的历史电子签流程摘要。
@@ -149,4 +161,7 @@ public class OnboardingElectronicSignDocumentOutput
/// <summary>签署完成后的 PDF 文件 Id。</summary>
public long? SignedPdfFileId { get; set; }
/// <summary>当前管理员流程中是否已生成过员工签后预览。</summary>
public bool EmployeeSignedPreviewed { get; set; }
}
@@ -24,6 +24,14 @@ public interface IOnboardingElectronicSignService : ITransient
Task<OnboardingElectronicSignDetailOutput> GetSignLink(
OnboardingElectronicSignTaskInput input);
/// <summary>获取一份员工已签合同的只读浏览页面。</summary>
Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
OnboardingElectronicSignPreviewInput input);
/// <summary>确认全部文件已核对后,由后台执行企业盖章。</summary>
Task<OnboardingElectronicSignDetailOutput> CompanySeal(
OnboardingElectronicSignTaskInput input);
/// <summary>催办当前待签员工。</summary>
Task Notice(OnboardingElectronicSignTaskInput input);
@@ -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(
@@ -6,7 +6,7 @@ namespace SimpleAdmin.Application;
public interface IQiyuesuoClient : ITransient
{
/// <summary>
/// 创建包含个人签署方和企业自动盖章动作的合同草稿。
/// 创建包含个人签署方和企业人工盖章动作的合同草稿。
/// </summary>
Task<QiyuesuoContractResult> CreateDraft(
QiyuesuoCreateDraftInput input);
@@ -29,6 +29,12 @@ public interface IQiyuesuoClient : ITransient
Task<string> GetSignPageUrl(
QiyuesuoSignPageInput input);
/// <summary>
/// 获取当前合同中指定文档的只读浏览页面地址。
/// </summary>
Task<string> GetViewPageUrl(
QiyuesuoViewPageInput input);
/// <summary>
/// 查询契约锁合同详情。
/// </summary>
@@ -39,10 +45,15 @@ public interface IQiyuesuoClient : ITransient
/// </summary>
Task Notice(string contractId);
/// <summary>
/// 由后台管理员确认后执行企业盖章节点。
/// </summary>
Task CompanySign(QiyuesuoCompanySignInput input);
/// <summary>
/// 作废尚未完成的合同流程。
/// </summary>
Task Invalid(string contractId, string reason);
Task Invalid(string contractId, string sealId, string reason);
/// <summary>
/// 下载单份签署完成的 PDF 文档。
@@ -103,9 +103,9 @@ public class QiyuesuoClient : IQiyuesuoClient
new
{
type = "COMPANY",
name = "企业自动盖章",
name = "企业人工盖章",
serialNo = 0,
autoSign = true,
autoSign = false,
sealId = input.SealId,
corpSealIds = new[] { sealId },
actionNo = "company_seal"
@@ -185,6 +185,33 @@ public class QiyuesuoClient : IQiyuesuoClient
throw Oops.Bah("契约锁未返回员工签署页面地址");
}
/// <inheritdoc />
public async Task<string> GetViewPageUrl(
QiyuesuoViewPageInput input)
{
var result = await GetJsonElement(
"/v2/contract/viewurl",
new Dictionary<string, string>
{
["contractId"] = input.ContractId,
["documentId"] = input.DocumentId,
["tenantName"] = input.CompanyName,
["downloadButton"] = "false",
["printButton"] = "false"
});
if (result.ValueKind == JsonValueKind.String)
return result.GetString();
if (TryGetString(result, "url", out var url)
|| TryGetString(result, "viewUrl", out url)
|| TryGetString(result, "pageUrl", out url))
{
return url;
}
throw Oops.Bah("契约锁未返回合同浏览页面地址");
}
/// <inheritdoc />
public Task<QiyuesuoContractResult> GetContractDetail(string contractId)
{
@@ -212,14 +239,27 @@ public class QiyuesuoClient : IQiyuesuoClient
}
/// <inheritdoc />
public async Task Invalid(string contractId, string reason)
public async Task CompanySign(QiyuesuoCompanySignInput input)
{
await PostJsonElement(
"/v2/contract/companysign",
new
{
contractId = input.ContractId,
tenantName = input.CompanyName,
sealId = input.SealId
});
}
/// <inheritdoc />
public async Task Invalid(string contractId, string sealId, string reason)
{
await PostJsonElement(
"/v2/contract/invalid",
new
{
contractId,
sealId = _setting.SealId,
sealId,
reason,
deleteDoc = false,
autoSign = true
@@ -318,6 +358,15 @@ public class QiyuesuoClient : IQiyuesuoClient
private async Task<T> GetJson<T>(
string path,
Dictionary<string, string> query)
{
var result = await GetJsonElement(path, query);
return result.Deserialize<T>(JsonOptions)
?? throw Oops.Bah("契约锁返回数据为空");
}
private async Task<JsonElement> GetJsonElement(
string path,
Dictionary<string, string> query)
{
ValidateSettings();
using var request = new HttpRequestMessage(
@@ -328,9 +377,7 @@ public class QiyuesuoClient : IQiyuesuoClient
path,
HttpMethod.Get,
query);
var result = await SendForJsonResult(request);
return result.Deserialize<T>(JsonOptions)
?? throw Oops.Bah("契约锁返回数据为空");
return await SendForJsonResult(request);
}
private async Task<byte[]> Download(
@@ -77,6 +77,12 @@ public class QiyuesuoStamperInput
/// <summary>契约锁文档 Id。</summary>
public string DocumentId { get; set; }
/// <summary>骑缝章覆盖的契约锁文档 Id,多个 Id 使用英文逗号分隔。</summary>
public string DocumentIds { get; set; }
/// <summary>骑缝章位置指定方式。</summary>
public string AcrossType { get; set; }
/// <summary>企业印章 Id。</summary>
public string SealId { get; set; }
@@ -84,7 +90,13 @@ public class QiyuesuoStamperInput
public string Keyword { get; set; }
/// <summary>同名关键字的序号,从 1 开始。</summary>
public int KeywordIndex { get; set; } = 1;
public int? KeywordIndex { get; set; }
/// <summary>相对关键字的横向偏移量;契约锁要求必传,不偏移时为 0。</summary>
public decimal OffsetX { get; set; }
/// <summary>相对关键字的纵向偏移量;契约锁要求必传,不偏移时为 0。</summary>
public decimal OffsetY { get; set; }
}
/// <summary>
@@ -120,6 +132,36 @@ public class QiyuesuoSignPageInput
public string CallbackPage { get; set; }
}
/// <summary>
/// 获取契约锁合同文档只读浏览页所需参数。
/// </summary>
public class QiyuesuoViewPageInput
{
/// <summary>契约锁合同流程 Id。</summary>
public string ContractId { get; set; }
/// <summary>契约锁合同文档 Id。</summary>
public string DocumentId { get; set; }
/// <summary>签署企业法定名称;集团模式下用于定位子公司。</summary>
public string CompanyName { get; set; }
}
/// <summary>
/// 契约锁企业人工盖章所需参数。
/// </summary>
public class QiyuesuoCompanySignInput
{
/// <summary>契约锁合同流程 Id。</summary>
public string ContractId { get; set; }
/// <summary>签署企业法定名称;集团模式下用于定位子公司。</summary>
public string CompanyName { get; set; }
/// <summary>当前流程使用的企业印章 Id 快照。</summary>
public string SealId { get; set; }
}
/// <summary>
/// 契约锁合同返回对象。
/// </summary>
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职业务岗位业务能力。
@@ -17,6 +17,7 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
.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}")
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.PositionName)
.ToPagedListAsync(input.PageNum, input.PageSize);