namespace SimpleAdmin.Application; /// /// 实现员工端动态资料、上传材料、合同确认和最终提交能力。 /// public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService { private readonly SqlSugarScope _db; private readonly IOnboardingContractSnapshotService _snapshotService; private readonly IOnboardingIdentityValidationService _identityValidationService; private readonly IOnboardingContractVariableValidationService _contractVariableValidationService; public OnboardingPublicSubmissionService( IOnboardingContractSnapshotService snapshotService, IOnboardingIdentityValidationService identityValidationService, IOnboardingContractVariableValidationService contractVariableValidationService) { _db = DbContext.DB; _snapshotService = snapshotService; _identityValidationService = identityValidationService; _contractVariableValidationService = contractVariableValidationService; } /// /// 关联已上传 OSS 文件与任务材料要求。 /// public async Task SaveMaterial(OnboardingSaveMaterialInput input) { await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveMaterialCore(input), transactional: true); } private async Task SaveMaterialCore(OnboardingSaveMaterialInput input) { var task = await GetEditableTask(input.Token); var requirement = await _db.Queryable() .FirstAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId) ?? throw Oops.Bah("材料类型不属于当前入职模板"); var fileExists = await _db.Queryable().AnyAsync(item => item.Id == input.FileId); if (!fileExists) throw Oops.Bah("上传文件不存在"); var existingMaterials = await _db.Queryable() .Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId) .ToListAsync(); if (input.TaskMaterialId.HasValue) { var replacement = existingMaterials.FirstOrDefault( item => item.Id == input.TaskMaterialId.Value) ?? throw Oops.Bah("需要重新上传的材料不存在"); replacement.FileId = input.FileId; replacement.SubmitRound = task.SubmitCount + 1; replacement.UploadedAt = DateTime.Now; replacement.ReviewStatus = TaskMaterialReviewStatus.Pending; replacement.ReviewRemark = null; await _db.Updateable(replacement).ExecuteCommandAsync(); return; } var supplementMaterial = existingMaterials.FirstOrDefault( item => item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement); if (supplementMaterial != null) { supplementMaterial.FileId = input.FileId; supplementMaterial.SubmitRound = task.SubmitCount + 1; supplementMaterial.UploadedAt = DateTime.Now; supplementMaterial.ReviewStatus = TaskMaterialReviewStatus.Pending; supplementMaterial.ReviewRemark = null; await _db.Updateable(supplementMaterial).ExecuteCommandAsync(); return; } if (existingMaterials.Count >= requirement.MaxFileCount) throw Oops.Bah("该材料已达到最大上传数量"); await _db.Insertable(new ObTaskMaterial { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), TaskId = task.Id, MaterialTypeId = requirement.MaterialTypeId, MaterialName = requirement.MaterialName, FileId = input.FileId, SubmitRound = task.SubmitCount + 1, UploadedAt = DateTime.Now }).ExecuteCommandAsync(); } /// /// 任务仍可编辑时删除一条已上传材料。 /// public async Task DeleteMaterial( OnboardingDeleteMaterialInput input) { await OnboardingConcurrency.RunPublic(_db, input.Token, () => DeleteMaterialCore(input), transactional: true); } private async Task DeleteMaterialCore( OnboardingDeleteMaterialInput input) { var task = await GetEditableTask(input.Token); var material = await _db.Queryable() .FirstAsync(item => item.Id == input.TaskMaterialId && item.TaskId == task.Id); if (material == null) throw Oops.Bah("上传材料不存在"); await _db.Deleteable() .In(material.Id) .ExecuteCommandAsync(); } /// /// 记录候选人已经查看并确认动态生成的合同。 /// public async Task ConfirmContract(OnboardingConfirmContractInput input) { await OnboardingConcurrency.RunPublic(_db, input.Token, () => ConfirmContractCore(input), transactional: true); } private async Task ConfirmContractCore(OnboardingConfirmContractInput input) { var task = await GetEditableTask(input.Token); var contract = await _db.Queryable() .FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == task.Id) ?? throw Oops.Bah("合同不存在"); if (!contract.GeneratedDocxFileId.HasValue || !contract.ReadAt.HasValue) throw Oops.Bah("请先查看已经生成的合同内容"); contract.ConfirmedAt = DateTime.Now; contract.ContractStatus = TaskContractStatus.Confirmed; await _db.Updateable(contract).ExecuteCommandAsync(); } /// /// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。 /// public async Task Submit(OnboardingSubmitInput input) { await OnboardingConcurrency.RunPublic(_db, input.Token, () => SubmitCore(input), transactional: true, protectIdentity: true); } private async Task SubmitCore(OnboardingSubmitInput input) { var task = await GetEditableTask(input.Token); var info = await _db.Queryable() .FirstAsync(item => item.TaskId == task.Id) ?? throw Oops.Bah("请先完整填写基本信息"); // 最终提交必须重新执行全量校验,不能只依赖员工手机端的页面校验。 var formRequirements = await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id); OnboardingEmployeeInfoValidator.ValidateAndNormalize(info, formRequirements); EnsureIdentityInformationConfirmed(input, info); await _db.Updateable(info).ExecuteCommandAsync(); await _identityValidationService.EnsureNoDuplicate( task.Id, info.EmployeeName, info.IdCardNumber); await _snapshotService.Build(task.Id, info); var requiredMaterials = (await GetMaterialRequirements(task.Id)) .Where(item => item.Required) .Select(item => item.MaterialTypeId) .ToList(); var materials = await _db.Queryable() .Where(item => item.TaskId == task.Id) .ToListAsync(); var hasMissingRequiredMaterial = requiredMaterials.Any( id => materials.All(item => item.MaterialTypeId != id)); if (hasMissingRequiredMaterial) throw Oops.Bah("仍有必传材料未上传"); await _contractVariableValidationService.Validate(task.Id); var hasUnconfirmedRequiredContract = await _db.Queryable() .AnyAsync(item => item.TaskId == task.Id && item.Required && item.ConfirmedAt == null); if (hasUnconfirmedRequiredContract) throw Oops.Bah("请查看并确认全部必签合同"); var submittedAt = DateTime.Now; var identityConfirmationHash = BuildIdentityConfirmationHash(info); var originalStatus = task.TaskStatus; var originalSubmitCount = task.SubmitCount; task.TaskStatus = OnboardingTaskStatus.Reviewing; task.SubmitCount++; task.LastSubmittedAt = submittedAt; task.RejectReason = null; var changed = await _db.Updateable(task) .UpdateColumns(item => new { item.TaskStatus, item.SubmitCount, item.LastSubmittedAt, item.RejectReason }) .Where(item => item.Id == task.Id && item.TaskStatus == originalStatus && item.SubmitCount == originalSubmitCount) .ExecuteCommandAsync(); if (changed != 1) throw Oops.Bah("任务状态已变更,请刷新后重试"); await _db.Insertable(new ObTaskAudit { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), TaskId = task.Id, AuditRound = task.SubmitCount, AuditAction = OnboardingAuditAction.Submit, AuditOpinion = "员工提交入职资料,并确认姓名、身份证号码和手机号码用于实名认证", SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new { info, materials, identityConfirmation = new { confirmedAt = submittedAt, summaryHash = identityConfirmationHash } }), AuditedAt = submittedAt }).ExecuteCommandAsync(); } /// /// 上传 OSS 前按照任务材料快照校验扩展名、文件大小和数量。 /// public async Task ValidateMaterialUpload(OnboardingMaterialUploadValidationInput input) { var task = await GetEditableTask(input.Token); var requirement = await _db.Queryable() .FirstAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId) ?? throw Oops.Bah("材料类型不属于当前入职模板"); var suffix = Path.GetExtension(input.FileName).TrimStart('.').ToLowerInvariant(); var allowedExtensions = (requirement.AllowedExtensions ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(item => item.TrimStart('.').ToLowerInvariant()); var suffixNotAllowed = allowedExtensions.Any() && !allowedExtensions.Contains(suffix); if (suffixNotAllowed) throw Oops.Bah("该材料不允许上传此文件类型"); var fileTooLarge = requirement.MaxFileSizeMb.HasValue && input.FileLength > requirement.MaxFileSizeMb.Value * 1024L * 1024L; if (fileTooLarge) throw Oops.Bah("文件超过允许的大小"); var materials = await _db.Queryable() .Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId) .ToListAsync(); if (input.TaskMaterialId.HasValue) { var replacementExists = materials.Any( item => item.Id == input.TaskMaterialId.Value); if (!replacementExists) throw Oops.Bah("需要重新上传的材料不存在"); return; } var canReplaceSupplement = materials.Any( item => item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement); if (materials.Count >= requirement.MaxFileCount && !canReplaceSupplement) throw Oops.Bah("该材料已达到最大上传数量"); } /// /// 后端再次比较员工勾选确认时的三项实名信息,防止绕过页面或确认后数据发生变化。 /// private static void EnsureIdentityInformationConfirmed( OnboardingSubmitInput input, ObTaskEmployeeInfo info) { if (!input.IdentityInformationConfirmed) throw Oops.Bah("请逐项确认姓名、身份证号码和手机号码无误"); var confirmedEmployeeName = input.ConfirmedEmployeeName?.Trim(); var confirmedIdCardNumber = input.ConfirmedIdCardNumber?.Trim().ToUpperInvariant(); var confirmedPhone = input.ConfirmedPhone?.Trim(); if (!string.Equals(confirmedEmployeeName, info.EmployeeName, StringComparison.Ordinal)) throw Oops.Bah("姓名在确认后发生了变化,请返回基本信息重新核对并确认"); if (!string.Equals(confirmedIdCardNumber, info.IdCardNumber, StringComparison.Ordinal)) throw Oops.Bah("身份证号码在确认后发生了变化,请返回基本信息重新核对并确认"); if (!string.Equals(confirmedPhone, info.Phone, StringComparison.Ordinal)) throw Oops.Bah("手机号码在确认后发生了变化,请返回基本信息重新核对并确认"); } /// /// 审核快照只记录实名信息摘要,完整原始值仍以本轮员工信息快照为准。 /// private static string BuildIdentityConfirmationHash(ObTaskEmployeeInfo info) { var value = $"{info.EmployeeName}\n{info.IdCardNumber}\n{info.Phone}"; var bytes = global::System.Text.Encoding.UTF8.GetBytes(value); return Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(bytes)); } private Task> GetMaterialRequirements(long taskId) { return _db.Queryable() .Where(item => item.TaskId == taskId) .OrderBy(item => item.SortCode) .ToListAsync(); } private async Task GetEditableTask(string token) { if (string.IsNullOrWhiteSpace(token)) throw Oops.Bah("入职链接 Token 不能为空"); var hash = Convert.ToHexString( global::System.Security.Cryptography.SHA256.HashData( global::System.Text.Encoding.UTF8.GetBytes(token))); var link = await _db.Queryable().FirstAsync(item => item.TokenHash == hash) ?? throw Oops.Bah("链接不存在"); var linkUnavailable = link.LinkStatus != OnboardingLinkStatus.Active || link.ExpiresAt <= DateTime.Now; if (linkUnavailable) throw Oops.Bah("链接已失效"); var task = await _db.Queryable().InSingleAsync(link.TaskId) ?? throw Oops.Bah("入职任务不存在"); var taskNotEditable = task.TaskStatus != OnboardingTaskStatus.PendingFill && task.TaskStatus != OnboardingTaskStatus.Rejected; if (taskNotEditable) throw Oops.Bah("当前任务不可编辑"); return task; } }