using System.Text.RegularExpressions; using SqlSugar; using Volo.Abp; using Yi.Framework.SqlSugarCore.Abstractions; namespace FoodLabeling.Th.Application.MultiTenancy; /// /// 租户业务库同步建库与 GRANT(建库候选优先级与 TenantService.CodeFirstForTenantAsync 一致)。 /// public static class TenantDatabaseBootstrapper { private static readonly Regex SafeDatabaseNameRegex = new(@"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$", RegexOptions.Compiled); /// /// 同步执行 CREATE DATABASE IF NOT EXISTS 并对业务账号 GRANT;失败抛 。 /// public static void EnsureDatabaseCreated( DbConnOptions dbConnOptions, DbType dbType, string tenantConnectionString, string databaseName) { if (string.IsNullOrWhiteSpace(databaseName) || !SafeDatabaseNameRegex.IsMatch(databaseName)) { throw new UserFriendlyException("租户连接串中的数据库名无效,无法建库"); } var createCandidates = BuildCreateDatabaseCandidates(dbConnOptions, tenantConnectionString); if (createCandidates.Count == 0) { throw new UserFriendlyException("未配置可用的数据库连接串,无法创建租户库"); } var createErrors = new List(); string? privilegedConnectionString = null; foreach (var candidate in createCandidates) { try { ExecuteCreateDatabase(dbType, candidate.ConnectionString, databaseName); privilegedConnectionString = candidate.ConnectionString; break; } catch (Exception ex) { createErrors.Add($"{candidate.Label}: {GetRootMessage(ex)}"); } } if (privilegedConnectionString == null) { var userId = ExtractConnectionValue(tenantConnectionString, "uid") ?? "netteam"; throw new UserFriendlyException( $"创建租户库失败(库={databaseName})。尝试结果:{string.Join(" | ", createErrors)}。" + $"说明:MySQL 报 Access denied to database 新建库名时,通常是账号没有 CREATE 权限(与能否连上主库无关)。" + $"请在 appsettings 的 DbConnOptions.AdminConnectionString 配置高权限账号连接串后重试;或手动执行:" + $"CREATE DATABASE IF NOT EXISTS `{databaseName}` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; " + $"GRANT ALL PRIVILEGES ON `{databaseName}`.* TO '{userId}'@'%'; " + $"再调用 initialize-tenant-database 补跑建表与 Seed"); } TryGrantTenantDatabaseAccess( dbType, privilegedConnectionString, databaseName, tenantConnectionString); } /// /// 建库连接候选(按优先级,互不相同才尝试下一条)。 /// internal static List<(string Label, string ConnectionString)> BuildCreateDatabaseCandidates( DbConnOptions dbConnOptions, string tenantConnectionString) { var createCandidates = new List<(string Label, string ConnectionString)>(); void AddCandidate(string label, string? conn) { if (string.IsNullOrWhiteSpace(conn)) { return; } if (createCandidates.Any(x => string.Equals(x.ConnectionString, conn, StringComparison.OrdinalIgnoreCase))) { return; } createCandidates.Add((label, conn)); } AddCandidate("AdminConnectionString", dbConnOptions.AdminConnectionString); AddCandidate("DbConnOptions.Url(主库)", dbConnOptions.Url); AddCandidate( "实例级(无database)", BuildServerLevelConnectionString( !string.IsNullOrWhiteSpace(dbConnOptions.Url) ? dbConnOptions.Url! : tenantConnectionString)); return createCandidates; } private static void ExecuteCreateDatabase(DbType dbType, string connectionString, string databaseName) { using var createDb = new SqlSugarClient(new ConnectionConfig { ConfigId = $"tenant-create-db-{databaseName}-{Guid.NewGuid():N}", DbType = dbType, ConnectionString = connectionString, IsAutoCloseConnection = true }); createDb.Ado.ExecuteCommand( $"CREATE DATABASE IF NOT EXISTS `{databaseName}` " + "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); } /// /// 授权业务账号访问新租户库;失败不抛(建表阶段仍可用高权限连接)。 /// private static void TryGrantTenantDatabaseAccess( DbType dbType, string privilegedConnectionString, string databaseName, string tenantConnectionString) { var appUserId = ExtractConnectionValue(tenantConnectionString, "uid"); var privilegedUserId = ExtractConnectionValue(privilegedConnectionString, "uid"); if (string.IsNullOrWhiteSpace(appUserId)) { return; } if (string.Equals(appUserId, privilegedUserId, StringComparison.OrdinalIgnoreCase)) { return; } try { using var grantDb = new SqlSugarClient(new ConnectionConfig { ConfigId = $"tenant-grant-{databaseName}-{Guid.NewGuid():N}", DbType = dbType, ConnectionString = privilegedConnectionString, IsAutoCloseConnection = true }); grantDb.Ado.ExecuteCommand( $"GRANT ALL PRIVILEGES ON `{databaseName}`.* TO '{EscapeSqlLiteral(appUserId)}'@'%'"); } catch { // GRANT 失败不阻断:后续 Init 仍可用高权限连接建表 } } private static string BuildServerLevelConnectionString(string connectionString) { var segments = connectionString .Split(';', StringSplitOptions.RemoveEmptyEntries) .Select(x => x.Trim()) .Where(x => !x.StartsWith("database=", StringComparison.OrdinalIgnoreCase) && !x.StartsWith("initial catalog=", StringComparison.OrdinalIgnoreCase)) .ToList(); return string.Join(';', segments) + ";"; } private static string GetRootMessage(Exception ex) { var current = ex; while (current.InnerException != null) { current = current.InnerException; } return current.Message; } private static string EscapeSqlLiteral(string value) => value.Replace("'", "''", StringComparison.Ordinal); private static string? ExtractConnectionValue(string connectionString, string key) { if (string.IsNullOrWhiteSpace(connectionString)) { return null; } var prefix = key + "="; foreach (var segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)) { var part = segment.Trim(); if (part.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { return part[prefix.Length..].Trim(); } } return null; } }