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

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 ( CREATE TABLE tenants (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@ -10,62 +13,137 @@ CREATE TABLE tenants (
CREATE TABLE users ( CREATE TABLE users (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE, user_name TEXT,
password_hash TEXT NOT NULL, normalized_user_name TEXT,
email_verified_at TIMESTAMPTZ, email TEXT,
status TEXT NOT NULL DEFAULT 'active', 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() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE TABLE oauth_clients ( CREATE TABLE roles (
id UUID PRIMARY KEY, 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, name TEXT NOT NULL,
redirect_uris TEXT[] NOT NULL DEFAULT '{}', value TEXT,
client_type TEXT NOT NULL DEFAULT 'confidential', PRIMARY KEY (user_id, login_provider, name)
client_secret_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
ALTER TABLE oauth_clients CREATE UNIQUE INDEX idx_users_normalized_user_name ON users(normalized_user_name);
ADD CONSTRAINT chk_oauth_clients_secret CREATE UNIQUE INDEX idx_users_normalized_email ON users(normalized_email);
CHECK ( CREATE UNIQUE INDEX idx_roles_normalized_name ON roles(normalized_name);
(client_type = 'confidential' AND client_secret_hash IS NOT NULL)
OR (client_type <> 'confidential')
);
CREATE TABLE oauth_codes ( -- OpenIddict (EF Core default tables)
id UUID PRIMARY KEY, CREATE TABLE "OpenIddictApplications" (
client_id UUID NOT NULL REFERENCES oauth_clients(id), "Id" UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id), "ApplicationType" TEXT,
code_hash TEXT NOT NULL, "ClientId" TEXT,
code_challenge TEXT NOT NULL, "ClientSecret" TEXT,
code_challenge_method TEXT NOT NULL DEFAULT 'S256', "ConsentType" TEXT,
expires_at TIMESTAMPTZ NOT NULL, "DisplayName" TEXT,
consumed_at TIMESTAMPTZ "DisplayNames" JSONB,
"Permissions" JSONB,
"PostLogoutRedirectUris" JSONB,
"Properties" JSONB,
"RedirectUris" JSONB,
"Requirements" JSONB,
"ConcurrencyToken" TEXT
); );
CREATE TABLE oauth_tokens ( CREATE TABLE "OpenIddictAuthorizations" (
id UUID PRIMARY KEY, "Id" UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id), "ApplicationId" UUID REFERENCES "OpenIddictApplications"("Id") ON DELETE SET NULL,
client_id UUID NOT NULL REFERENCES oauth_clients(id), "CreationDate" TIMESTAMPTZ,
access_token_hash TEXT NOT NULL, "Status" TEXT,
refresh_token_hash TEXT NOT NULL, "Subject" TEXT,
expires_at TIMESTAMPTZ NOT NULL, "Type" TEXT,
revoked_at TIMESTAMPTZ "Scopes" JSONB,
"Properties" JSONB,
"ConcurrencyToken" TEXT
); );
CREATE TABLE oidc_id_tokens ( CREATE TABLE "OpenIddictScopes" (
id UUID PRIMARY KEY, "Id" UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id), "Description" TEXT,
client_id UUID NOT NULL REFERENCES oauth_clients(id), "Descriptions" JSONB,
id_token_hash TEXT NOT NULL, "DisplayName" TEXT,
expires_at TIMESTAMPTZ NOT NULL "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 ( CREATE TABLE newsletter_lists (
id UUID PRIMARY KEY, 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, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
@ -73,9 +151,9 @@ CREATE TABLE newsletter_lists (
CREATE TABLE newsletter_subscriptions ( CREATE TABLE newsletter_subscriptions (
id UUID PRIMARY KEY, 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, 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', status TEXT NOT NULL DEFAULT 'pending',
preferences JSONB NOT NULL DEFAULT '{}'::jsonb, preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@ -85,7 +163,7 @@ CREATE TABLE newsletter_subscriptions (
CREATE TABLE email_verifications ( CREATE TABLE email_verifications (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
email TEXT NOT NULL, 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, token_hash TEXT NOT NULL,
purpose TEXT NOT NULL, purpose TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
@ -94,7 +172,7 @@ CREATE TABLE email_verifications (
CREATE TABLE unsubscribe_tokens ( CREATE TABLE unsubscribe_tokens (
id UUID PRIMARY KEY, 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, token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ consumed_at TIMESTAMPTZ
@ -109,17 +187,11 @@ CREATE TABLE audit_logs (
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE TABLE roles ( CREATE TABLE system_flags (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE, key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() value TEXT NOT NULL,
); updated_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)
); );
CREATE INDEX idx_newsletter_subscriptions_email CREATE INDEX idx_newsletter_subscriptions_email
@ -128,17 +200,5 @@ CREATE INDEX idx_newsletter_subscriptions_email
CREATE INDEX idx_newsletter_subscriptions_list_id CREATE INDEX idx_newsletter_subscriptions_list_id
ON 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 CREATE INDEX idx_email_verifications_email
ON 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: get:
summary: OAuth2 Authorization Endpoint summary: OAuth2 Authorization Endpoint
description: Authorization Code + PKCE flow description: Authorization Code + PKCE flow
security: []
parameters: parameters:
- in: query - in: query
name: client_id name: client_id
@ -49,20 +50,15 @@ paths:
/oauth/token: /oauth/token:
post: post:
summary: OAuth2 Token Endpoint summary: OAuth2 Token Endpoint
security: []
requestBody: requestBody:
required: true required: true
content: content:
application/x-www-form-urlencoded: application/x-www-form-urlencoded:
schema: schema:
type: object oneOf:
required: [grant_type, code, redirect_uri, client_id, code_verifier] - $ref: '#/components/schemas/AuthorizationCodeTokenRequest'
properties: - $ref: '#/components/schemas/RefreshTokenRequest'
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 }
responses: responses:
'200': '200':
description: Token response description: Token response
@ -88,6 +84,7 @@ paths:
/auth/register: /auth/register:
post: post:
summary: Register user summary: Register user
security: []
requestBody: requestBody:
required: true required: true
content: content:
@ -105,12 +102,13 @@ paths:
/auth/login: /auth/login:
post: post:
summary: API login summary: API login
security: []
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/x-www-form-urlencoded:
schema: schema:
$ref: '#/components/schemas/LoginRequest' $ref: '#/components/schemas/PasswordTokenRequest'
responses: responses:
'200': '200':
description: Token response description: Token response
@ -122,12 +120,13 @@ paths:
/auth/refresh: /auth/refresh:
post: post:
summary: Refresh token summary: Refresh token
security: []
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/x-www-form-urlencoded:
schema: schema:
$ref: '#/components/schemas/RefreshRequest' $ref: '#/components/schemas/RefreshTokenRequest'
responses: responses:
'200': '200':
description: Token response description: Token response
@ -157,6 +156,7 @@ paths:
/auth/password/forgot: /auth/password/forgot:
post: post:
summary: Request password reset summary: Request password reset
security: []
requestBody: requestBody:
required: true required: true
content: content:
@ -170,6 +170,7 @@ paths:
/auth/password/reset: /auth/password/reset:
post: post:
summary: Reset password summary: Reset password
security: []
requestBody: requestBody:
required: true required: true
content: content:
@ -183,11 +184,16 @@ paths:
/auth/email/verify: /auth/email/verify:
get: get:
summary: Verify email summary: Verify email
security: []
parameters: parameters:
- in: query - in: query
name: token name: token
required: true required: true
schema: { type: string } schema: { type: string }
- in: query
name: email
required: true
schema: { type: string, format: email }
responses: responses:
'200': '200':
description: Email verified description: Email verified
@ -208,6 +214,7 @@ paths:
/newsletter/subscribe: /newsletter/subscribe:
post: post:
summary: Subscribe (unauthenticated allowed) summary: Subscribe (unauthenticated allowed)
security: []
requestBody: requestBody:
required: true required: true
content: content:
@ -220,11 +227,12 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Subscription' $ref: '#/components/schemas/PendingSubscriptionResponse'
/newsletter/confirm: /newsletter/confirm:
get: get:
summary: Confirm subscription (double opt-in) summary: Confirm subscription (double opt-in)
security: []
parameters: parameters:
- in: query - in: query
name: token name: token
@ -241,6 +249,7 @@ paths:
/newsletter/unsubscribe: /newsletter/unsubscribe:
post: post:
summary: Unsubscribe single list summary: Unsubscribe single list
security: []
requestBody: requestBody:
required: true required: true
content: content:
@ -515,14 +524,25 @@ components:
email: { type: string, format: email } email: { type: string, format: email }
password: { type: string } password: { type: string }
LoginRequest: PasswordTokenRequest:
type: object type: object
required: [email, password, client_id] required: [grant_type, username, password]
properties: properties:
email: { type: string, format: email } grant_type: { type: string, enum: [password] }
username: { type: string, format: email }
password: { type: string } password: { type: string }
client_id: { type: string }
scope: { 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: RefreshRequest:
type: object type: object
@ -531,6 +551,14 @@ components:
refresh_token: { type: string } refresh_token: { type: string }
client_id: { 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: ForgotPasswordRequest:
type: object type: object
required: [email] required: [email]
@ -539,8 +567,9 @@ components:
ResetPasswordRequest: ResetPasswordRequest:
type: object type: object
required: [token, new_password] required: [email, token, new_password]
properties: properties:
email: { type: string, format: email }
token: { type: string } token: { type: string }
new_password: { type: string } new_password: { type: string }
@ -571,6 +600,13 @@ components:
preferences: { type: object } preferences: { type: object }
created_at: { type: string, format: date-time } created_at: { type: string, format: date-time }
PendingSubscriptionResponse:
allOf:
- $ref: '#/components/schemas/Subscription'
- type: object
properties:
confirm_token: { type: string }
Tenant: Tenant:
type: object type: object
properties: properties:

View File

@ -6,11 +6,20 @@
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" /> <ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
</Project> <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 using MemberCenter.Domain.Entities;
Console.WriteLine("Hello, World!"); 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;
}