From db39a6ac4c7400936d27e7b7d62b2502f4ee5efb Mon Sep 17 00:00:00 2001 From: warrenchen Date: Tue, 3 Feb 2026 16:48:25 +0900 Subject: [PATCH] Enhance installation process with environment variable support and dotenv loading for development --- .env.example | 2 + docs/INSTALL.md | 25 +++++ docs/OPENAPI.md | 7 +- src/MemberCenter.Api/Program.cs | 3 + src/MemberCenter.Api/appsettings.json | 3 - .../Configuration/EnvLoader.cs | 100 ++++++++++++++++++ src/MemberCenter.Installer/Program.cs | 44 +++++++- 7 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 .env.example create mode 100644 src/MemberCenter.Infrastructure/Configuration/EnvLoader.cs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..67a03ae --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +ASPNETCORE_ENVIRONMENT=Development +ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres diff --git a/docs/INSTALL.md b/docs/INSTALL.md index faf8edf..b4ae8ce 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 維持設定檔 diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 87b0da1..a343525 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -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` diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 9e4555d..cf77be4 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -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(options => diff --git a/src/MemberCenter.Api/appsettings.json b/src/MemberCenter.Api/appsettings.json index 067a9df..0c208ae 100644 --- a/src/MemberCenter.Api/appsettings.json +++ b/src/MemberCenter.Api/appsettings.json @@ -1,7 +1,4 @@ { - "ConnectionStrings": { - "Default": "Host=localhost;Database=member_center;Username=postgres;Password=postgres" - }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/MemberCenter.Infrastructure/Configuration/EnvLoader.cs b/src/MemberCenter.Infrastructure/Configuration/EnvLoader.cs new file mode 100644 index 0000000..db55a7f --- /dev/null +++ b/src/MemberCenter.Infrastructure/Configuration/EnvLoader.cs @@ -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); + } + } +} diff --git a/src/MemberCenter.Installer/Program.cs b/src/MemberCenter.Installer/Program.cs index 8a6fbe7..985c353 100644 --- a/src/MemberCenter.Installer/Program.cs +++ b/src/MemberCenter.Installer/Program.cs @@ -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( @@ -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(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 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)