Files
OnboardXAdmin/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs
T
2026-09-18 16:08:14 +08:00

327 lines
15 KiB
C#

namespace SimpleAdmin.Application;
/// <summary>
/// 实现员工端动态资料、上传材料、合同确认和最终提交能力。
/// </summary>
public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService
{
private const int PublicMaterialMaxFileSizeMb = 20;
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;
}
/// <summary>
/// 关联已上传 OSS 文件与任务材料要求。
/// </summary>
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<ObTaskMaterialRequirement>()
.FirstAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
?? throw Oops.Bah("材料类型不属于当前入职模板");
var fileExists = await _db.Queryable<SysFile>().AnyAsync(item => item.Id == input.FileId);
if (!fileExists)
throw Oops.Bah("上传文件不存在");
var existingMaterials = await _db.Queryable<ObTaskMaterial>()
.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();
}
/// <summary>
/// 任务仍可编辑时删除一条已上传材料。
/// </summary>
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<ObTaskMaterial>()
.FirstAsync(item =>
item.Id == input.TaskMaterialId
&& item.TaskId == task.Id);
if (material == null)
throw Oops.Bah("上传材料不存在");
await _db.Deleteable<ObTaskMaterial>()
.In(material.Id)
.ExecuteCommandAsync();
}
/// <summary>
/// 记录候选人已经查看并确认动态生成的合同。
/// </summary>
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<ObTaskContract>()
.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();
}
/// <summary>
/// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。
/// </summary>
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<ObTaskEmployeeInfo>()
.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<ObTaskMaterial>()
.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<ObTaskContract>()
.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();
}
/// <summary>
/// 上传 OSS 前按照任务材料快照校验扩展名、文件大小和数量。
/// </summary>
public async Task ValidateMaterialUpload(OnboardingMaterialUploadValidationInput input)
{
var task = await GetEditableTask(input.Token);
var requirement = await _db.Queryable<ObTaskMaterialRequirement>()
.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 maxFileSizeMb = requirement.MaxFileSizeMb.HasValue
? Math.Min(requirement.MaxFileSizeMb.Value, PublicMaterialMaxFileSizeMb)
: PublicMaterialMaxFileSizeMb;
var fileTooLarge = input.FileLength > maxFileSizeMb * 1024L * 1024L;
if (fileTooLarge)
throw Oops.Bah($"单个文件不能超过{maxFileSizeMb}MB,请压缩后重新上传");
var materials = await _db.Queryable<ObTaskMaterial>()
.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("该材料已达到最大上传数量");
}
/// <summary>
/// 后端再次比较员工勾选确认时的三项实名信息,防止绕过页面或确认后数据发生变化。
/// </summary>
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("手机号码在确认后发生了变化,请返回基本信息重新核对并确认");
}
/// <summary>
/// 审核快照只记录实名信息摘要,完整原始值仍以本轮员工信息快照为准。
/// </summary>
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<List<ObTaskMaterialRequirement>> GetMaterialRequirements(long taskId)
{
return _db.Queryable<ObTaskMaterialRequirement>()
.Where(item => item.TaskId == taskId)
.OrderBy(item => item.SortCode)
.ToListAsync();
}
private async Task<ObOnboardingTask> 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<ObTaskLink>().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<ObOnboardingTask>().InSingleAsync(link.TaskId)
?? throw Oops.Bah("入职任务不存在");
var taskNotEditable = task.TaskStatus != OnboardingTaskStatus.PendingFill
&& task.TaskStatus != OnboardingTaskStatus.Rejected;
if (taskNotEditable)
throw Oops.Bah("当前任务不可编辑");
return task;
}
}