170 lines
7.0 KiB
C#
170 lines
7.0 KiB
C#
using System.Data.Common;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace SimpleAdmin.Application;
|
|
|
|
/// <summary>
|
|
/// 所有修改同一任务的入口共用 MySQL 命名锁,适用于同一主库上的多个后端实例。
|
|
/// 锁使用独立、非池化连接,不在第三方请求期间持有业务数据库事务。
|
|
/// </summary>
|
|
internal static class OnboardingConcurrency
|
|
{
|
|
public static Task RunTask(SqlSugarScope db, long taskId, Func<Task> action,
|
|
bool transactional = false, bool protectIdentity = false)
|
|
=> RunTask(db, taskId, async () => { await action(); return true; },
|
|
transactional, protectIdentity);
|
|
|
|
public static Task<T> RunTask<T>(SqlSugarScope db, long taskId, Func<Task<T>> 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<Task> action)
|
|
=> Run(db, $"employee:{employeeId}", async () => { await action(); return true; });
|
|
|
|
public static Task RunPublic(SqlSugarScope db, string token, Func<Task> action,
|
|
bool transactional = false, bool protectIdentity = false)
|
|
=> RunPublic(db, token, async () => { await action(); return true; },
|
|
transactional, protectIdentity);
|
|
|
|
public static async Task<T> RunPublic<T>(SqlSugarScope db, string token, Func<Task<T>> 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<ObTaskLink>().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<Task> action,
|
|
bool transactional = false)
|
|
=> RunContract(db, contractId, async () => { await action(); return true; }, transactional);
|
|
|
|
public static async Task<T> RunContract<T>(SqlSugarScope db, long contractId, Func<Task<T>> action,
|
|
bool transactional = false)
|
|
{
|
|
var contract = await db.Queryable<ObTaskContract>().InSingleAsync(contractId)
|
|
?? throw Oops.Bah("任务合同不存在");
|
|
return await RunTask(db, contract.TaskId, action, transactional);
|
|
}
|
|
|
|
public static async Task RunMaterial(SqlSugarScope db, long materialId, Func<Task> action)
|
|
{
|
|
var material = await db.Queryable<ObTaskMaterial>().InSingleAsync(materialId)
|
|
?? throw Oops.Bah("任务资料不存在");
|
|
await RunTask(db, material.TaskId, action, transactional: true);
|
|
}
|
|
|
|
private static async Task<T> RunPerson<T>(SqlSugarScope db, long taskId, Func<Task<T>> action)
|
|
{
|
|
var info = await db.Queryable<ObTaskEmployeeInfo>().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<T> InTransaction<T>(SqlSugarScope db, Func<Task<T>> 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<T> Run<T>(SqlSugarScope db, string resource, Func<Task<T>> 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<IAsyncDisposable> 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();
|
|
}
|
|
}
|
|
}
|
|
}
|