Enhance installation process with environment variable support and dotenv loading for development
This commit is contained in:
parent
8756010173
commit
db39a6ac4c
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||
@ -29,6 +29,19 @@
|
||||
- `--no-prompt`: 不使用互動輸入(CI/CD)
|
||||
- `--verbose`: 詳細輸出
|
||||
|
||||
### 環境變數(建議用於部署)
|
||||
- `ConnectionStrings__Default`: 主要連線字串(優先)
|
||||
- `MEMBERCENTER_CONNECTION`: 備用連線字串
|
||||
|
||||
若在開發環境(`ASPNETCORE_ENVIRONMENT=Development` 或 `DOTNET_ENVIRONMENT=Development`),
|
||||
可以建立 `.env` 檔案,installer 與 API 會在啟動時讀取(僅限開發環境)。
|
||||
建議在 `.env` 內加入:
|
||||
|
||||
```
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||
```
|
||||
|
||||
### 1) `installer init`
|
||||
用途:首次安裝(含 migrations + seed + superuser)
|
||||
|
||||
@ -42,6 +55,7 @@
|
||||
1) 解析連線字串(參數或 appsettings)
|
||||
- 若提供 `--connection-string`,會寫入 appsettings
|
||||
- 若 appsettings 中缺少連線字串,會互動式詢問並寫入
|
||||
- 若設定環境變數,會優先使用環境變數(不寫入 appsettings)
|
||||
2) 執行 migrations(不 Drop)
|
||||
3) 建立 roles(admin, support)
|
||||
4) 建立 admin(不存在才建立)並加入 admin 角色
|
||||
@ -84,3 +98,14 @@
|
||||
- 密碼必須符合強度規則
|
||||
- 初次安裝完成後,禁用安裝入口或限內網
|
||||
- 安裝過程需紀錄 audit log
|
||||
|
||||
## Docker / 部署建議
|
||||
- 建議用環境變數提供連線字串(避免重建 container 後設定遺失)
|
||||
- 範例:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
ConnectionStrings__Default: "Host=postgres;Database=member_center;Username=postgres;Password=postgres"
|
||||
```
|
||||
|
||||
- 若仍要用 `appsettings.json`,請用 volume 維持設定檔
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
## 核心資源
|
||||
- OAuth2/OIDC:授權、token、discovery、JWKS
|
||||
- Auth:註冊、登入、刷新、登出、忘記/重設密碼、Email 驗證
|
||||
- Auth:註冊、登入(password grant)、刷新、登出、忘記/重設密碼、Email 驗證
|
||||
- User:個人資料
|
||||
- Newsletter:訂閱/確認/退訂/偏好
|
||||
- Admin:Tenants/Lists/OAuth Clients(MVP CRUD)
|
||||
@ -16,3 +16,8 @@
|
||||
## Security Schemes
|
||||
- OAuth2 (Authorization Code + PKCE)
|
||||
- Bearer JWT(API 使用)
|
||||
|
||||
## 補充說明
|
||||
- `/oauth/token`、`/auth/login`、`/auth/refresh` 使用 `application/x-www-form-urlencoded`
|
||||
- `/auth/email/verify` 需要 `token` + `email`
|
||||
- `/newsletter/subscribe` 會回傳 `confirm_token`
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using MemberCenter.Infrastructure.Configuration;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -5,6 +6,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
|
||||
EnvLoader.LoadDotEnvIfDevelopment();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddDbContext<MemberCenterDbContext>(options =>
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"Default": "Host=localhost;Database=member_center;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
100
src/MemberCenter.Infrastructure/Configuration/EnvLoader.cs
Normal file
100
src/MemberCenter.Infrastructure/Configuration/EnvLoader.cs
Normal file
@ -0,0 +1,100 @@
|
||||
namespace MemberCenter.Infrastructure.Configuration;
|
||||
|
||||
public static class EnvLoader
|
||||
{
|
||||
public static void LoadDotEnvIfDevelopment(string? startDirectory = null)
|
||||
{
|
||||
var path = FindDotEnvPath(startDirectory ?? Directory.GetCurrentDirectory());
|
||||
if (path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
|
||||
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(environment))
|
||||
{
|
||||
if (!string.Equals(environment, "Development", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (!DotEnvDeclaresDevelopment(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in ReadDotEnvPairs(path))
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable(key) is null)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindDotEnvPath(string startDirectory)
|
||||
{
|
||||
var directory = new DirectoryInfo(startDirectory);
|
||||
for (var depth = 0; depth < 6 && directory is not null; depth++)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, ".env");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool DotEnvDeclaresDevelopment(string path)
|
||||
{
|
||||
foreach (var (key, value) in ReadDotEnvPairs(path))
|
||||
{
|
||||
if ((key.Equals("ASPNETCORE_ENVIRONMENT", StringComparison.OrdinalIgnoreCase)
|
||||
|| key.Equals("DOTNET_ENVIRONMENT", StringComparison.OrdinalIgnoreCase))
|
||||
&& value.Equals("Development", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Key, string Value)> ReadDotEnvPairs(string path)
|
||||
{
|
||||
foreach (var line in File.ReadAllLines(path))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separatorIndex = trimmed.IndexOf('=');
|
||||
if (separatorIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = trimmed[..separatorIndex].Trim();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = trimmed[(separatorIndex + 1)..].Trim();
|
||||
if ((value.StartsWith('\"') && value.EndsWith('\"')) || (value.StartsWith('\'') && value.EndsWith('\'')))
|
||||
{
|
||||
value = value[1..^1];
|
||||
}
|
||||
|
||||
yield return (key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Configuration;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -6,10 +7,14 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.CommandLine;
|
||||
|
||||
EnvLoader.LoadDotEnvIfDevelopment();
|
||||
|
||||
var root = new RootCommand("Member Center installer");
|
||||
|
||||
var connectionStringOption = new Option<string>(
|
||||
@ -279,6 +284,7 @@ return await root.InvokeAsync(args);
|
||||
static IServiceProvider BuildServices(string connectionString)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddConsole());
|
||||
services.AddDbContext<MemberCenterDbContext>(options =>
|
||||
{
|
||||
options.UseNpgsql(connectionString);
|
||||
@ -302,7 +308,14 @@ static IServiceProvider BuildServices(string connectionString)
|
||||
|
||||
static string? ResolveConnectionString(string? connectionString, string? appsettingsPath, bool noPrompt)
|
||||
{
|
||||
var targetPath = string.IsNullOrWhiteSpace(appsettingsPath) ? "appsettings.json" : appsettingsPath;
|
||||
var targetPath = ResolveAppsettingsPath(appsettingsPath);
|
||||
|
||||
var envConnection = Environment.GetEnvironmentVariable("ConnectionStrings__Default")
|
||||
?? Environment.GetEnvironmentVariable("MEMBERCENTER_CONNECTION");
|
||||
if (!string.IsNullOrWhiteSpace(envConnection))
|
||||
{
|
||||
return envConnection;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
@ -313,7 +326,7 @@ static string? ResolveConnectionString(string? connectionString, string? appsett
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile(targetPath)
|
||||
.AddJsonFile(targetPath, optional: true)
|
||||
.Build();
|
||||
var existing = config.GetConnectionString("Default");
|
||||
if (!string.IsNullOrWhiteSpace(existing))
|
||||
@ -336,6 +349,22 @@ static string? ResolveConnectionString(string? connectionString, string? appsett
|
||||
return generated;
|
||||
}
|
||||
|
||||
static string ResolveAppsettingsPath(string? appsettingsPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(appsettingsPath))
|
||||
{
|
||||
return appsettingsPath;
|
||||
}
|
||||
|
||||
var apiDefault = Path.Combine("src", "MemberCenter.Api", "appsettings.json");
|
||||
if (File.Exists(apiDefault))
|
||||
{
|
||||
return apiDefault;
|
||||
}
|
||||
|
||||
return "appsettings.json";
|
||||
}
|
||||
|
||||
static void WriteConnectionString(string path, string connectionString)
|
||||
{
|
||||
JsonNode root;
|
||||
@ -360,8 +389,15 @@ static void WriteConnectionString(string path, string connectionString)
|
||||
|
||||
static async Task<bool> IsInstalledAsync(MemberCenterDbContext db)
|
||||
{
|
||||
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
|
||||
return flag is not null && string.Equals(flag.Value, "true", StringComparison.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
|
||||
return flag is not null && string.Equals(flag.Value, "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (PostgresException ex) when (ex.SqlState == "42P01")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task SetInstalledFlagAsync(MemberCenterDbContext db)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user