diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs b/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs index 008e992..2a576c9 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs @@ -35,35 +35,35 @@ public class ObCompany : BaseEntity /// /// 统一社会信用代码。 /// - public string UnifiedSocialCreditCode { get; set; } + public virtual string UnifiedSocialCreditCode { get; set; } /// /// 法定代表人姓名。 /// - public string LegalRepresentative { get; set; } + public virtual string LegalRepresentative { get; set; } /// /// 工商注册地址。 /// - public string RegisteredAddress { get; set; } + public virtual string RegisteredAddress { get; set; } /// /// 公司联系电话。 /// - public string ContactPhone { get; set; } + public virtual string ContactPhone { get; set; } /// /// 契约锁默认业务分类 Id;每个签约公司独立配置。 /// - public string QiyuesuoCategoryId { get; set; } + public virtual string QiyuesuoCategoryId { get; set; } /// /// 契约锁企业印章 Id;每个签约公司独立配置。 /// - public string QiyuesuoSealId { get; set; } + public virtual string QiyuesuoSealId { get; set; } /// /// 业务备注。 /// - public string Remark { get; set; } + public virtual string Remark { get; set; } } diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs b/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs index fbd3e23..0f6a60e 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 入职业务部门档案,属于某家入职公司。 @@ -30,7 +30,7 @@ public class ObDepartment : BaseEntity /// /// 业务备注。 /// - public string Remark { get; set; } + public virtual string Remark { get; set; } /// /// 所属公司名称 (展示用) diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs b/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs index 6adaa46..298c63c 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs @@ -56,7 +56,7 @@ public class ObPosition : BaseEntity /// /// 业务备注。 /// - public string Remark { get; set; } + public virtual string Remark { get; set; } /// /// 所属公司名称 (展示用) diff --git a/SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionValidity.cs b/SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionValidity.cs new file mode 100644 index 0000000..f65a5f9 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionValidity.cs @@ -0,0 +1,21 @@ +namespace SimpleAdmin.Application; + +/// +/// 签署材料模板版本的时间有效性规则。 +/// +internal static class ContractTemplateVersionValidity +{ + internal static void ValidateRange(DateTime? effectiveAt, DateTime? expiresAt) + { + if (effectiveAt.HasValue && expiresAt.HasValue && expiresAt.Value <= effectiveAt.Value) + throw Oops.Bah("失效时间必须晚于生效时间"); + } + + internal static bool IsAvailable(ObContractTemplateVersion version, DateTime now) + { + return version != null + && version.VersionStatus == ContractTemplateVersionStatus.Enabled + && (!version.EffectiveDate.HasValue || version.EffectiveDate.Value <= now) + && (!version.ExpiryDate.HasValue || version.ExpiryDate.Value > now); + } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/Company/Dto/OnboardingCompanyInput.cs b/SimpleAdmin.Application/Services/Onboarding/Company/Dto/OnboardingCompanyInput.cs index 1f12c77..9921a46 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Company/Dto/OnboardingCompanyInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Company/Dto/OnboardingCompanyInput.cs @@ -30,19 +30,64 @@ public class OnboardingCompanyAddInput : ObCompany /// 公司业务编码。 /// [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司编码不能为空")] + [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司编码不能超过50个字符")] public override string CompanyCode { get; set; } /// /// 公司名称。 /// [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司名称不能为空")] + [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司名称不能超过50个字符")] public override string CompanyName { get; set; } /// /// 合同甲方完整法定名称。 /// [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "合同甲方全称不能为空")] + [global::System.ComponentModel.DataAnnotations.MaxLength(100, ErrorMessage = "合同甲方全称不能超过100个字符")] public override string LegalName { get; set; } + + /// + /// 统一社会信用代码。 + /// + [global::System.ComponentModel.DataAnnotations.MaxLength(18, ErrorMessage = "统一社会信用代码不能超过18位")] + public override string UnifiedSocialCreditCode { get; set; } + + /// + /// 法定代表人姓名。 + /// + [global::System.ComponentModel.DataAnnotations.MaxLength(20, ErrorMessage = "法定代表人不能超过20个字符")] + public override string LegalRepresentative { get; set; } + + /// + /// 工商注册地址。 + /// + [global::System.ComponentModel.DataAnnotations.MaxLength(150, ErrorMessage = "注册地址不能超过150个字符")] + public override string RegisteredAddress { get; set; } + + /// + /// 公司联系电话。 + /// + [global::System.ComponentModel.DataAnnotations.MaxLength(20, ErrorMessage = "联系电话不能超过20个字符")] + public override string ContactPhone { get; set; } + + /// + /// 契约锁默认业务分类 Id;每个签约公司独立配置。 + /// + [global::System.ComponentModel.DataAnnotations.MaxLength(19, ErrorMessage = "契约锁业务分类Id不能超过19位数字")] + public override string QiyuesuoCategoryId { get; set; } + + /// + /// 契约锁企业印章 Id;每个签约公司独立配置。 + /// + [global::System.ComponentModel.DataAnnotations.MaxLength(19, ErrorMessage = "契约锁印章Id不能超过19位数字")] + public override string QiyuesuoSealId { get; set; } + + /// + /// 业务备注。 + /// + [global::System.ComponentModel.DataAnnotations.MaxLength(300, ErrorMessage = "备注不能超过300个字符")] + public override string Remark { get; set; } } /// diff --git a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs index 518835e..87cbe5e 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs @@ -138,10 +138,16 @@ public class OnboardingCompanyService : DbRepository, IOnboardingComp throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写"); if (!hasCategoryId) return; + if (!company.QiyuesuoCategoryId.All(char.IsDigit) + || !company.QiyuesuoSealId.All(char.IsDigit)) + { + throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写纯数字"); + } + if (!long.TryParse(company.QiyuesuoCategoryId, out _) || !long.TryParse(company.QiyuesuoSealId, out _)) { - throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写数字"); + throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 超出有效数字范围(最大支持19位长整数)"); } } diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs index 3ef15f3..a59d9b3 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs @@ -153,13 +153,14 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ var versions = await _db.Queryable() .Where(item => versionIds.Contains(item.Id)) .ToListAsync(); + var now = DateTime.Now; var versionsValid = versions.Count == versionIds.Count && contractConfigs.All(config => versions.Any(version => version.Id == config.ContractTemplateVersionId && version.TemplateId == config.ContractTemplateId - && version.VersionStatus == ContractTemplateVersionStatus.Enabled)); + && ContractTemplateVersionValidity.IsAvailable(version, now))); if (!versionsValid) - throw Oops.Bah("入职模板包含不存在、未启用或归属错误的合同版本"); + throw Oops.Bah("入职模板包含不存在、未生效、已失效、未启用或归属错误的签署材料版本"); var inputs = input.ContractVariables ?? new List(); diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs index 2bf12f9..d768607 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs @@ -46,12 +46,12 @@ public class OnboardingContractVersionAddInput public string FileHash { get; set; } /// - /// 生效日期。 + /// 生效时间。 /// public DateTime? EffectiveDate { get; set; } /// - /// 失效日期。 + /// 失效时间。 /// public DateTime? ExpiryDate { get; set; } diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs index a73e131..f82129e 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs @@ -79,7 +79,21 @@ public class OnboardingContractTemplateService : DbRepository item.Id).ToList(); + var availableVersionTemplateIds = (await Context.Queryable() + .Where(item => candidateIds.Contains(item.TemplateId)) + .ToListAsync()) + .Where(item => ContractTemplateVersionValidity.IsAvailable(item, now)) + .Select(item => item.TemplateId) + .ToHashSet(); + candidates = candidates + .Where(item => availableVersionTemplateIds.Contains(item.Id)) + .ToList(); + if (candidates.Count == 0) + return candidates; + + candidateIds = candidates.Select(item => item.Id).ToList(); var relations = await Context.Queryable() .Where(item => candidateIds.Contains(item.ContractTemplateId)) .ToListAsync(); @@ -270,6 +284,7 @@ public class OnboardingContractTemplateService : DbRepository public async Task AddVersion(OnboardingContractVersionAddInput input) { + ContractTemplateVersionValidity.ValidateRange(input.EffectiveDate, input.ExpiryDate); var template = await Detail(new BaseIdInput { Id = input.TemplateId }); await _dataPermission.EnsureCanManage( template.CreateUserId, @@ -357,6 +372,9 @@ public class OnboardingContractTemplateService : DbRepository() .AnyAsync(item => item.TemplateVersionId == input.Id && item.VariableType == ContractVariableType.Unresolved); diff --git a/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs b/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs index 378d0bf..1b931f8 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 入职业务部门分页查询参数。 @@ -41,13 +41,21 @@ public class OnboardingDepartmentAddInput : ObDepartment /// 部门业务编码。 /// [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门编码不能为空")] + [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "部门编码不能超过50个字符")] public override string DepartmentCode { get; set; } /// /// 部门名称。 /// [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门名称不能为空")] + [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "部门名称不能超过50个字符")] public override string DepartmentName { get; set; } + + /// + /// 业务备注。 + /// + [global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")] + public override string Remark { get; set; } } /// diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs index cfa408c..2fa8c3f 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs @@ -177,8 +177,14 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService 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 格式不正确"); + throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或企业印章 Id 超出有效范围(最大支持19位长整数)"); var taskContracts = await _db.Queryable() .Where(item => item.TaskId == task.Id) diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs index 40b15f9..6a94678 100644 --- a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs @@ -311,6 +311,9 @@ public class OnboardingTemplateService : IOnboardingTemplateService { throw Oops.Bah("合同模板与版本不匹配"); } + var now = DateTime.Now; + if (versions.Any(version => !ContractTemplateVersionValidity.IsAvailable(version, now))) + throw Oops.Bah("入职模板只能选择当前已生效且尚未失效的启用签署材料版本"); var contractTemplateIds = input.Contracts .Select(item => item.ContractTemplateId) @@ -352,8 +355,6 @@ public class OnboardingTemplateService : IOnboardingTemplateService "enable", global::System.StringComparison.OrdinalIgnoreCase))) throw Oops.Bah("启用入职模板前,请先启用全部合同模板"); - if (versions.Any(item => item.VersionStatus != ContractTemplateVersionStatus.Enabled)) - throw Oops.Bah("启用入职模板前,请先启用全部合同版本"); } var materialIds = input.Materials.Select(item => item.MaterialTypeId).ToList(); diff --git a/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs b/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs index 5de8f08..ffd683f 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 入职业务岗位分页查询参数。 @@ -52,13 +52,21 @@ public class OnboardingPositionAddInput : ObPosition /// 岗位业务编码。 /// [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位编码不能为空")] + [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位编码不能超过50个字符")] public override string PositionCode { get; set; } /// /// 岗位名称。 /// [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位名称不能为空")] + [global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位名称不能超过50个字符")] public override string PositionName { get; set; } + + /// + /// 业务备注。 + /// + [global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")] + public override string Remark { get; set; } } /// diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs index 2b6cc26..88c85cb 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs @@ -95,11 +95,13 @@ public class OnboardingTaskService : IOnboardingTaskService throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板"); var versionIds = contractConfigs.Select(item => item.ContractTemplateVersionId).Distinct().ToList(); - var enabledVersionCount = await _db.Queryable() - .CountAsync(item => versionIds.Contains(item.Id) - && item.VersionStatus == ContractTemplateVersionStatus.Enabled); - if (enabledVersionCount != versionIds.Count) - throw Oops.Bah("入职模板包含未启用的合同版本,请先调整模板配置"); + var now = DateTime.Now; + var taskVersions = await _db.Queryable() + .Where(item => versionIds.Contains(item.Id)) + .ToListAsync(); + if (taskVersions.Count != versionIds.Count + || taskVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now))) + throw Oops.Bah("入职模板包含未生效、已失效或未启用的签署材料版本,请先调整模板配置"); var materialIds = materialConfigs.Select(item => item.MaterialTypeId).Distinct().ToList(); var materialTypes = materialIds.Count == 0 @@ -290,11 +292,13 @@ public class OnboardingTaskService : IOnboardingTaskService .Select(item => item.TemplateVersionId) .Distinct() .ToList(); - var enabledVersionCount = await _db.Queryable() - .CountAsync(item => versionIds.Contains(item.Id) - && item.VersionStatus == ContractTemplateVersionStatus.Enabled); - if (enabledVersionCount != versionIds.Count) - throw Oops.Bah("原任务包含已停用的合同版本,不能继续复制"); + var now = DateTime.Now; + var copyVersions = await _db.Queryable() + .Where(item => versionIds.Contains(item.Id)) + .ToListAsync(); + if (copyVersions.Count != versionIds.Count + || copyVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now))) + throw Oops.Bah("原任务包含未生效、已失效或未启用的签署材料版本,不能继续复制"); var sourceMaterialRequirements = await _db.Queryable() .Where(item => item.TaskId == sourceTask.Id) diff --git a/tests/SecurityRegression/Program.cs b/tests/SecurityRegression/Program.cs index d47f56c..25ffb87 100644 --- a/tests/SecurityRegression/Program.cs +++ b/tests/SecurityRegression/Program.cs @@ -47,6 +47,27 @@ foreach (var terminal in new[] { ElectronicSignStatus.Completed, ElectronicSignS foreach (var backward in new[] { ElectronicSignStatus.PendingEmployeeSign, ElectronicSignStatus.Draft, ElectronicSignStatus.Creating, ElectronicSignStatus.Failed }) Reject(() => OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingCompanySeal, backward), "signed progress cannot regress: " + backward); + +// Uses the real signing-material validity rule. End time is exclusive. +var validityNow = new DateTime(2026, 9, 17, 12, 0, 0); +var availableVersion = new ObContractTemplateVersion { VersionStatus = ContractTemplateVersionStatus.Enabled }; +Check(ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "enabled version without time bounds is available"); +availableVersion.EffectiveDate = validityNow; +availableVersion.ExpiryDate = validityNow.AddSeconds(1); +Check(ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "effective boundary is inclusive"); +availableVersion.ExpiryDate = validityNow; +Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "expiry boundary is exclusive"); +availableVersion.EffectiveDate = validityNow.AddSeconds(1); +availableVersion.ExpiryDate = validityNow.AddMinutes(1); +Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "future version is unavailable"); +availableVersion.EffectiveDate = validityNow.AddMinutes(-1); +availableVersion.ExpiryDate = validityNow.AddMinutes(1); +availableVersion.VersionStatus = ContractTemplateVersionStatus.Disabled; +Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "disabled version is unavailable"); +ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSeconds(1)); +Reject(() => ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow), "equal start/end rejected"); +Reject(() => ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSeconds(-1)), "end before start rejected"); + // Exercises the real async gate with a fake external lease. Does NOT claim MySQL GET_LOCK integration. var leases = new FakeLeases(); Task Gate(string key, Func> action) => OnboardingOperationGate.Run(key, () => leases.Acquire(key), action); diff --git a/tests/SecurityRegression/SecurityRegression.csproj b/tests/SecurityRegression/SecurityRegression.csproj index 70324da..f763a52 100644 --- a/tests/SecurityRegression/SecurityRegression.csproj +++ b/tests/SecurityRegression/SecurityRegression.csproj @@ -10,6 +10,7 @@ + \ No newline at end of file diff --git a/tests/SecurityRegression/Stubs.cs b/tests/SecurityRegression/Stubs.cs index c0ffd50..8aea31f 100644 --- a/tests/SecurityRegression/Stubs.cs +++ b/tests/SecurityRegression/Stubs.cs @@ -7,4 +7,5 @@ namespace SimpleAdmin.Core { } 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; } } }