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') -- 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_codes ( CREATE TABLE "OpenIddictAuthorizations" (
id UUID PRIMARY KEY, "Id" UUID PRIMARY KEY,
client_id UUID NOT NULL REFERENCES oauth_clients(id), "ApplicationId" UUID REFERENCES "OpenIddictApplications"("Id") ON DELETE SET NULL,
user_id UUID NOT NULL REFERENCES users(id), "CreationDate" TIMESTAMPTZ,
code_hash TEXT NOT NULL, "Status" TEXT,
code_challenge TEXT NOT NULL, "Subject" TEXT,
code_challenge_method TEXT NOT NULL DEFAULT 'S256', "Type" TEXT,
expires_at TIMESTAMPTZ NOT NULL, "Scopes" JSONB,
consumed_at TIMESTAMPTZ "Properties" JSONB,
"ConcurrencyToken" TEXT
); );
CREATE TABLE oauth_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,
access_token_hash TEXT NOT NULL, "DisplayName" TEXT,
refresh_token_hash TEXT NOT NULL, "DisplayNames" JSONB,
expires_at TIMESTAMPTZ NOT NULL, "Name" TEXT,
revoked_at TIMESTAMPTZ "Properties" JSONB,
"Resources" JSONB,
"ConcurrencyToken" TEXT
); );
CREATE TABLE oidc_id_tokens ( CREATE TABLE "OpenIddictTokens" (
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), "AuthorizationId" UUID REFERENCES "OpenIddictAuthorizations"("Id") ON DELETE SET NULL,
id_token_hash TEXT NOT NULL, "CreationDate" TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL "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

@ -13,4 +13,13 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </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> </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;
}