Refactor installation process and update schema definitions for improved functionality
This commit is contained in:
parent
4631f82ee4
commit
8756010173
9
NuGet.Config
Normal file
9
NuGet.Config
Normal 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>
|
||||
@ -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) 建立 roles(admin, 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)
|
||||
|
||||
## 安全注意
|
||||
- 密碼必須符合強度規則
|
||||
|
||||
192
docs/SCHEMA.sql
192
docs/SCHEMA.sql
@ -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);
|
||||
|
||||
-- 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 (
|
||||
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
|
||||
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 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 "OpenIddictScopes" (
|
||||
"Id" UUID PRIMARY KEY,
|
||||
"Description" TEXT,
|
||||
"Descriptions" JSONB,
|
||||
"DisplayName" TEXT,
|
||||
"DisplayNames" JSONB,
|
||||
"Name" TEXT,
|
||||
"Properties" JSONB,
|
||||
"Resources" 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 "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);
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user