namespace SimpleAdmin.Application; /// /// 员工手机端公开入口,仅凭一次性任务链接访问入职资料。 /// public class OnboardingPublicTaskService : IOnboardingPublicTaskService { private readonly SqlSugarScope _db; private readonly IOnboardingContractSnapshotService _snapshotService; private readonly IOnboardingContractDocumentService _contractDocumentService; private readonly IOnboardingIdentityValidationService _identityValidationService; public OnboardingPublicTaskService( IOnboardingContractSnapshotService snapshotService, IOnboardingContractDocumentService contractDocumentService, IOnboardingIdentityValidationService identityValidationService) { _db = DbContext.DB; _snapshotService = snapshotService; _contractDocumentService = contractDocumentService; _identityValidationService = identityValidationService; } /// /// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。 /// public async Task Detail(OnboardingTaskPublicAccessInput input) { var link = await GetActiveLink(input.Token); var task = await _db.Queryable().InSingleAsync(link.TaskId) ?? throw Oops.Bah("入职任务不存在"); // 员工端必须展示当前 Token 的实际到期时间,不能依赖任务表中的冗余时间。 task.LinkExpireAt = link.ExpiresAt; link.AccessCount++; link.LastAccessedAt = DateTime.Now; await _db.Updateable(link).ExecuteCommandAsync(); var materialRequirements = await _db.Queryable() .Where(item => item.TaskId == task.Id) .OrderBy(item => item.SortCode) .ToListAsync(); var contracts = await _db.Queryable() .Where(item => item.TaskId == task.Id) .ToListAsync(); var materials = await _db.Queryable() .Where(item => item.TaskId == task.Id) .ToListAsync(); var materialFileIds = materials .Select(item => item.FileId) .Distinct() .ToList(); var materialFiles = materialFileIds.Count == 0 ? new List() : await _db.Queryable() .Where(item => materialFileIds.Contains(item.Id)) .ToListAsync(); var materialFileMap = materialFiles.ToDictionary(item => item.Id); var materialOutputs = materials.Select(item => { materialFileMap.TryGetValue(item.FileId, out var file); return new OnboardingPublicTaskMaterialOutput { Id = item.Id, MaterialTypeId = item.MaterialTypeId, MaterialName = item.MaterialName, FileId = item.FileId, FileName = file?.Name, FileSuffix = file?.Suffix, FileSizeInfo = file?.SizeInfo, UploadedAt = item.UploadedAt, ReviewStatus = item.ReviewStatus, ReviewRemark = item.ReviewRemark }; }).ToList(); var templateVersionIds = contracts .Select(item => item.TemplateVersionId) .Distinct() .ToList(); var contractVariables = new List(); if (templateVersionIds.Count > 0) { var employeeVariables = await _db.Queryable() .Where(item => templateVersionIds.Contains(item.TemplateVersionId) && item.VariableType == ContractVariableType.Employee) .OrderBy(item => item.SortCode) .ToListAsync(); contractVariables = employeeVariables.Select(item => new OnboardingPublicContractVariableOutput { VariableCode = item.VariableCode, VariableName = item.VariableName, VariableType = item.VariableType, DataType = item.DataType, DefaultValue = item.DefaultValue, OptionsJson = item.OptionsJson, Required = item.Required }).ToList(); } return new OnboardingTaskPublicDetailOutput { Task = task, EmployeeInfo = await _db.Queryable() .FirstAsync(item => item.TaskId == task.Id), Contracts = contracts, DynamicValues = new List(), Materials = materialOutputs, FormFields = new List(), MaterialRequirements = materialRequirements.Select(requirement => new OnboardingPublicMaterialOutput { MaterialTypeId = requirement.MaterialTypeId, MaterialName = requirement.MaterialName, AllowedExtensions = requirement.AllowedExtensions, MaxFileCount = requirement.MaxFileCount, MaxFileSizeMb = requirement.MaxFileSizeMb, Required = requirement.Required }).ToList(), ContractVariables = contractVariables }; } /// /// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。 /// public async Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input) { var link = await GetActiveLink(input.Token); var task = await _db.Queryable().InSingleAsync(link.TaskId) ?? throw Oops.Bah("入职任务不存在"); if (task.TaskStatus != OnboardingTaskStatus.PendingFill && task.TaskStatus != OnboardingTaskStatus.Rejected) { throw Oops.Bah("当前任务不可编辑"); } OnboardingEmployeeInfoValidator.ValidateAndNormalize(input); await _identityValidationService.EnsureNoDuplicate( task.Id, input.EmployeeName, input.IdCardNumber); var old = await _db.Queryable() .FirstAsync(item => item.TaskId == task.Id); var entity = input.Adapt(); entity.TaskId = task.Id; entity.IsDelete = false; if (old == null) { entity.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); entity.Status = CommonStatusConst.ENABLE; entity.CreateTime = DateTime.Now; entity.CreateUserId = null; entity.CreateUser = null; await _db.Insertable(entity).ExecuteCommandAsync(); } else { entity.Id = old.Id; entity.Status = old.Status; entity.CreateTime = old.CreateTime; entity.CreateUserId = old.CreateUserId; entity.CreateUser = old.CreateUser; entity.ExtJson = old.ExtJson; entity.IsDelete = old.IsDelete; entity.UpdateTime = DateTime.Now; entity.UpdateUserId = null; entity.UpdateUser = null; await _db.Updateable(entity).ExecuteCommandAsync(); } await _snapshotService.Build(task.Id, entity, invalidateGeneratedPreview: true); } /// /// 根据员工当前已填写信息校验变量并提前生成合同 DOCX。 /// public async Task PrepareContract( OnboardingPublicContractSourceInput input) { var link = await GetActiveLink(input.Token); var contract = await _db.Queryable() .FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == link.TaskId) ?? throw Oops.Bah("合同不存在"); // 员工资料保存时会清除旧文件 Id;仍有文件 Id 时还需确认文件记录没有被异常删除。 if (contract.GeneratedDocxFileId.HasValue) { var generatedFileExists = await _db.Queryable() .AnyAsync(item => item.Id == contract.GeneratedDocxFileId.Value); if (!generatedFileExists) throw Oops.Bah("已生成的合同文件不存在,请联系管理员重新生成"); return new OnboardingPublicContractPrepareOutput { FileId = contract.GeneratedDocxFileId.Value, GeneratedAt = contract.GeneratedAt }; } await _snapshotService.Build(link.TaskId); var fileId = await _contractDocumentService.Generate(new OnboardingGenerateContractInput { TaskContractId = contract.Id }); var generatedAt = await _db.Queryable() .Where(item => item.Id == contract.Id) .Select(item => item.GeneratedAt) .SingleAsync(); return new OnboardingPublicContractPrepareOutput { FileId = fileId, GeneratedAt = generatedAt }; } /// /// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。 /// public async Task GetContractSourceFileId(OnboardingPublicContractSourceInput input) { var link = await GetActiveLink(input.Token); var contract = await _db.Queryable() .FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == link.TaskId) ?? throw Oops.Bah("合同不存在"); var fileId = contract.GeneratedDocxFileId; if (!fileId.HasValue) { // 下载入口增加服务端兜底,避免前端状态与数据库短暂不同步时打开 JSON 错误页。 var prepared = await PrepareContract(input); fileId = prepared.FileId; } var fileExists = await _db.Queryable() .AnyAsync(item => item.Id == fileId.Value); if (!fileExists) throw Oops.Bah("合同文件不存在,请重新准备合同"); contract.ReadAt ??= DateTime.Now; if (contract.ContractStatus == TaskContractStatus.PendingGenerate) contract.ContractStatus = TaskContractStatus.PendingConfirm; await _db.Updateable(contract) .UpdateColumns(item => new { item.ReadAt, item.ContractStatus }) .ExecuteCommandAsync(); return fileId.Value; } /// /// 校验一次性链接与材料归属,避免通过文件 Id 越权读取其他任务文件。 /// public async Task GetMaterialSource( OnboardingPublicMaterialSourceInput input) { var link = await GetActiveLink(input.Token); var material = await _db.Queryable() .FirstAsync(item => item.Id == input.TaskMaterialId && item.TaskId == link.TaskId) ?? throw Oops.Bah("上传材料不存在"); var file = await _db.Queryable().InSingleAsync(material.FileId) ?? throw Oops.Bah("上传文件不存在"); return new OnboardingPublicTaskMaterialOutput { Id = material.Id, MaterialTypeId = material.MaterialTypeId, MaterialName = material.MaterialName, FileId = material.FileId, FileName = file.Name, FileSuffix = file.Suffix, FileSizeInfo = file.SizeInfo, UploadedAt = material.UploadedAt, ReviewStatus = material.ReviewStatus, ReviewRemark = material.ReviewRemark }; } private async Task GetActiveLink(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("链接不存在"); if (link.LinkStatus != OnboardingLinkStatus.Active || link.ExpiresAt <= DateTime.Now) throw Oops.Bah("链接已失效"); return link; } }