新增公司盖章与骑缝章配置

This commit is contained in:
fuenmao
2026-10-15 17:41:55 +08:00
parent 0bb8263431
commit 83854ce901
20 changed files with 607 additions and 166 deletions
+132
View File
@@ -68,6 +68,39 @@ ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSecond
Reject(() => ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow), "equal start/end rejected");
Reject(() => ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSeconds(-1)), "end before start rejected");
// Uses the real partial-update builder and ORM SQL generation, never executes the SQL.
Reject(() => ContractTemplateVersionSealConfiguration.BuildUpdate(db, new() { Id = 42 }),
"seal update rejects an empty patch instead of resetting both flags");
Reject(() => ContractTemplateVersionSealConfiguration.BuildUpdate(db, new() { Id = 0, CompanySealEnabled = false }),
"seal update requires a specific version");
foreach (var enabled in new[] { false, true })
{
foreach (var companySeal in new[] { true, false })
{
var patch = new OnboardingContractVersionSealInput { Id = 42 };
var changedField = companySeal ? "CompanySealEnabled" : "AcrossPageSealEnabled";
var untouchedField = companySeal ? "AcrossPageSealEnabled" : "CompanySealEnabled";
if (companySeal) patch.CompanySealEnabled = enabled;
else patch.AcrossPageSealEnabled = enabled;
var generated = ContractTemplateVersionSealConfiguration.BuildUpdate(db, patch).ToSql();
var setClause = System.Text.RegularExpressions.Regex.Match(generated.Key, @"\bSET\b([\s\S]*?)\bWHERE\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase).Groups[1].Value;
Check(setClause.Contains(changedField) && !setClause.Contains(untouchedField)
&& !setClause.Contains("VersionStatus") && !System.Text.RegularExpressions.Regex.IsMatch(setClause, @"\bId\b"),
$"{changedField}={enabled} changes only its own column, never the other flag, status or primary key");
Check(generated.Value.Any(item => item.Value is bool flag && flag == enabled),
$"{changedField}={enabled} preserves the explicit boolean value in parameterized SQL");
Check(System.Text.RegularExpressions.Regex.IsMatch(generated.Key, @"\bWHERE\b[\s\S]*\bId\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase)
&& generated.Value.Any(item => item.Value is long id && id == 42),
$"{changedField} update is scoped to the requested version ID");
}
}
var bothSealUpdates = ContractTemplateVersionSealConfiguration.BuildUpdate(db,
new() { Id = 42, CompanySealEnabled = false, AcrossPageSealEnabled = true }).ToSql().Key;
Check(bothSealUpdates.Contains("CompanySealEnabled") && bothSealUpdates.Contains("AcrossPageSealEnabled"),
"both seal flags can be explicitly updated without overwriting unrelated version data");
// Exercises the real async gate with a fake external lease. Does NOT claim MySQL GET_LOCK integration.
var leases = new FakeLeases();
Task<int> Gate(string key, Func<Task<int>> action) => OnboardingOperationGate.Run(key, () => leases.Acquire(key), action);
@@ -91,6 +124,105 @@ Check(leases.AcquisitionCount - before == 2, "reentrant gate acquires each resou
try { await Gate("failure", () => throw new ApplicationException("synthetic fault")); } catch (ApplicationException) {}
Check(await Gate("failure", () => Task.FromResult(4)) == 4, "lock released on exception");
Check(leases.ActiveCount == 0, "no leaked leases or caller async context");
// Uses the real request builder and provider models. No provider or database requests.
var sealSettings = new QiyuesuoSettingOptions { EnableAcrossPageSeal = true, AcrossPageSealType = "SINGLE_DOCUMENT" };
var signDocuments = new[]
{
new OnboardingSigningDocument("labor", "员工签字", "公司盖章", true, true),
new OnboardingSigningDocument("confidentiality", "员工签字", "公司盖章", true, true),
new OnboardingSigningDocument("notice", "员工签字", null, false, false),
new OnboardingSigningDocument("no_release_certificate", "员工签字", null, false, false)
};
var positions = QiyuesuoContractRequestBuilder.BuildStampers(signDocuments, "person", "company", "123", sealSettings);
Check(positions.Count(item => item.Type == "PERSONAL") == 4, "all four materials require employee signatures");
Check(positions.Where(item => item.Type == "PERSONAL").Select(item => item.DocumentId).SequenceEqual(signDocuments.Select(item => item.DocumentId)),
"employee signatures retain each distinct material and its order");
Check(positions.Count(item => item.Type == "COMPANY") == 2, "only labor contract and confidentiality agreement receive company seals");
Check(positions.Count(item => item.Type == "ACROSS_PAGE") == 2, "selected materials receive separate across-page seals");
Check(positions.Where(item => item.Type != "PERSONAL").All(item => item.DocumentId is "labor" or "confidentiality"),
"employee-only materials never receive any company signing position");
Check(positions.Where(item => item.Type == "COMPANY").All(item => item.ActionId == "company" && item.SealId == "123"),
"company positions use the intended action and company seal");
sealSettings.AcrossPageSealType = "ALL_DOCUMENT";
var reorderedDocuments = new[] { signDocuments[2], signDocuments[0], signDocuments[3], signDocuments[1] };
positions = QiyuesuoContractRequestBuilder.BuildStampers(reorderedDocuments, "person", "company", "123", sealSettings);
var mergedAcross = positions.Single(item => item.Type == "ACROSS_PAGE");
Check(mergedAcross.DocumentId == "labor", "merged across-page anchor is the first selected material, not the first uploaded file");
Check(mergedAcross.DocumentIds == "labor,confidentiality", "merged across-page seal excludes both employee-only materials");
sealSettings.EnableAcrossPageSeal = false;
var legacyDocuments = new[] { new OnboardingSigningDocument("legacy", "员工签字", "公司盖章", null, null) };
positions = QiyuesuoContractRequestBuilder.BuildStampers(legacyDocuments, "person", "company", "123", sealSettings);
Check(positions.Count(item => item.Type == "COMPANY") == 1 && positions.All(item => item.Type != "ACROSS_PAGE"),
"old versions retain company seals and the disabled global across-page setting");
sealSettings.EnableAcrossPageSeal = true;
positions = QiyuesuoContractRequestBuilder.BuildStampers(legacyDocuments, "person", "company", "123", sealSettings);
Check(positions.Count(item => item.Type == "ACROSS_PAGE") == 1, "old versions retain the enabled global across-page setting");
sealSettings.EnableAcrossPageSeal = false;
positions = QiyuesuoContractRequestBuilder.BuildStampers(signDocuments, "person", "company", "123", sealSettings);
Check(positions.Count(item => item.Type == "ACROSS_PAGE") == 1, "new explicit across-page switch overrides legacy global fallback");
var acrossOnly = new[] { new OnboardingSigningDocument("across_only", "员工签字", null, false, true) };
positions = QiyuesuoContractRequestBuilder.BuildStampers(acrossOnly, "person", "company", "123", sealSettings);
Check(positions.Count(item => item.Type == "COMPANY") == 0 && positions.Count(item => item.Type == "ACROSS_PAGE") == 1,
"company seal and across-page seal switches operate independently");
Check(QiyuesuoContractRequestBuilder.RequiresCompanySign(false, true, sealSettings),
"across-page-only materials still require a company signing action");
var companyOnly = new[] { new OnboardingSigningDocument("company_only", "员工签字", "公司盖章", true, false) };
positions = QiyuesuoContractRequestBuilder.BuildStampers(companyOnly, "person", "company", "123", sealSettings);
Check(positions.Count(item => item.Type == "COMPANY") == 1 && positions.All(item => item.Type != "ACROSS_PAGE"),
"company seal without across-page seal is supported");
var personalOnly = new[] { new OnboardingSigningDocument("personal_only", "员工签字", null, false, false) };
positions = QiyuesuoContractRequestBuilder.BuildStampers(personalOnly, "person", null, null, sealSettings);
Check(positions.Count == 1 && positions[0].Type == "PERSONAL", "all switches off produces only personal signature positions");
Check(!QiyuesuoContractRequestBuilder.RequiresCompanySign(false, false, sealSettings),
"employee-only signing needs no company action");
Reject(() => QiyuesuoContractRequestBuilder.BuildStampers(signDocuments, "person", null, "123", sealSettings),
"company action cannot be silently omitted for materials requiring seals");
Reject(() => QiyuesuoContractRequestBuilder.BuildStampers(
new[] { new OnboardingSigningDocument(null, "员工签字", "公司盖章", true, true) }, "person", "company", "123", sealSettings),
"missing document ID is rejected instead of broadening seal scope");
sealSettings.AcrossPageSealType = "INVALID";
Reject(() => QiyuesuoContractRequestBuilder.BuildStampers(acrossOnly, "person", "company", "123", sealSettings),
"invalid across-page mode is checked even when the global fallback is off");
var draftInput = new QiyuesuoCreateDraftInput
{
RequiresCompanySign = false, CompanyName = "测试企业", EmployeeName = "测试员工",
EmployeeMobile = "13800138000", CreatorName = "测试经办人", CreatorMobile = "13900139000",
CategoryId = "456", CallbackUrl = " https://example.invalid/callback "
};
var employeeOnlyDraft = System.Text.Json.JsonSerializer.SerializeToElement(QiyuesuoContractRequestBuilder.BuildDraft(draftInput));
Check(employeeOnlyDraft.GetProperty("signatories").GetArrayLength() == 1
&& employeeOnlyDraft.GetProperty("signatories")[0].GetProperty("tenantType").GetString() == "PERSONAL",
"employee-only draft never creates a company signature node");
Check(employeeOnlyDraft.GetProperty("tenantName").GetString() == "测试企业",
"employee-only workflow retains its sponsoring company");
Check(employeeOnlyDraft.GetProperty("callbackUrl").GetString() == "https://example.invalid/callback",
"draft builder preserves normalized server callback");
draftInput.RequiresCompanySign = true;
Reject(() => QiyuesuoContractRequestBuilder.BuildDraft(draftInput), "company signing draft requires a valid seal");
draftInput.SealId = "123";
var companyDraft = System.Text.Json.JsonSerializer.SerializeToElement(QiyuesuoContractRequestBuilder.BuildDraft(draftInput));
Check(companyDraft.GetProperty("signatories").GetArrayLength() == 2
&& companyDraft.GetProperty("signatories")[1].GetProperty("actions")[0].GetProperty("type").GetString() == "COMPANY",
"mixed materials keep one company signing action after employee signing");
Check(!companyDraft.GetProperty("signatories")[1].GetProperty("actions")[0].GetProperty("autoSign").GetBoolean(),
"company seal remains manually triggered");
var employeeOnlyProvider = new QiyuesuoContractResult { Signatories = new()
{
new QiyuesuoSignatoryResult { TenantType = "PERSONAL", Status = "SIGNED" }
}};
Check(QiyuesuoContractRequestBuilder.ResolvePendingStatus(employeeOnlyProvider) != ElectronicSignStatus.PendingCompanySeal,
"employee-only workflow cannot become stuck in a nonexistent company-seal stage");
employeeOnlyProvider.Signatories.Add(new QiyuesuoSignatoryResult { TenantType = "COMPANY", Actions = new()
{
new QiyuesuoActionResult { Type = "COMPANY", Status = "START" }
}});
Check(QiyuesuoContractRequestBuilder.ResolvePendingStatus(employeeOnlyProvider) == ElectronicSignStatus.PendingCompanySeal,
"mixed workflow waits for company seal after employee signing");
Console.WriteLine("ALL " + passed + " CHECKS PASSED (offline; no business database or provider contacted)");
[SugarTable("test_row")]
+6
View File
@@ -26,6 +26,12 @@ dotnet run --project tests/SecurityRegression/SecurityRegression.csproj -p:NuGet
- 签约终态、防回退、归档状态保持。
- 64 个竞争调用与同任务互斥;不同任务互不阻塞。
- 同调用链嵌套任务锁可重入;正常/异常结束释放锁;调用方上下文不泄漏。
- 按版本选择公司盖章及骑缝章:四份材料全部员工签字,仅指定两份盖章。
- 单份/合并骑缝章只覆盖已选文件;两个开关独立;历史空值兼容全局设置。
- 纯员工签字的草稿无企业盖章节点;混合材料仍由后台人工触发企业盖章。
- 版本盖章开关局部更新:开启/关闭均保留布尔值,只更新传入的字段,版本 ID 只用作条件,不更新主键、另一个开关或版本状态;空请求被拒绝。
- 局部更新测试直接链接生产 SQL 构造代码和请求 DTO;版本实体仅使用带主键映射的测试替身,不代表真实数据库写入或权限集成测试。
- 请求构造器直接链接生产代码,只校验构造出的参数,不向契约锁发起真实签约。
## 仍需测试环境验证
@@ -11,6 +11,11 @@
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/Common/OnboardingOperationGate.cs" Link="OnboardingOperationGate.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingSignStateGuard.cs" Link="OnboardingSignStateGuard.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionValidity.cs" Link="ContractTemplateVersionValidity.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionSealConfiguration.cs" Link="ContractTemplateVersionSealConfiguration.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs" Link="OnboardingContractVersionInput.cs"/>
<Compile Include="../../SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs" Link="OnboardingStatus.cs"/>
<Compile Include="../../SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs" Link="QiyuesuoSettingOptions.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs" Link="QiyuesuoClientModels.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoContractRequestBuilder.cs" Link="QiyuesuoContractRequestBuilder.cs"/>
</ItemGroup>
</Project>
</Project>
+12 -1
View File
@@ -1,11 +1,22 @@
global using System.ComponentModel.DataAnnotations;
global using SimpleAdmin.Core;
namespace SimpleAdmin.Core {
[AttributeUsage(AttributeTargets.Property)]
public sealed class DataValidationAttribute : Attribute { public DataValidationAttribute(ValidationTypes type) {} }
[AttributeUsage(AttributeTargets.Property)]
public sealed class IdNotNullAttribute : Attribute { public string ErrorMessage { get; set; } }
public enum ValidationTypes { Numeric }
public static class Oops { public static Exception Bah(string text) => new InvalidOperationException(text); }
}
namespace SimpleAdmin.Application {
public static class Oops { public static Exception Bah(string text) => new InvalidOperationException(text); }
public sealed class ObContractTemplateVersion { public string VersionStatus { get; set; } public DateTime? EffectiveDate { get; set; } public DateTime? ExpiryDate { get; set; } }
[SqlSugar.SugarTable("ob_contract_template_version")]
public sealed class ObContractTemplateVersion {
[SqlSugar.SugarColumn(IsPrimaryKey = true)] public long Id { get; set; }
public string VersionStatus { get; set; }
public DateTime? EffectiveDate { get; set; }
public DateTime? ExpiryDate { get; set; }
public bool? CompanySealEnabled { get; set; }
public bool? AcrossPageSealEnabled { get; set; }
}
}