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)
|
- `--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) 建立 roles(admin, support)
|
3) 建立 roles(admin, 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 維持設定檔
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## 核心資源
|
## 核心資源
|
||||||
- OAuth2/OIDC:授權、token、discovery、JWKS
|
- OAuth2/OIDC:授權、token、discovery、JWKS
|
||||||
- Auth:註冊、登入、刷新、登出、忘記/重設密碼、Email 驗證
|
- Auth:註冊、登入(password grant)、刷新、登出、忘記/重設密碼、Email 驗證
|
||||||
- User:個人資料
|
- User:個人資料
|
||||||
- Newsletter:訂閱/確認/退訂/偏好
|
- Newsletter:訂閱/確認/退訂/偏好
|
||||||
- Admin:Tenants/Lists/OAuth Clients(MVP CRUD)
|
- Admin:Tenants/Lists/OAuth Clients(MVP CRUD)
|
||||||
@ -16,3 +16,8 @@
|
|||||||
## Security Schemes
|
## Security Schemes
|
||||||
- OAuth2 (Authorization Code + PKCE)
|
- OAuth2 (Authorization Code + PKCE)
|
||||||
- Bearer JWT(API 使用)
|
- 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.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 =>
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
|
||||||
"Default": "Host=localhost;Database=member_center;Username=postgres;Password=postgres"
|
|
||||||
},
|
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"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.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)
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user