using System.Data.Common; using System.Security.Cryptography; using System.Text; namespace SimpleAdmin.Application; /// /// 所有修改同一任务的入口共用 MySQL 命名锁,适用于同一主库上的多个后端实例。 /// 锁使用独立、非池化连接,不在第三方请求期间持有业务数据库事务。 /// internal static class OnboardingConcurrency { public static Task RunTask(SqlSugarScope db, long taskId, Func action, bool transactional = false, bool protectIdentity = false) => RunTask(db, taskId, async () => { await action(); return true; }, transactional, protectIdentity); public static Task RunTask(SqlSugarScope db, long taskId, Func> action, bool transactional = false, bool protectIdentity = false) => Run(db, $"task:{taskId}", () => protectIdentity ? RunPerson(db, taskId, () => InTransaction(db, action, transactional)) : InTransaction(db, action, transactional)); public static Task RunEmployee(SqlSugarScope db, long employeeId, Func action) => Run(db, $"employee:{employeeId}", async () => { await action(); return true; }); public static Task RunPublic(SqlSugarScope db, string token, Func action, bool transactional = false, bool protectIdentity = false) => RunPublic(db, token, async () => { await action(); return true; }, transactional, protectIdentity); public static async Task RunPublic(SqlSugarScope db, string token, Func> action, bool transactional = false, bool protectIdentity = false) { if (string.IsNullOrWhiteSpace(token)) throw Oops.Bah("入职链接 Token 不能为空"); var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); var link = await db.Queryable().FirstAsync(item => item.TokenHash == hash) ?? throw Oops.Bah("链接不存在"); // 此处只定位锁;被保护的方法必须在锁内重新检查链接、任务状态和归属。 return await RunTask(db, link.TaskId, action, transactional, protectIdentity); } public static Task RunContract(SqlSugarScope db, long contractId, Func action, bool transactional = false) => RunContract(db, contractId, async () => { await action(); return true; }, transactional); public static async Task RunContract(SqlSugarScope db, long contractId, Func> action, bool transactional = false) { var contract = await db.Queryable().InSingleAsync(contractId) ?? throw Oops.Bah("任务合同不存在"); return await RunTask(db, contract.TaskId, action, transactional); } public static async Task RunMaterial(SqlSugarScope db, long materialId, Func action) { var material = await db.Queryable().InSingleAsync(materialId) ?? throw Oops.Bah("任务资料不存在"); await RunTask(db, material.TaskId, action, transactional: true); } private static async Task RunPerson(SqlSugarScope db, long taskId, Func> action) { var info = await db.Queryable().FirstAsync(item => item.TaskId == taskId) ?? throw Oops.Bah("请先完整填写基本信息"); // 统一顺序:任务 -> 身份证 -> 手机;跨任务提交/建档的查重与写入也串行。 return await Run(db, "identity:" + info.IdCardNumber?.Trim().ToUpperInvariant(), () => Run(db, "phone:" + info.Phone?.Trim(), action)); } private static async Task InTransaction(SqlSugarScope db, Func> action, bool enabled) { if (!enabled || db.Ado.Transaction != null) return await action(); await db.Ado.BeginTranAsync(); try { var result = await action(); await db.Ado.CommitTranAsync(); return result; } catch { await db.Ado.RollbackTranAsync(); throw; } } private static Task Run(SqlSugarScope db, string resource, Func> action) { if (db.CurrentConnectionConfig.DbType != global::SqlSugar.DbType.MySql) throw Oops.Bah("入职并发保护需要 MySQL 数据库"); // 使用已选择的业务主库,不能在只读副本/不同主库上分别申请锁。 var database = db.Ado.Connection.Database; var key = Convert.ToHexString(SHA256.HashData( Encoding.UTF8.GetBytes($"onboardx:{database}:{resource}"))); return OnboardingOperationGate.Run(key, () => Acquire(db, key), action); } private static async Task Acquire(SqlSugarScope db, string key) { var connection = (DbConnection)Activator.CreateInstance(db.Ado.Connection.GetType()); var builder = new DbConnectionStringBuilder { ConnectionString = db.CurrentConnectionConfig.ConnectionString }; // 确保异常关闭时会真正关闭持锁会话,不把命名锁留在连接池里。 builder["Pooling"] = false; connection.ConnectionString = builder.ConnectionString; try { await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = "SELECT GET_LOCK(@lockName, 0)"; command.CommandTimeout = 5; AddKey(command, key); var result = await command.ExecuteScalarAsync(); if (result == null || result == DBNull.Value || Convert.ToInt32(result) != 1) throw Oops.Bah("当前数据正在处理,请稍后刷新重试"); return new NamedLock(connection, key); } catch { await connection.DisposeAsync(); throw; } } private static void AddKey(DbCommand command, string key) { var parameter = command.CreateParameter(); parameter.ParameterName = "@lockName"; parameter.Value = key; command.Parameters.Add(parameter); } private sealed class NamedLock : IAsyncDisposable { private readonly DbConnection _connection; private readonly string _key; public NamedLock(DbConnection connection, string key) { _connection = connection; _key = key; } public async ValueTask DisposeAsync() { try { await using var command = _connection.CreateCommand(); command.CommandText = "SELECT RELEASE_LOCK(@lockName)"; command.CommandTimeout = 5; AddKey(command, _key); await command.ExecuteScalarAsync(); } catch { // 非池化物理连接关闭会释放会话锁;释放错误不覆盖业务原异常。 } finally { await _connection.DisposeAsync(); } } } }