feat: Improve error handling and logging in installer commands

This commit is contained in:
Warren Chen 2026-02-27 03:43:03 +09:00
parent 8b3f9284df
commit c56fc21915
5 changed files with 150 additions and 84 deletions

View File

@ -1,4 +1,7 @@
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_ENVIRONMENT=Development
# Reduce noisy SQL statement logs (EF Core):
Logging__LogLevel__Microsoft__EntityFrameworkCore=Warning
Logging__LogLevel__Microsoft__EntityFrameworkCore__Database__Command=Warning
ConnectionStrings__Default=Host=localhost;Database=send_engine;Username=postgres;Password=postgres ConnectionStrings__Default=Host=localhost;Database=send_engine;Username=postgres;Password=postgres
ESP__Provider=ses ESP__Provider=ses
Db__AutoMigrate=true Db__AutoMigrate=true

View File

@ -58,3 +58,5 @@ mass_mail_engine/
- SES/SNS 簽章完整驗證(目前 `Ses__SkipSignatureValidation=false` 僅檢查 header 存在) - SES/SNS 簽章完整驗證(目前 `Ses__SkipSignatureValidation=false` 僅檢查 header 存在)
- 事件重試/DLQ 策略補強(目前主要依 SQS redrive policy - 事件重試/DLQ 策略補強(目前主要依 SQS redrive policy
- recipient 狀態機擴充delivery/open/click 的完整優先序與狀態轉換) - recipient 狀態機擴充delivery/open/click 的完整優先序與狀態轉換)
- 與 Member Center 對齊並同步可讀的 list 名稱(避免 auto-created `list-{uuid}` 成為最終顯示)
- SES 郵件 header 補 `List-ID`(優先使用同步後的 list 名稱),避免 Gmail 取消訂閱視窗顯示 `(Unknown)`

View File

@ -27,6 +27,8 @@
- 使用 Installer 建立 webhook client`id` 自動隨機產生): - 使用 Installer 建立 webhook client`id` 自動隨機產生):
- `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <tenant_uuid> [--tenant-name <name>] --client-id <client_id> --name <display_name> --scopes <scope1,scope2>` - `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <tenant_uuid> [--tenant-name <name>] --client-id <client_id> --name <display_name> --scopes <scope1,scope2>`
- 例如:`dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id 11111111-1111-1111-1111-111111111111 --tenant-name "Tenant A" --client-id member-center-webhook --name "Member Center Webhook" --scopes newsletter:events.write` - 例如:`dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id 11111111-1111-1111-1111-111111111111 --tenant-name "Tenant A" --client-id member-center-webhook --name "Member Center Webhook" --scopes newsletter:events.write`
- 若 `client_id` 已存在且屬於同一 tenantInstaller 會重用既有 webhook client不重複建立可直接搭配 `--upsert-member-center` 做同步
- 若 `client_id` 已存在但屬於不同 tenantInstaller 會中止並回報錯誤
- 若 tenant 不存在Installer 會先自動建立 `tenants` 基本資料,避免 webhook 出現 `tenant_not_found` - 若 tenant 不存在Installer 會先自動建立 `tenants` 基本資料,避免 webhook 出現 `tenant_not_found`
- 建立成功後Member Center webhook header `X-Client-Id` 請帶回傳的 `id` - 建立成功後Member Center webhook header `X-Client-Id` 請帶回傳的 `id`
- 若要自動同步到 Member Center `POST /integrations/send-engine/webhook-clients/upsert`(保留原手動流程): - 若要自動同步到 Member Center `POST /integrations/send-engine/webhook-clients/upsert`(保留原手動流程):

View File

@ -1,9 +1,11 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning",
} "Microsoft.EntityFrameworkCore": "Warning",
}, "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
"AllowedHosts": "*" }
} },
"AllowedHosts": "*"
}

View File

@ -9,57 +9,78 @@ using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
var command = args.Length > 0 ? args[0] : "migrate"; var command = args.Length > 0 ? args[0] : "migrate";
var verboseErrors = args.Any(x => string.Equals(x, "--verbose-errors", StringComparison.OrdinalIgnoreCase));
var startedAt = DateTimeOffset.UtcNow;
if (command is "-h" or "--help" or "help") AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{ {
Console.WriteLine("SendEngine Installer"); WriteError("Unhandled installer exception.");
Console.WriteLine("Usage:"); if (eventArgs.ExceptionObject is Exception ex)
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate"); {
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- ensure-tenant --tenant-id <uuid> [--tenant-name <string>]"); WriteError(verboseErrors ? ex.ToString() : ex.Message);
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <uuid> [--tenant-name <string>] --client-id <string> --name <string> --scopes <csv> [--upsert-member-center --mc-base-url <url> --mc-client-id <string> --mc-client-secret <string> [--mc-scope <scope>] [--mc-token-path <path>] [--mc-upsert-path <path>] [--mc-token-url <url>] [--mc-upsert-url <url>]]"); }
return; };
}
var configuration = new ConfigurationBuilder() try
.AddEnvironmentVariables()
.Build();
var services = new ServiceCollection();
services.AddInfrastructure(configuration);
var provider = services.BuildServiceProvider();
if (command == "migrate")
{ {
using var scope = provider.CreateScope(); // Always print a startup line so container logs are never completely empty.
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>(); WriteError($"[installer] start command={command} utc={startedAt:O}");
db.Database.Migrate();
Console.WriteLine("Database migration completed.");
return;
}
if (command == "add-webhook-client") if (command is "-h" or "--help" or "help")
{ {
var options = ParseOptions(args); Console.WriteLine("SendEngine Installer");
options.TryGetValue("tenant-id", out var tenantIdRaw); Console.WriteLine("Usage:");
options.TryGetValue("tenant-name", out var tenantName); Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate");
options.TryGetValue("client-id", out var clientId); Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- ensure-tenant --tenant-id <uuid> [--tenant-name <string>]");
options.TryGetValue("name", out var name); Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <uuid> [--tenant-name <string>] --client-id <string> --name <string> --scopes <csv> [--upsert-member-center --mc-base-url <url> --mc-client-id <string> --mc-client-secret <string> [--mc-scope <scope>] [--mc-token-path <path>] [--mc-upsert-path <path>] [--mc-token-url <url>] [--mc-upsert-url <url>]]");
options.TryGetValue("scopes", out var scopesRaw); Console.WriteLine(" optional: --verbose-errors (print full exception stack traces to stderr)");
return;
}
var configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
var services = new ServiceCollection();
services.AddInfrastructure(configuration);
Verbose("infrastructure registered");
var provider = services.BuildServiceProvider();
Verbose("service provider built");
if (command == "migrate")
{
Verbose("running migrate");
using var scope = provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
db.Database.Migrate();
Console.WriteLine("Database migration completed.");
return;
}
if (command == "add-webhook-client")
{
Verbose("running add-webhook-client");
var options = ParseOptions(args);
options.TryGetValue("tenant-id", out var tenantIdRaw);
options.TryGetValue("tenant-name", out var tenantName);
options.TryGetValue("client-id", out var clientId);
options.TryGetValue("name", out var name);
options.TryGetValue("scopes", out var scopesRaw);
if (string.IsNullOrWhiteSpace(tenantIdRaw) || if (string.IsNullOrWhiteSpace(tenantIdRaw) ||
string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientId) ||
string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(name) ||
string.IsNullOrWhiteSpace(scopesRaw)) string.IsNullOrWhiteSpace(scopesRaw))
{ {
Console.WriteLine("Missing required options."); WriteError("Missing required options.");
Console.WriteLine("Required: --tenant-id, --client-id, --name, --scopes"); WriteError("Required: --tenant-id, --client-id, --name, --scopes");
Environment.Exit(1); Environment.Exit(1);
} }
if (!Guid.TryParse(tenantIdRaw, out var tenantId)) if (!Guid.TryParse(tenantIdRaw, out var tenantId))
{ {
Console.WriteLine("Invalid --tenant-id, expected UUID."); WriteError("Invalid --tenant-id, expected UUID.");
Environment.Exit(1); Environment.Exit(1);
} }
@ -70,12 +91,14 @@ if (command == "add-webhook-client")
if (scopes.Length == 0) if (scopes.Length == 0)
{ {
Console.WriteLine("Invalid --scopes, expected comma-separated values."); WriteError("Invalid --scopes, expected comma-separated values.");
Environment.Exit(1); Environment.Exit(1);
} }
using var scope = provider.CreateScope(); using var scope = provider.CreateScope();
Verbose("db scope created");
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>(); var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
Verbose("db context resolved");
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId); var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
if (tenant is null) if (tenant is null)
@ -91,28 +114,36 @@ if (command == "add-webhook-client")
Console.WriteLine($"Tenant created. tenant_id={tenant.Id}"); Console.WriteLine($"Tenant created. tenant_id={tenant.Id}");
} }
var exists = await db.AuthClients.AsNoTracking().AnyAsync(x => x.ClientId == clientId); var entity = await db.AuthClients.FirstOrDefaultAsync(x => x.ClientId == clientId);
if (exists) if (entity is not null)
{ {
Console.WriteLine($"client_id already exists: {clientId}"); if (entity.TenantId != tenantId)
Environment.Exit(1); {
WriteError($"client_id already exists under different tenant. client_id={clientId} existing_tenant_id={entity.TenantId} requested_tenant_id={tenantId}");
Environment.Exit(1);
}
Console.WriteLine("Webhook client already exists. Reusing existing record.");
}
else
{
entity = new AuthClient
{
Id = Guid.NewGuid(),
TenantId = tenantId,
ClientId = clientId,
Name = name,
Scopes = scopes,
Status = "active",
CreatedAt = DateTimeOffset.UtcNow
};
db.AuthClients.Add(entity);
await db.SaveChangesAsync();
Verbose("auth client saved");
Console.WriteLine("Webhook client created.");
} }
var entity = new AuthClient
{
Id = Guid.NewGuid(),
TenantId = tenantId,
ClientId = clientId,
Name = name,
Scopes = scopes,
Status = "active",
CreatedAt = DateTimeOffset.UtcNow
};
db.AuthClients.Add(entity);
await db.SaveChangesAsync();
Console.WriteLine("Webhook client created.");
Console.WriteLine($"id={entity.Id}"); Console.WriteLine($"id={entity.Id}");
Console.WriteLine($"tenant_id={entity.TenantId}"); Console.WriteLine($"tenant_id={entity.TenantId}");
Console.WriteLine($"client_id={entity.ClientId}"); Console.WriteLine($"client_id={entity.ClientId}");
@ -123,15 +154,15 @@ if (command == "add-webhook-client")
var mcSettings = ResolveMemberCenterUpsertSettings(options); var mcSettings = ResolveMemberCenterUpsertSettings(options);
if (mcSettings is null) if (mcSettings is null)
{ {
Console.WriteLine("Missing required Member Center options for --upsert-member-center."); WriteError("Missing required Member Center options for --upsert-member-center.");
Console.WriteLine("Required: --mc-base-url, --mc-client-id, --mc-client-secret"); WriteError("Required: --mc-base-url, --mc-client-id, --mc-client-secret");
Environment.Exit(1); Environment.Exit(1);
} }
var token = await ResolveMemberCenterTokenAsync(mcSettings); var token = await ResolveMemberCenterTokenAsync(mcSettings, verboseErrors);
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
{ {
Console.WriteLine("Member Center upsert failed: unable to get access token."); WriteError("Member Center upsert failed: unable to get access token.");
Environment.Exit(1); Environment.Exit(1);
} }
@ -145,11 +176,12 @@ if (command == "add-webhook-client")
try try
{ {
Verbose($"calling member center upsert url={mcSettings.UpsertWebhookClientUrl}");
var response = await client.PostAsJsonAsync(mcSettings.UpsertWebhookClientUrl, upsertPayload); var response = await client.PostAsJsonAsync(mcSettings.UpsertWebhookClientUrl, upsertPayload);
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
Console.WriteLine($"Member Center upsert failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}"); WriteError($"Member Center upsert failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
Environment.Exit(1); Environment.Exit(1);
} }
@ -159,34 +191,38 @@ if (command == "add-webhook-client")
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Member Center upsert request failed: {ex.Message}"); WriteError("Member Center upsert request failed.");
WriteError(verboseErrors ? ex.ToString() : ex.Message);
Environment.Exit(1); Environment.Exit(1);
} }
} }
return; return;
} }
if (command == "ensure-tenant") if (command == "ensure-tenant")
{ {
var options = ParseOptions(args); Verbose("running ensure-tenant");
options.TryGetValue("tenant-id", out var tenantIdRaw); var options = ParseOptions(args);
options.TryGetValue("tenant-name", out var tenantName); options.TryGetValue("tenant-id", out var tenantIdRaw);
options.TryGetValue("tenant-name", out var tenantName);
if (string.IsNullOrWhiteSpace(tenantIdRaw)) if (string.IsNullOrWhiteSpace(tenantIdRaw))
{ {
Console.WriteLine("Missing required option: --tenant-id"); WriteError("Missing required option: --tenant-id");
Environment.Exit(1); Environment.Exit(1);
} }
if (!Guid.TryParse(tenantIdRaw, out var tenantId)) if (!Guid.TryParse(tenantIdRaw, out var tenantId))
{ {
Console.WriteLine("Invalid --tenant-id, expected UUID."); WriteError("Invalid --tenant-id, expected UUID.");
Environment.Exit(1); Environment.Exit(1);
} }
using var scope = provider.CreateScope(); using var scope = provider.CreateScope();
Verbose("db scope created");
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>(); var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
Verbose("db context resolved");
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId); var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
if (tenant is null) if (tenant is null)
{ {
@ -205,10 +241,17 @@ if (command == "ensure-tenant")
Console.WriteLine($"Tenant exists. tenant_id={tenant.Id}"); Console.WriteLine($"Tenant exists. tenant_id={tenant.Id}");
} }
return; return;
} }
Console.WriteLine($"Unknown command: {command}"); Console.WriteLine($"Unknown command: {command}");
}
catch (Exception ex)
{
WriteError("Installer failed with unhandled error.");
WriteError(verboseErrors ? ex.ToString() : ex.Message);
Environment.Exit(1);
}
static Dictionary<string, string> ParseOptions(string[] args) static Dictionary<string, string> ParseOptions(string[] args)
{ {
@ -262,7 +305,7 @@ static MemberCenterUpsertSettings? ResolveMemberCenterUpsertSettings(IReadOnlyDi
return new MemberCenterUpsertSettings(tokenUrl, upsertUrl, clientId, clientSecret, scope); return new MemberCenterUpsertSettings(tokenUrl, upsertUrl, clientId, clientSecret, scope);
} }
static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSettings settings) static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSettings settings, bool verboseErrors)
{ {
using var client = new HttpClient(); using var client = new HttpClient();
var form = new Dictionary<string, string> var form = new Dictionary<string, string>
@ -282,7 +325,7 @@ static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSetti
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
Console.WriteLine($"Member Center token request failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}"); WriteError($"Member Center token request failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
return null; return null;
} }
@ -298,7 +341,8 @@ static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSetti
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Member Center token request error: {ex.Message}"); WriteError("Member Center token request error.");
WriteError(verboseErrors ? ex.ToString() : ex.Message);
} }
return null; return null;
@ -319,6 +363,19 @@ static string Truncate(string? input, int maxLen)
return input.Length <= maxLen ? input : $"{input[..maxLen]}...(truncated)"; return input.Length <= maxLen ? input : $"{input[..maxLen]}...(truncated)";
} }
static void WriteError(string message)
{
Console.Error.WriteLine(message);
}
void Verbose(string message)
{
if (verboseErrors)
{
WriteError($"[installer][verbose] {message}");
}
}
sealed record MemberCenterUpsertSettings( sealed record MemberCenterUpsertSettings(
string TokenUrl, string TokenUrl,
string UpsertWebhookClientUrl, string UpsertWebhookClientUrl,