Refactor installation process and update schema definitions for improved functionality

This commit is contained in:
warrenchen 2026-02-03 15:33:16 +09:00
parent 4631f82ee4
commit 8756010173
6 changed files with 655 additions and 117 deletions

9
NuGet.Config Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<add key="globalPackagesFolder" value=".nuget/packages" />
</config>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>

View File

@ -21,22 +21,12 @@
- 強制重設 superuser 密碼
- 只執行 migration差異安裝不重建 schema
## 建議實作方式(.NET
- 提供一個 CLI 或安裝頁面(類 nopCommerce / Wagtail
- CLI 支援互動式輸入DB 連線、admin 帳號、密碼)
- 內部流程一致:
1) 讀取/寫入連線資訊
2) 執行 migration`dbContext.Database.Migrate()`
3) 建立角色與 superuser若不存在
## CLI 指令規格(草案)
## CLI 指令規格(實作)
### 通用參數
- `--connection-string <string>`: DB 連線字串
- `--db-provider <string>`: 預設 `postgres`
- `--appsettings <path>`: 設定檔路徑(預設 `appsettings.json`
- `--appsettings <path>`: 設定檔路徑(讀/寫 `ConnectionStrings:Default`
- `--no-prompt`: 不使用互動輸入CI/CD
- `--dry-run`: 只檢查,不寫入
- `--verbose`: 詳細輸出
### 1) `installer init`
@ -49,11 +39,13 @@
- `--force`: 若偵測已初始化,仍強制執行
流程:
1) 讀取/寫入 DB 連線資訊
1) 解析連線字串(參數或 appsettings
- 若提供 `--connection-string`,會寫入 appsettings
- 若 appsettings 中缺少連線字串,會互動式詢問並寫入
2) 執行 migrations不 Drop
3) 建立 rolesadmin, support
4) 建立 admin不存在才建立
5) 寫入安裝鎖定(install.lock 或 DB flag
4) 建立 admin不存在才建立並加入 admin 角色
5) 寫入安裝鎖定DB flag: `system_flags` / `installed=true`
### 2) `installer add-admin`
用途:新增 superuser
@ -64,7 +56,7 @@
- `--admin-display-name <string>`
流程:
1) 檢查 DB 連線
1) 解析連線字串
2) 建立使用者並指派 admin 角色
### 3) `installer reset-admin-password`
@ -75,9 +67,8 @@
- `--admin-password <string>`
流程:
1) 檢查帳號存在
1) 解析連線字串
2) 更新密碼(強制)
3) 寫入 audit log
### 4) `installer migrate`
用途:只執行 migrations
@ -86,9 +77,8 @@
- `--target <migration>`: 指定遷移(可選)
流程:
1) 連線檢查
2) 執行 migrations
3) 輸出版本
1) 解析連線字串
2) 執行 migrations可指定 target
## 安全注意
- 密碼必須符合強度規則

View File

@ -1,4 +1,7 @@
-- PostgreSQL schema draft (MVP)
-- PostgreSQL schema draft (MVP)
-- Note: OpenIddict/Identity tables are based on EF Core migrations.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE tenants (
id UUID PRIMARY KEY,
@ -10,62 +13,137 @@ CREATE TABLE tenants (
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'active',
user_name TEXT,
normalized_user_name TEXT,
email TEXT,
normalized_email TEXT,
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
password_hash TEXT,
security_stamp TEXT,
concurrency_stamp TEXT,
phone_number TEXT,
phone_number_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
lockout_end TIMESTAMPTZ,
lockout_enabled BOOLEAN NOT NULL DEFAULT FALSE,
access_failed_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE oauth_clients (
CREATE TABLE roles (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT,
normalized_name TEXT,
concurrency_stamp TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE user_claims (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
claim_type TEXT,
claim_value TEXT
);
CREATE TABLE role_claims (
id SERIAL PRIMARY KEY,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
claim_type TEXT,
claim_value TEXT
);
CREATE TABLE user_logins (
login_provider TEXT NOT NULL,
provider_key TEXT NOT NULL,
provider_display_name TEXT,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (login_provider, provider_key)
);
CREATE TABLE user_tokens (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
login_provider TEXT NOT NULL,
name TEXT NOT NULL,
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
client_type TEXT NOT NULL DEFAULT 'confidential',
client_secret_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
value TEXT,
PRIMARY KEY (user_id, login_provider, name)
);
ALTER TABLE oauth_clients
ADD CONSTRAINT chk_oauth_clients_secret
CHECK (
(client_type = 'confidential' AND client_secret_hash IS NOT NULL)
OR (client_type <> 'confidential')
);
CREATE UNIQUE INDEX idx_users_normalized_user_name ON users(normalized_user_name);
CREATE UNIQUE INDEX idx_users_normalized_email ON users(normalized_email);
CREATE UNIQUE INDEX idx_roles_normalized_name ON roles(normalized_name);
CREATE TABLE oauth_codes (
id UUID PRIMARY KEY,
client_id UUID NOT NULL REFERENCES oauth_clients(id),
user_id UUID NOT NULL REFERENCES users(id),
code_hash TEXT NOT NULL,
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
-- OpenIddict (EF Core default tables)
CREATE TABLE "OpenIddictApplications" (
"Id" UUID PRIMARY KEY,
"ApplicationType" TEXT,
"ClientId" TEXT,
"ClientSecret" TEXT,
"ConsentType" TEXT,
"DisplayName" TEXT,
"DisplayNames" JSONB,
"Permissions" JSONB,
"PostLogoutRedirectUris" JSONB,
"Properties" JSONB,
"RedirectUris" JSONB,
"Requirements" JSONB,
"ConcurrencyToken" TEXT
);
CREATE TABLE oauth_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
client_id UUID NOT NULL REFERENCES oauth_clients(id),
access_token_hash TEXT NOT NULL,
refresh_token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
CREATE TABLE "OpenIddictAuthorizations" (
"Id" UUID PRIMARY KEY,
"ApplicationId" UUID REFERENCES "OpenIddictApplications"("Id") ON DELETE SET NULL,
"CreationDate" TIMESTAMPTZ,
"Status" TEXT,
"Subject" TEXT,
"Type" TEXT,
"Scopes" JSONB,
"Properties" JSONB,
"ConcurrencyToken" TEXT
);
CREATE TABLE oidc_id_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
client_id UUID NOT NULL REFERENCES oauth_clients(id),
id_token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
CREATE TABLE "OpenIddictScopes" (
"Id" UUID PRIMARY KEY,
"Description" TEXT,
"Descriptions" JSONB,
"DisplayName" TEXT,
"DisplayNames" JSONB,
"Name" TEXT,
"Properties" JSONB,
"Resources" JSONB,
"ConcurrencyToken" TEXT
);
CREATE TABLE "OpenIddictTokens" (
"Id" UUID PRIMARY KEY,
"ApplicationId" UUID REFERENCES "OpenIddictApplications"("Id") ON DELETE SET NULL,
"AuthorizationId" UUID REFERENCES "OpenIddictAuthorizations"("Id") ON DELETE SET NULL,
"CreationDate" TIMESTAMPTZ,
"ExpirationDate" TIMESTAMPTZ,
"RedemptionDate" TIMESTAMPTZ,
"Payload" TEXT,
"ReferenceId" TEXT,
"Status" TEXT,
"Subject" TEXT,
"Type" TEXT,
"Properties" JSONB,
"ConcurrencyToken" TEXT
);
CREATE UNIQUE INDEX idx_openiddict_applications_client_id ON "OpenIddictApplications"("ClientId");
CREATE UNIQUE INDEX idx_openiddict_scopes_name ON "OpenIddictScopes"("Name");
CREATE UNIQUE INDEX idx_openiddict_tokens_reference_id ON "OpenIddictTokens"("ReferenceId");
CREATE INDEX idx_openiddict_tokens_subject ON "OpenIddictTokens"("Subject");
CREATE TABLE newsletter_lists (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
@ -73,9 +151,9 @@ CREATE TABLE newsletter_lists (
CREATE TABLE newsletter_subscriptions (
id UUID PRIMARY KEY,
list_id UUID NOT NULL REFERENCES newsletter_lists(id),
list_id UUID NOT NULL REFERENCES newsletter_lists(id) ON DELETE CASCADE,
email TEXT NOT NULL,
user_id UUID REFERENCES users(id),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending',
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@ -85,7 +163,7 @@ CREATE TABLE newsletter_subscriptions (
CREATE TABLE email_verifications (
id UUID PRIMARY KEY,
email TEXT NOT NULL,
tenant_id UUID NOT NULL REFERENCES tenants(id),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
@ -94,7 +172,7 @@ CREATE TABLE email_verifications (
CREATE TABLE unsubscribe_tokens (
id UUID PRIMARY KEY,
subscription_id UUID NOT NULL REFERENCES newsletter_subscriptions(id),
subscription_id UUID NOT NULL REFERENCES newsletter_subscriptions(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
@ -109,17 +187,11 @@ CREATE TABLE audit_logs (
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE roles (
CREATE TABLE system_flags (
id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_roles (
user_id UUID NOT NULL REFERENCES users(id),
role_id UUID NOT NULL REFERENCES roles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, role_id)
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_newsletter_subscriptions_email
@ -128,17 +200,5 @@ CREATE INDEX idx_newsletter_subscriptions_email
CREATE INDEX idx_newsletter_subscriptions_list_id
ON newsletter_subscriptions(list_id);
CREATE INDEX idx_oauth_tokens_user_id
ON oauth_tokens(user_id);
CREATE INDEX idx_oauth_tokens_client_id
ON oauth_tokens(client_id);
CREATE INDEX idx_oauth_codes_client_id
ON oauth_codes(client_id);
CREATE INDEX idx_email_verifications_email
ON email_verifications(email);
CREATE INDEX idx_user_roles_role_id
ON user_roles(role_id);

View File

@ -13,6 +13,7 @@ paths:
get:
summary: OAuth2 Authorization Endpoint
description: Authorization Code + PKCE flow
security: []
parameters:
- in: query
name: client_id
@ -49,20 +50,15 @@ paths:
/oauth/token:
post:
summary: OAuth2 Token Endpoint
security: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [grant_type, code, redirect_uri, client_id, code_verifier]
properties:
grant_type: { type: string, enum: [authorization_code, refresh_token] }
code: { type: string }
redirect_uri: { type: string }
client_id: { type: string }
code_verifier: { type: string }
refresh_token: { type: string }
oneOf:
- $ref: '#/components/schemas/AuthorizationCodeTokenRequest'
- $ref: '#/components/schemas/RefreshTokenRequest'
responses:
'200':
description: Token response
@ -88,6 +84,7 @@ paths:
/auth/register:
post:
summary: Register user
security: []
requestBody:
required: true
content:
@ -105,12 +102,13 @@ paths:
/auth/login:
post:
summary: API login
security: []
requestBody:
required: true
content:
application/json:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/LoginRequest'
$ref: '#/components/schemas/PasswordTokenRequest'
responses:
'200':
description: Token response
@ -122,12 +120,13 @@ paths:
/auth/refresh:
post:
summary: Refresh token
security: []
requestBody:
required: true
content:
application/json:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/RefreshRequest'
$ref: '#/components/schemas/RefreshTokenRequest'
responses:
'200':
description: Token response
@ -157,6 +156,7 @@ paths:
/auth/password/forgot:
post:
summary: Request password reset
security: []
requestBody:
required: true
content:
@ -170,6 +170,7 @@ paths:
/auth/password/reset:
post:
summary: Reset password
security: []
requestBody:
required: true
content:
@ -183,11 +184,16 @@ paths:
/auth/email/verify:
get:
summary: Verify email
security: []
parameters:
- in: query
name: token
required: true
schema: { type: string }
- in: query
name: email
required: true
schema: { type: string, format: email }
responses:
'200':
description: Email verified
@ -208,6 +214,7 @@ paths:
/newsletter/subscribe:
post:
summary: Subscribe (unauthenticated allowed)
security: []
requestBody:
required: true
content:
@ -220,11 +227,12 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/Subscription'
$ref: '#/components/schemas/PendingSubscriptionResponse'
/newsletter/confirm:
get:
summary: Confirm subscription (double opt-in)
security: []
parameters:
- in: query
name: token
@ -241,6 +249,7 @@ paths:
/newsletter/unsubscribe:
post:
summary: Unsubscribe single list
security: []
requestBody:
required: true
content:
@ -515,14 +524,25 @@ components:
email: { type: string, format: email }
password: { type: string }
LoginRequest:
PasswordTokenRequest:
type: object
required: [email, password, client_id]
required: [grant_type, username, password]
properties:
email: { type: string, format: email }
grant_type: { type: string, enum: [password] }
username: { type: string, format: email }
password: { type: string }
client_id: { type: string }
scope: { type: string }
client_id: { type: string }
AuthorizationCodeTokenRequest:
type: object
required: [grant_type, code, redirect_uri, code_verifier]
properties:
grant_type: { type: string, enum: [authorization_code] }
code: { type: string }
redirect_uri: { type: string }
code_verifier: { type: string }
client_id: { type: string }
RefreshRequest:
type: object
@ -531,6 +551,14 @@ components:
refresh_token: { type: string }
client_id: { type: string }
RefreshTokenRequest:
type: object
required: [grant_type, refresh_token]
properties:
grant_type: { type: string, enum: [refresh_token] }
refresh_token: { type: string }
client_id: { type: string }
ForgotPasswordRequest:
type: object
required: [email]
@ -539,8 +567,9 @@ components:
ResetPasswordRequest:
type: object
required: [token, new_password]
required: [email, token, new_password]
properties:
email: { type: string, format: email }
token: { type: string }
new_password: { type: string }
@ -571,6 +600,13 @@ components:
preferences: { type: object }
created_at: { type: string, format: date-time }
PendingSubscriptionResponse:
allOf:
- $ref: '#/components/schemas/Subscription'
- type: object
properties:
confirm_token: { type: string }
Tenant:
type: object
properties:

View File

@ -13,4 +13,13 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
</Project>

View File

@ -1,2 +1,436 @@
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.CommandLine;
var root = new RootCommand("Member Center installer");
var connectionStringOption = new Option<string>(
name: "--connection-string",
description: "Database connection string");
connectionStringOption.AddAlias("-c");
var appsettingsOption = new Option<string?>(
name: "--appsettings",
description: "Path to appsettings.json");
var noPromptOption = new Option<bool>(
name: "--no-prompt",
description: "Disable interactive prompts");
var verboseOption = new Option<bool>(
name: "--verbose",
description: "Enable verbose output");
var forceOption = new Option<bool>(
name: "--force",
description: "Force execution even if already installed");
var adminEmailOption = new Option<string?>(
name: "--admin-email",
description: "Admin email");
var adminPasswordOption = new Option<string?>(
name: "--admin-password",
description: "Admin password");
var adminDisplayNameOption = new Option<string?>(
name: "--admin-display-name",
description: "Admin display name (optional)");
var targetMigrationOption = new Option<string?>(
name: "--target",
description: "Target migration");
var initCommand = new Command("init", "Initialize database (migrate + seed + admin)");
initCommand.AddOption(connectionStringOption);
initCommand.AddOption(appsettingsOption);
initCommand.AddOption(noPromptOption);
initCommand.AddOption(verboseOption);
initCommand.AddOption(forceOption);
initCommand.AddOption(adminEmailOption);
initCommand.AddOption(adminPasswordOption);
initCommand.AddOption(adminDisplayNameOption);
initCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, bool verbose, bool force, string? adminEmail, string? adminPassword) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
if (!force && await IsInstalledAsync(db))
{
Console.Error.WriteLine("System already installed. Use --force to override.");
return;
}
if (!noPrompt)
{
adminEmail ??= Prompt("Admin email", "admin@example.com");
adminPassword ??= PromptSecret("Admin password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Admin password is required.");
return;
}
await db.Database.MigrateAsync();
await EnsureRoleAsync(roleManager, "admin");
await EnsureRoleAsync(roleManager, "support");
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
admin = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true
};
var createResult = await userManager.CreateAsync(admin, adminPassword);
if (!createResult.Succeeded)
{
Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description)));
return;
}
}
if (!await userManager.IsInRoleAsync(admin, "admin"))
{
await userManager.AddToRoleAsync(admin, "admin");
}
await SetInstalledFlagAsync(db);
if (verbose)
{
Console.WriteLine("Init completed.");
}
}, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption);
var addAdminCommand = new Command("add-admin", "Add admin user");
addAdminCommand.AddOption(connectionStringOption);
addAdminCommand.AddOption(appsettingsOption);
addAdminCommand.AddOption(noPromptOption);
addAdminCommand.AddOption(adminEmailOption);
addAdminCommand.AddOption(adminPasswordOption);
addAdminCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
if (!noPrompt)
{
adminEmail ??= Prompt("Admin email", "admin@example.com");
adminPassword ??= PromptSecret("Admin password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Admin password is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
await EnsureRoleAsync(roleManager, "admin");
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
admin = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true
};
var createResult = await userManager.CreateAsync(admin, adminPassword);
if (!createResult.Succeeded)
{
Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description)));
return;
}
}
if (!await userManager.IsInRoleAsync(admin, "admin"))
{
await userManager.AddToRoleAsync(admin, "admin");
}
Console.WriteLine("Admin user ready.");
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
var resetCommand = new Command("reset-admin-password", "Reset admin password");
resetCommand.AddOption(connectionStringOption);
resetCommand.AddOption(appsettingsOption);
resetCommand.AddOption(noPromptOption);
resetCommand.AddOption(adminEmailOption);
resetCommand.AddOption(adminPasswordOption);
resetCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
if (!noPrompt)
{
adminEmail ??= Prompt("Admin email", "admin@example.com");
adminPassword ??= PromptSecret("New password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Admin password is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
Console.Error.WriteLine("Admin not found.");
return;
}
var resetToken = await userManager.GeneratePasswordResetTokenAsync(admin);
var result = await userManager.ResetPasswordAsync(admin, resetToken, adminPassword);
if (!result.Succeeded)
{
Console.Error.WriteLine(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
return;
}
Console.WriteLine("Password reset completed.");
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
var migrateCommand = new Command("migrate", "Run migrations only");
migrateCommand.AddOption(connectionStringOption);
migrateCommand.AddOption(appsettingsOption);
migrateCommand.AddOption(targetMigrationOption);
migrateCommand.SetHandler(async (string? connectionString, string? appsettings, string? target) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt: true);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
if (string.IsNullOrWhiteSpace(target))
{
await db.Database.MigrateAsync();
}
else
{
var migrator = scope.ServiceProvider.GetRequiredService<IMigrator>();
await migrator.MigrateAsync(target);
}
Console.WriteLine("Migrations completed.");
}, connectionStringOption, appsettingsOption, targetMigrationOption);
root.AddCommand(initCommand);
root.AddCommand(addAdminCommand);
root.AddCommand(resetCommand);
root.AddCommand(migrateCommand);
return await root.InvokeAsync(args);
static IServiceProvider BuildServices(string connectionString)
{
var services = new ServiceCollection();
services.AddDbContext<MemberCenterDbContext>(options =>
{
options.UseNpgsql(connectionString);
options.UseOpenIddict();
});
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
return services.BuildServiceProvider();
}
static string? ResolveConnectionString(string? connectionString, string? appsettingsPath, bool noPrompt)
{
var targetPath = string.IsNullOrWhiteSpace(appsettingsPath) ? "appsettings.json" : appsettingsPath;
if (!string.IsNullOrWhiteSpace(connectionString))
{
WriteConnectionString(targetPath, connectionString);
return connectionString;
}
if (File.Exists(targetPath))
{
var config = new ConfigurationBuilder()
.AddJsonFile(targetPath)
.Build();
var existing = config.GetConnectionString("Default");
if (!string.IsNullOrWhiteSpace(existing))
{
return existing;
}
}
if (noPrompt)
{
return null;
}
var host = Prompt("DB host", "localhost");
var database = Prompt("DB name", "member_center");
var username = Prompt("DB user", "postgres");
var password = PromptSecret("DB password");
var generated = $"Host={host};Database={database};Username={username};Password={password}";
WriteConnectionString(targetPath, generated);
return generated;
}
static void WriteConnectionString(string path, string connectionString)
{
JsonNode root;
if (File.Exists(path))
{
var json = File.ReadAllText(path);
root = JsonNode.Parse(json) ?? new JsonObject();
}
else
{
root = new JsonObject();
}
var obj = root as JsonObject ?? new JsonObject();
var connectionStrings = obj["ConnectionStrings"] as JsonObject ?? new JsonObject();
connectionStrings["Default"] = connectionString;
obj["ConnectionStrings"] = connectionStrings;
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(path, obj.ToJsonString(options));
}
static async Task<bool> 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);
}
static async Task SetInstalledFlagAsync(MemberCenterDbContext db)
{
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
if (flag is null)
{
db.SystemFlags.Add(new SystemFlag
{
Id = Guid.NewGuid(),
Key = "installed",
Value = "true",
UpdatedAt = DateTimeOffset.UtcNow
});
}
else
{
flag.Value = "true";
flag.UpdatedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync();
}
static async Task EnsureRoleAsync(RoleManager<ApplicationRole> roleManager, string roleName)
{
if (!await roleManager.RoleExistsAsync(roleName))
{
var result = await roleManager.CreateAsync(new ApplicationRole
{
Id = Guid.NewGuid(),
Name = roleName,
NormalizedName = roleName.ToUpperInvariant()
});
if (!result.Succeeded)
{
throw new InvalidOperationException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
}
}
}
static string Prompt(string label, string defaultValue)
{
Console.Write($"{label} [{defaultValue}]: ");
var input = Console.ReadLine();
return string.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim();
}
static string PromptSecret(string label)
{
Console.Write($"{label}: ");
var password = string.Empty;
ConsoleKeyInfo key;
while ((key = Console.ReadKey(true)).Key != ConsoleKey.Enter)
{
if (key.Key == ConsoleKey.Backspace && password.Length > 0)
{
password = password[..^1];
Console.Write("\b \b");
continue;
}
if (!char.IsControl(key.KeyChar))
{
password += key.KeyChar;
Console.Write("*");
}
}
Console.WriteLine();
return password;
}