using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Http.Timeouts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Data;
using Volo.Abp.Modularity;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Uow;
using Yi.Framework.Ddd.Application;
using Yi.Framework.SqlSugarCore.Abstractions;
using Yi.Framework.TenantManagement.Application.Contracts;
using Yi.Framework.TenantManagement.Application.Contracts.Dtos;
using Yi.Framework.TenantManagement.Domain;
namespace Yi.Framework.TenantManagement.Application
{
///
/// 租户管理
///
public class TenantService :
YiCrudAppService, ITenantService
{
private readonly ISqlSugarRepository _repository;
private readonly IDataSeeder _dataSeeder;
private readonly DbConnOptions _dbConnOptions;
public TenantService(
ISqlSugarRepository repository,
IDataSeeder dataSeeder,
IOptions dbConnOptions) :
base(repository)
{
_repository = repository;
_dataSeeder = dataSeeder;
_dbConnOptions = dbConnOptions.Value;
}
///
/// YiTenant 标记了 ,读写必须走主库(CurrentTenant=null → DbConnOptions.Url),
/// 不能随请求租户上下文落到 Default 租户的业务库。
///
private IDisposable UseHostTenantScope() => CurrentTenant.Change(null);
///
/// 用主库连接串直连读取 YiTenant,避免 UoW 事务 + IsAutoCloseConnection 在后台 Init 时 NRE。
///
private async Task LoadTenantFromHostAsync(Guid id)
{
if (string.IsNullOrWhiteSpace(_dbConnOptions.Url))
{
throw new UserFriendlyException("未配置主库连接字符串 DbConnOptions.Url");
}
var dbType = _dbConnOptions.DbType ?? DbType.MySql;
using var client = new SqlSugarClient(new ConnectionConfig
{
ConfigId = $"host-yitenant-read-{Guid.NewGuid():N}",
DbType = dbType,
ConnectionString = _dbConnOptions.Url,
IsAutoCloseConnection = true
});
var tenant = await client.Queryable().InSingleAsync(id);
return tenant is { IsDeleted: true } ? null : tenant;
}
///
/// 租户单查
///
///
///
public override async Task GetAsync(Guid id)
{
using (UseHostTenantScope())
{
return await base.GetAsync(id);
}
}
///
/// 租户多查
///
///
///
public override async Task> GetListAsync(TenantGetListInput input)
{
using (UseHostTenantScope())
{
RefAsync total = 0;
var entities = await _repository._DbQueryable
.WhereIF(!string.IsNullOrEmpty(input.Name), x => x.Name.Contains(input.Name!))
.WhereIF(input.StartTime is not null && input.EndTime is not null,
x => x.CreationTime >= input.StartTime && x.CreationTime <= input.EndTime)
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
return new PagedResultDto(total, await MapToGetListOutputDtosAsync(entities));
}
}
///
/// 租户选项
///
///
public async Task> GetSelectAsync()
{
using (UseHostTenantScope())
{
var entites = await _repository._DbQueryable.ToListAsync();
return entites.Select(x => new TenantSelectOutputDto { Id = x.Id, Name = x.Name }).ToList();
}
}
///
/// 创建租户
///
///
///
public override async Task CreateAsync(TenantCreateInput input)
{
using (UseHostTenantScope())
{
if (await _repository.IsAnyAsync(x => x.Name == input.Name))
{
throw new UserFriendlyException("创建失败,当前租户已存在");
}
return await base.CreateAsync(input);
}
}
///
/// 更新租户
///
///
///
///
public override async Task UpdateAsync(Guid id, TenantUpdateInput input)
{
using (UseHostTenantScope())
{
if (await _repository.IsAnyAsync(x => x.Name == input.Name && x.Id != id))
{
throw new UserFriendlyException("更新后租户名已经存在");
}
return await base.UpdateAsync(id, input);
}
}
///
/// 租户删除
///
///
///
public override async Task DeleteAsync(IEnumerable id)
{
using (UseHostTenantScope())
{
await base.DeleteAsync(id);
}
}
///
/// 初始化租户
///
///
///
[HttpPut("tenant/init/{id}")]
[RequestTimeout("ThTenantDatabaseInit")]
[UnitOfWork(isTransactional: false)]
public async Task InitAsync([FromRoute] Guid id)
{
TenantAggregateRoot tenant;
using (UseHostTenantScope())
{
tenant = await LoadTenantFromHostAsync(id);
if (tenant == null)
{
throw new UserFriendlyException("未找到租户信息");
}
}
// 用主库读到的连接串直接建库/表,不依赖 CurrentTenant.Change(id) 解析仓储连接
await CodeFirstForTenantAsync(tenant, LazyServiceProvider);
using (CurrentTenant.Change(id))
{
await _dataSeeder.SeedAsync(id);
}
}
///
/// 在租户业务库执行 CodeFirst(连接串来自主库 YiTenant 记录)。
/// 步骤:无库名连接建库 → 新连接连目标库建表(建库后必须重连,同会话 USE 在部分 MySQL/RDS 会 Access denied)。
///
private async Task CodeFirstForTenantAsync(TenantAggregateRoot tenant, IServiceProvider service)
{
var moduleContainer = service.GetRequiredService();
List types = new List();
foreach (var module in moduleContainer.Modules)
{
types.AddRange(module.Assembly.GetTypes()
.Where(x => x.GetCustomAttribute() == null)
.Where(x => x.GetCustomAttribute() != null)
.Where(x => x.GetCustomAttribute() is null)
.Where(x => x.GetCustomAttribute() is null));
}
var databaseName = ExtractDatabaseName(tenant.TenantConnectionString);
if (string.IsNullOrWhiteSpace(databaseName) || !SafeDatabaseNameRegex.IsMatch(databaseName))
{
throw new UserFriendlyException("租户连接串中的数据库名无效,无法建库");
}
// 建库连接候选(按优先级,互不相同才尝试下一条):
// 1) AdminConnectionString 高权限(可选)
// 2) 主库 Url(带 database=host,RDS 上多数账号必须先进入已授权库才能执行 CREATE)
// 3) 无 database= 的实例级连接(与部分 Navicat 用法一致)
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!
: tenant.TenantConnectionString));
if (createCandidates.Count == 0)
{
throw new UserFriendlyException("未配置可用的数据库连接串,无法创建租户库");
}
var externalServices = new ConfigureExternalServices
{
EntityNameService = (type, entity) =>
{
if (_dbConnOptions.EnableUnderLine && !entity.DbTableName.Contains('_'))
{
entity.DbTableName = UtilMethods.ToUnderLine(entity.DbTableName);
}
},
EntityService = (propertyInfo, columnInfo) =>
{
// 与 SqlSugarDbContextFactory 一致:可空引用类型映射为可空列,避免 Seed 插入 null 失败
if (new NullabilityInfoContext().Create(propertyInfo).WriteState
is NullabilityState.Nullable)
{
columnInfo.IsNullable = true;
}
if (_dbConnOptions.EnableUnderLine && !columnInfo.IsIgnore
&& !columnInfo.DbColumnName.Contains('_'))
{
columnInfo.DbColumnName = UtilMethods.ToUnderLine(columnInfo.DbColumnName);
}
}
};
// 1) CREATE DATABASE:依次尝试候选连接
var createErrors = new List();
string? privilegedConnectionString = null;
foreach (var candidate in createCandidates)
{
try
{
ExecuteCreateDatabase(tenant.DbType, candidate.ConnectionString, databaseName);
privilegedConnectionString = candidate.ConnectionString;
break;
}
catch (Exception ex)
{
createErrors.Add($"{candidate.Label}: {GetRootMessage(ex)}");
}
}
if (privilegedConnectionString == null)
{
var userId = ExtractConnectionValue(tenant.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}'@'%'; " +
$"再 PUT /api/app/tenant/init/{tenant.Id}");
}
// 2) 把业务账号(TenantConnectionString 的 uid,如 netteam)授权到新库
// 建库常用高权限账号(Admin / javateam),否则业务账号无 CREATE TABLE 权限
var grantWarnings = TryGrantTenantDatabaseAccess(
tenant.DbType,
privilegedConnectionString,
databaseName,
tenant.TenantConnectionString);
// 3) 建表:优先业务连接串;失败则用建库成功的高权限连接串切到目标库再建表
try
{
InitTenantTables(
tenant.Id,
tenant.DbType,
tenant.TenantConnectionString,
types,
externalServices);
}
catch (Exception tenantConnEx)
{
var adminTenantConn = ReplaceConnectionDatabase(privilegedConnectionString, databaseName);
try
{
InitTenantTables(
tenant.Id,
tenant.DbType,
adminTenantConn,
types,
externalServices);
}
catch (Exception adminConnEx)
{
var userId = ExtractConnectionValue(tenant.TenantConnectionString, "uid") ?? "netteam";
throw new UserFriendlyException(
$"租户库 `{databaseName}` 已存在,但建表失败。" +
$"业务账号建表:{GetRootMessage(tenantConnEx)};" +
$"高权限建表:{GetRootMessage(adminConnEx)}。" +
(string.IsNullOrEmpty(grantWarnings) ? "" : $"授权提示:{grantWarnings}。") +
$"请手动执行 GRANT ALL PRIVILEGES ON `{databaseName}`.* TO '{userId}'@'%'; 后 " +
$"PUT /api/app/tenant/init/{tenant.Id}");
}
}
await Task.CompletedTask;
}
private static readonly Regex SafeDatabaseNameRegex =
new(@"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$", RegexOptions.Compiled);
private static string GetRootMessage(Exception ex)
{
var current = ex;
while (current.InnerException != null)
{
current = current.InnerException;
}
return current.Message;
}
///
/// 去掉 database=,使连接为「实例级」,便于执行 CREATE DATABASE(与 Navicat 无默认库一致)。
///
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 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 string TryGrantTenantDatabaseAccess(
DbType dbType,
string privilegedConnectionString,
string databaseName,
string tenantConnectionString)
{
var appUserId = ExtractConnectionValue(tenantConnectionString, "uid");
var privilegedUserId = ExtractConnectionValue(privilegedConnectionString, "uid");
if (string.IsNullOrWhiteSpace(appUserId))
{
return "租户连接串缺少 uid,跳过 GRANT";
}
// 建库账号与业务账号相同,通常无需再 GRANT
if (string.Equals(appUserId, privilegedUserId, StringComparison.OrdinalIgnoreCase))
{
return string.Empty;
}
try
{
using var grantDb = new SqlSugarClient(new ConnectionConfig
{
ConfigId = $"tenant-grant-{databaseName}-{Guid.NewGuid():N}",
DbType = dbType,
ConnectionString = privilegedConnectionString,
IsAutoCloseConnection = true
});
// 阿里云 RDS:对业务账号授权新库全部权限
grantDb.Ado.ExecuteCommand(
$"GRANT ALL PRIVILEGES ON `{databaseName}`.* TO '{EscapeSqlLiteral(appUserId)}'@'%'");
return string.Empty;
}
catch (Exception ex)
{
return GetRootMessage(ex);
}
}
private static void InitTenantTables(
Guid tenantId,
DbType dbType,
string connectionString,
List types,
ConfigureExternalServices externalServices)
{
using var db = new SqlSugarClient(new ConnectionConfig
{
ConfigId = $"tenant-init-tables-{tenantId}-{Guid.NewGuid():N}",
DbType = dbType,
ConnectionString = connectionString,
IsAutoCloseConnection = true,
ConfigureExternalServices = externalServices
});
db.Ado.ExecuteCommand("SELECT 1");
// 历史误用精简 DbModel(如 RoleDbEntity)建出的残缺表,InitTables 不会补列,需先删再重建
DropIncompleteRbacTablesIfNeeded(db);
if (types.Count > 0)
{
db.CodeFirst.InitTables(types.ToArray());
}
}
///
/// 若 Role 等表缺少 CreationTime,说明曾被精简映射建坏,删除后由完整实体重建。
///
private static void DropIncompleteRbacTablesIfNeeded(ISqlSugarClient db)
{
foreach (var tableName in new[] { "Role", "role", "RoleMenu", "rolemenu", "Menu", "menu" })
{
if (!db.DbMaintenance.IsAnyTable(tableName, false))
{
continue;
}
var columns = db.DbMaintenance.GetColumnInfosByTableName(tableName, false);
var hasCreationTime = columns.Any(c =>
string.Equals(c.DbColumnName, "CreationTime", StringComparison.OrdinalIgnoreCase));
// Role 精简模型只有 Id/IsDeleted/RoleCode/RoleName,缺 CreationTime
if (string.Equals(tableName, "Role", StringComparison.OrdinalIgnoreCase) && !hasCreationTime)
{
db.DbMaintenance.DropTable(tableName);
continue;
}
// RoleMenu 精简模型无业务外键类型问题较少,但若列过少也重建
if (string.Equals(tableName, "RoleMenu", StringComparison.OrdinalIgnoreCase)
&& columns.Count < 3)
{
db.DbMaintenance.DropTable(tableName);
}
}
}
private static string EscapeSqlLiteral(string value)
=> value.Replace("'", "''", StringComparison.Ordinal);
private static string? ExtractDatabaseName(string connectionString)
=> ExtractConnectionValue(connectionString, "database");
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;
}
private static string ReplaceConnectionDatabase(string connectionString, string databaseName)
{
var segments = connectionString
.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !x.StartsWith("database=", StringComparison.OrdinalIgnoreCase)
&& !x.StartsWith("initial catalog=", StringComparison.OrdinalIgnoreCase))
.ToList();
segments.Add($"database={databaseName}");
return string.Join(';', segments) + ";";
}
}
}