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
# 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
ESP__Provider=ses
Db__AutoMigrate=true

View File

@ -58,3 +58,5 @@ mass_mail_engine/
- SES/SNS 簽章完整驗證(目前 `Ses__SkipSignatureValidation=false` 僅檢查 header 存在)
- 事件重試/DLQ 策略補強(目前主要依 SQS redrive policy
- 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` 自動隨機產生):
- `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`
- 若 `client_id` 已存在且屬於同一 tenantInstaller 會重用既有 webhook client不重複建立可直接搭配 `--upsert-member-center` 做同步
- 若 `client_id` 已存在但屬於不同 tenantInstaller 會中止並回報錯誤
- 若 tenant 不存在Installer 會先自動建立 `tenants` 基本資料,避免 webhook 出現 `tenant_not_found`
- 建立成功後Member Center webhook header `X-Client-Id` 請帶回傳的 `id`
- 若要自動同步到 Member Center `POST /integrations/send-engine/webhook-clients/upsert`(保留原手動流程):

View File

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

View File

@ -9,37 +9,58 @@ using System.Net.Http.Json;
using System.Text.Json;
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) =>
{
WriteError("Unhandled installer exception.");
if (eventArgs.ExceptionObject is Exception ex)
{
WriteError(verboseErrors ? ex.ToString() : ex.Message);
}
};
try
{
// Always print a startup line so container logs are never completely empty.
WriteError($"[installer] start command={command} utc={startedAt:O}");
if (command is "-h" or "--help" or "help")
{
Console.WriteLine("SendEngine Installer");
Console.WriteLine("Usage:");
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>]");
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>]]");
Console.WriteLine(" optional: --verbose-errors (print full exception stack traces to stderr)");
return;
}
}
var configuration = new ConfigurationBuilder()
var configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
var services = new ServiceCollection();
services.AddInfrastructure(configuration);
var services = new ServiceCollection();
services.AddInfrastructure(configuration);
Verbose("infrastructure registered");
var provider = services.BuildServiceProvider();
var provider = services.BuildServiceProvider();
Verbose("service provider built");
if (command == "migrate")
{
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")
{
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);
@ -52,14 +73,14 @@ if (command == "add-webhook-client")
string.IsNullOrWhiteSpace(name) ||
string.IsNullOrWhiteSpace(scopesRaw))
{
Console.WriteLine("Missing required options.");
Console.WriteLine("Required: --tenant-id, --client-id, --name, --scopes");
WriteError("Missing required options.");
WriteError("Required: --tenant-id, --client-id, --name, --scopes");
Environment.Exit(1);
}
if (!Guid.TryParse(tenantIdRaw, out var tenantId))
{
Console.WriteLine("Invalid --tenant-id, expected UUID.");
WriteError("Invalid --tenant-id, expected UUID.");
Environment.Exit(1);
}
@ -70,12 +91,14 @@ if (command == "add-webhook-client")
if (scopes.Length == 0)
{
Console.WriteLine("Invalid --scopes, expected comma-separated values.");
WriteError("Invalid --scopes, expected comma-separated values.");
Environment.Exit(1);
}
using var scope = provider.CreateScope();
Verbose("db scope created");
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
Verbose("db context resolved");
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
if (tenant is null)
@ -91,14 +114,20 @@ if (command == "add-webhook-client")
Console.WriteLine($"Tenant created. tenant_id={tenant.Id}");
}
var exists = await db.AuthClients.AsNoTracking().AnyAsync(x => x.ClientId == clientId);
if (exists)
var entity = await db.AuthClients.FirstOrDefaultAsync(x => x.ClientId == clientId);
if (entity is not null)
{
Console.WriteLine($"client_id already exists: {clientId}");
if (entity.TenantId != tenantId)
{
WriteError($"client_id already exists under different tenant. client_id={clientId} existing_tenant_id={entity.TenantId} requested_tenant_id={tenantId}");
Environment.Exit(1);
}
var entity = new AuthClient
Console.WriteLine("Webhook client already exists. Reusing existing record.");
}
else
{
entity = new AuthClient
{
Id = Guid.NewGuid(),
TenantId = tenantId,
@ -111,8 +140,10 @@ if (command == "add-webhook-client")
db.AuthClients.Add(entity);
await db.SaveChangesAsync();
Verbose("auth client saved");
Console.WriteLine("Webhook client created.");
}
Console.WriteLine($"id={entity.Id}");
Console.WriteLine($"tenant_id={entity.TenantId}");
Console.WriteLine($"client_id={entity.ClientId}");
@ -123,15 +154,15 @@ if (command == "add-webhook-client")
var mcSettings = ResolveMemberCenterUpsertSettings(options);
if (mcSettings is null)
{
Console.WriteLine("Missing required Member Center options for --upsert-member-center.");
Console.WriteLine("Required: --mc-base-url, --mc-client-id, --mc-client-secret");
WriteError("Missing required Member Center options for --upsert-member-center.");
WriteError("Required: --mc-base-url, --mc-client-id, --mc-client-secret");
Environment.Exit(1);
}
var token = await ResolveMemberCenterTokenAsync(mcSettings);
var token = await ResolveMemberCenterTokenAsync(mcSettings, verboseErrors);
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);
}
@ -145,11 +176,12 @@ if (command == "add-webhook-client")
try
{
Verbose($"calling member center upsert url={mcSettings.UpsertWebhookClientUrl}");
var response = await client.PostAsJsonAsync(mcSettings.UpsertWebhookClientUrl, upsertPayload);
var body = await response.Content.ReadAsStringAsync();
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);
}
@ -159,34 +191,38 @@ if (command == "add-webhook-client")
}
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);
}
}
return;
}
}
if (command == "ensure-tenant")
{
if (command == "ensure-tenant")
{
Verbose("running ensure-tenant");
var options = ParseOptions(args);
options.TryGetValue("tenant-id", out var tenantIdRaw);
options.TryGetValue("tenant-name", out var tenantName);
if (string.IsNullOrWhiteSpace(tenantIdRaw))
{
Console.WriteLine("Missing required option: --tenant-id");
WriteError("Missing required option: --tenant-id");
Environment.Exit(1);
}
if (!Guid.TryParse(tenantIdRaw, out var tenantId))
{
Console.WriteLine("Invalid --tenant-id, expected UUID.");
WriteError("Invalid --tenant-id, expected UUID.");
Environment.Exit(1);
}
using var scope = provider.CreateScope();
Verbose("db scope created");
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
Verbose("db context resolved");
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
if (tenant is null)
{
@ -206,9 +242,16 @@ if (command == "ensure-tenant")
}
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)
{
@ -262,7 +305,7 @@ static MemberCenterUpsertSettings? ResolveMemberCenterUpsertSettings(IReadOnlyDi
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();
var form = new Dictionary<string, string>
@ -282,7 +325,7 @@ static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSetti
var body = await response.Content.ReadAsStringAsync();
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;
}
@ -298,7 +341,8 @@ static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSetti
}
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;
@ -319,6 +363,19 @@ static string Truncate(string? input, int maxLen)
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(
string TokenUrl,
string UpsertWebhookClientUrl,