feat: 补充数据库脚本和配置

This commit is contained in:
贺爱泽
2025-12-01 18:16:49 +08:00
parent 84ac31158c
commit 15fc000cfc
37 changed files with 42829 additions and 448 deletions

View File

@@ -21,6 +21,7 @@ public abstract class AppDbContext(DbContextOptions options, ICurrentUserAccesso
{
base.OnModelCreating(modelBuilder);
ApplySoftDeleteQueryFilters(modelBuilder);
modelBuilder.ApplyXmlComments();
}
/// <summary>

View File

@@ -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;

View File

@@ -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));
}
}
}