Enhance installation process with environment variable support and dotenv loading for development

This commit is contained in:
warrenchen 2026-02-03 16:48:25 +09:00
parent 8756010173
commit db39a6ac4c
7 changed files with 176 additions and 8 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres

View File

@ -29,6 +29,19 @@
- `--no-prompt`: 不使用互動輸入CI/CD - `--no-prompt`: 不使用互動輸入CI/CD
- `--verbose`: 詳細輸出 - `--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` ### 1) `installer init`
用途:首次安裝(含 migrations + seed + superuser 用途:首次安裝(含 migrations + seed + superuser
@ -42,6 +55,7 @@
1) 解析連線字串(參數或 appsettings 1) 解析連線字串(參數或 appsettings
- 若提供 `--connection-string`,會寫入 appsettings - 若提供 `--connection-string`,會寫入 appsettings
- 若 appsettings 中缺少連線字串,會互動式詢問並寫入 - 若 appsettings 中缺少連線字串,會互動式詢問並寫入
- 若設定環境變數,會優先使用環境變數(不寫入 appsettings
2) 執行 migrations不 Drop 2) 執行 migrations不 Drop
3) 建立 rolesadmin, support 3) 建立 rolesadmin, support
4) 建立 admin不存在才建立並加入 admin 角色 4) 建立 admin不存在才建立並加入 admin 角色
@ -84,3 +98,14 @@
- 密碼必須符合強度規則 - 密碼必須符合強度規則
- 初次安裝完成後,禁用安裝入口或限內網 - 初次安裝完成後,禁用安裝入口或限內網
- 安裝過程需紀錄 audit log - 安裝過程需紀錄 audit log
## Docker / 部署建議
- 建議用環境變數提供連線字串(避免重建 container 後設定遺失)
- 範例:
```yaml
environment:
ConnectionStrings__Default: "Host=postgres;Database=member_center;Username=postgres;Password=postgres"
```
- 若仍要用 `appsettings.json`,請用 volume 維持設定檔

View File

@ -8,7 +8,7 @@
## 核心資源 ## 核心資源
- OAuth2/OIDC授權、token、discovery、JWKS - OAuth2/OIDC授權、token、discovery、JWKS
- Auth註冊、登入、刷新、登出、忘記/重設密碼、Email 驗證 - Auth註冊、登入password grant、刷新、登出、忘記/重設密碼、Email 驗證
- User個人資料 - User個人資料
- Newsletter訂閱/確認/退訂/偏好 - Newsletter訂閱/確認/退訂/偏好
- AdminTenants/Lists/OAuth ClientsMVP CRUD - AdminTenants/Lists/OAuth ClientsMVP CRUD
@ -16,3 +16,8 @@
## Security Schemes ## Security Schemes
- OAuth2 (Authorization Code + PKCE) - OAuth2 (Authorization Code + PKCE)
- Bearer JWTAPI 使用) - Bearer JWTAPI 使用)
## 補充說明
- `/oauth/token``/auth/login``/auth/refresh` 使用 `application/x-www-form-urlencoded`
- `/auth/email/verify` 需要 `token` + `email`
- `/newsletter/subscribe` 會回傳 `confirm_token`

View File

@ -1,3 +1,4 @@
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence; using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@ -5,6 +6,8 @@ using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore; using OpenIddict.Server.AspNetCore;
EnvLoader.LoadDotEnvIfDevelopment();
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<MemberCenterDbContext>(options => builder.Services.AddDbContext<MemberCenterDbContext>(options =>

View File

@ -1,7 +1,4 @@
{ {
"ConnectionStrings": {
"Default": "Host=localhost;Database=member_center;Username=postgres;Password=postgres"
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",

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

View File

@ -1,4 +1,5 @@
using MemberCenter.Domain.Entities; using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence; using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@ -6,10 +7,14 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Npgsql;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.CommandLine; using System.CommandLine;
EnvLoader.LoadDotEnvIfDevelopment();
var root = new RootCommand("Member Center installer"); var root = new RootCommand("Member Center installer");
var connectionStringOption = new Option<string>( var connectionStringOption = new Option<string>(
@ -279,6 +284,7 @@ return await root.InvokeAsync(args);
static IServiceProvider BuildServices(string connectionString) static IServiceProvider BuildServices(string connectionString)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddLogging(builder => builder.AddConsole());
services.AddDbContext<MemberCenterDbContext>(options => services.AddDbContext<MemberCenterDbContext>(options =>
{ {
options.UseNpgsql(connectionString); options.UseNpgsql(connectionString);
@ -302,7 +308,14 @@ static IServiceProvider BuildServices(string connectionString)
static string? ResolveConnectionString(string? connectionString, string? appsettingsPath, bool noPrompt) 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)) if (!string.IsNullOrWhiteSpace(connectionString))
{ {
@ -313,7 +326,7 @@ static string? ResolveConnectionString(string? connectionString, string? appsett
if (File.Exists(targetPath)) if (File.Exists(targetPath))
{ {
var config = new ConfigurationBuilder() var config = new ConfigurationBuilder()
.AddJsonFile(targetPath) .AddJsonFile(targetPath, optional: true)
.Build(); .Build();
var existing = config.GetConnectionString("Default"); var existing = config.GetConnectionString("Default");
if (!string.IsNullOrWhiteSpace(existing)) if (!string.IsNullOrWhiteSpace(existing))
@ -336,6 +349,22 @@ static string? ResolveConnectionString(string? connectionString, string? appsett
return generated; 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) static void WriteConnectionString(string path, string connectionString)
{ {
JsonNode root; JsonNode root;
@ -359,10 +388,17 @@ static void WriteConnectionString(string path, string connectionString)
} }
static async Task<bool> IsInstalledAsync(MemberCenterDbContext db) static async Task<bool> IsInstalledAsync(MemberCenterDbContext db)
{
try
{ {
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed"); var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
return flag is not null && string.Equals(flag.Value, "true", StringComparison.OrdinalIgnoreCase); 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) static async Task SetInstalledFlagAsync(MemberCenterDbContext db)
{ {