feat: 补充数据库脚本和配置
This commit is contained in:
@@ -21,6 +21,7 @@ public abstract class AppDbContext(DbContextOptions options, ICurrentUserAccesso
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ApplySoftDeleteQueryFilters(modelBuilder);
|
||||
modelBuilder.ApplyXmlComments();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using TakeoutSaaS.Infrastructure.Common.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 设计时 DbContext 工厂基类,提供统一的连接串与依赖替身。
|
||||
/// EF Core 设计时 DbContext 工厂基类,统一读取 appsettings 中的数据库配置。
|
||||
/// </summary>
|
||||
internal abstract class DesignTimeDbContextFactoryBase<TContext> : IDesignTimeDbContextFactory<TContext>
|
||||
where TContext : TenantAwareDbContext
|
||||
{
|
||||
private readonly string _connectionStringEnvVar;
|
||||
private readonly string _defaultDatabase;
|
||||
private readonly string _dataSourceName;
|
||||
private readonly string? _connectionStringEnvVar;
|
||||
|
||||
protected DesignTimeDbContextFactoryBase(string connectionStringEnvVar, string defaultDatabase)
|
||||
protected DesignTimeDbContextFactoryBase(string dataSourceName, string? connectionStringEnvVar = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataSourceName))
|
||||
{
|
||||
throw new ArgumentException("数据源名称不能为空。", nameof(dataSourceName));
|
||||
}
|
||||
|
||||
_dataSourceName = dataSourceName;
|
||||
_connectionStringEnvVar = connectionStringEnvVar;
|
||||
_defaultDatabase = defaultDatabase;
|
||||
}
|
||||
|
||||
public TContext CreateDbContext(string[] args)
|
||||
@@ -46,15 +54,91 @@ internal abstract class DesignTimeDbContextFactoryBase<TContext> : IDesignTimeDb
|
||||
|
||||
private string ResolveConnectionString()
|
||||
{
|
||||
var env = Environment.GetEnvironmentVariable(_connectionStringEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(env))
|
||||
if (!string.IsNullOrWhiteSpace(_connectionStringEnvVar))
|
||||
{
|
||||
return env;
|
||||
var envValue = Environment.GetEnvironmentVariable(_connectionStringEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(envValue))
|
||||
{
|
||||
return envValue;
|
||||
}
|
||||
}
|
||||
|
||||
return $"Host=localhost;Port=5432;Database={_defaultDatabase};Username=postgres;Password=postgres";
|
||||
var configuration = BuildConfiguration();
|
||||
var writeConnection = configuration[$"{DatabaseOptions.SectionName}:DataSources:{_dataSourceName}:Write"];
|
||||
if (string.IsNullOrWhiteSpace(writeConnection))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"未在配置中找到数据源 '{_dataSourceName}' 的 Write 连接字符串,请检查 appsettings 或设置 {_connectionStringEnvVar ?? "相应"} 环境变量。");
|
||||
}
|
||||
|
||||
return writeConnection;
|
||||
}
|
||||
|
||||
private static IConfigurationRoot BuildConfiguration()
|
||||
{
|
||||
var basePath = ResolveConfigurationDirectory();
|
||||
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
|
||||
|
||||
return new ConfigurationBuilder()
|
||||
.SetBasePath(basePath)
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
||||
.AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: false)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static string ResolveConfigurationDirectory()
|
||||
{
|
||||
var explicitDir = Environment.GetEnvironmentVariable("TAKEOUTSAAS_APPSETTINGS_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(explicitDir) && Directory.Exists(explicitDir))
|
||||
{
|
||||
return explicitDir;
|
||||
}
|
||||
|
||||
var currentDir = Directory.GetCurrentDirectory();
|
||||
var solutionRoot = LocateSolutionRoot(currentDir);
|
||||
|
||||
var candidateDirs = new[]
|
||||
{
|
||||
currentDir,
|
||||
solutionRoot,
|
||||
solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.AdminApi"),
|
||||
solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.UserApi"),
|
||||
solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.MiniApi")
|
||||
}.Where(dir => !string.IsNullOrWhiteSpace(dir));
|
||||
|
||||
foreach (var dir in candidateDirs)
|
||||
{
|
||||
if (dir != null && Directory.Exists(dir) && HasAppSettings(dir))
|
||||
{
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"未找到 appsettings 配置文件,请设置 TAKEOUTSAAS_APPSETTINGS_DIR 环境变量指向包含 appsettings*.json 的目录。");
|
||||
}
|
||||
|
||||
private static string? LocateSolutionRoot(string currentPath)
|
||||
{
|
||||
var directoryInfo = new DirectoryInfo(currentPath);
|
||||
while (directoryInfo != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directoryInfo.FullName, "TakeoutSaaS.sln")))
|
||||
{
|
||||
return directoryInfo.FullName;
|
||||
}
|
||||
|
||||
directoryInfo = directoryInfo.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasAppSettings(string directory) =>
|
||||
File.Exists(Path.Combine(directory, "appsettings.json")) ||
|
||||
Directory.GetFiles(directory, "appsettings.*.json").Length > 0;
|
||||
|
||||
private sealed class DesignTimeTenantProvider : ITenantProvider
|
||||
{
|
||||
public Guid GetCurrentTenantId() => Guid.Empty;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Applies XML documentation summaries to EF Core entities/columns as comments.
|
||||
/// </summary>
|
||||
internal static class ModelBuilderCommentExtensions
|
||||
{
|
||||
public static void ApplyXmlComments(this ModelBuilder modelBuilder)
|
||||
{
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
ApplyEntityComment(entityType);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyEntityComment(IMutableEntityType entityType)
|
||||
{
|
||||
var clrType = entityType.ClrType;
|
||||
if (clrType == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (XmlDocCommentProvider.TryGetSummary(clrType, out var typeComment))
|
||||
{
|
||||
entityType.SetComment(typeComment);
|
||||
}
|
||||
|
||||
foreach (var property in entityType.GetProperties())
|
||||
{
|
||||
var propertyInfo = property.PropertyInfo;
|
||||
if (propertyInfo == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (XmlDocCommentProvider.TryGetSummary(propertyInfo, out var propertyComment))
|
||||
{
|
||||
property.SetComment(propertyComment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class XmlDocCommentProvider
|
||||
{
|
||||
private static readonly ConcurrentDictionary<Assembly, IReadOnlyDictionary<string, string>> Cache = new();
|
||||
|
||||
public static bool TryGetSummary(MemberInfo member, out string? summary)
|
||||
{
|
||||
summary = null;
|
||||
var assembly = member switch
|
||||
{
|
||||
Type type => type.Assembly,
|
||||
_ => member.DeclaringType?.Assembly
|
||||
};
|
||||
|
||||
if (assembly == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var map = Cache.GetOrAdd(assembly, LoadComments);
|
||||
if (map.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = GetMemberKey(member);
|
||||
if (key == null || !map.TryGetValue(key, out var text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
summary = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> LoadComments(Assembly assembly)
|
||||
{
|
||||
var dictionary = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var xmlPath = Path.ChangeExtension(assembly.Location, ".xml");
|
||||
if (string.IsNullOrWhiteSpace(xmlPath) || !File.Exists(xmlPath))
|
||||
{
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
var document = XDocument.Load(xmlPath);
|
||||
foreach (var member in document.Descendants("member"))
|
||||
{
|
||||
var name = member.Attribute("name")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var summary = member.Element("summary")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(summary))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = Normalize(summary);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
dictionary[name] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private static string? GetMemberKey(MemberInfo member) =>
|
||||
member switch
|
||||
{
|
||||
Type type => $"T:{GetFullName(type)}",
|
||||
PropertyInfo property => $"P:{GetFullName(property.DeclaringType!)}.{property.Name}",
|
||||
FieldInfo field => $"F:{GetFullName(field.DeclaringType!)}.{field.Name}",
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string GetFullName(Type type) =>
|
||||
(type.FullName ?? type.Name).Replace('+', '.');
|
||||
|
||||
private static string Normalize(string text)
|
||||
{
|
||||
var chars = text.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ');
|
||||
return string.Join(' ', chars.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user