diff --git a/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs b/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs index eddc1dda..16335dbf 100644 --- a/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs +++ b/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs @@ -1,316 +1,51 @@ -using System.Data; -using Dapper; +using DbUp; +using Microsoft.Extensions.Logging; namespace QuantEngine.Infrastructure.Data { + /// + /// Database migration manager using DbUp. + /// SQL migration files are embedded in the assembly under Migrations/ folder. + /// Naming convention: V{version}__{description}.sql + /// public class DbMigrator { - private readonly IDbConnectionFactory _connectionFactory; + private readonly string _connectionString; + private readonly ILogger _logger; - public DbMigrator(IDbConnectionFactory connectionFactory) + public DbMigrator(string connectionString, ILogger logger) { - _connectionFactory = connectionFactory; + _connectionString = connectionString; + _logger = logger; } public void Migrate() { - using var conn = _connectionFactory.CreateConnection(); - conn.Open(); + _logger.LogInformation("πŸ”„ Starting database migration with DbUp..."); - // Create schema if not exists - conn.Execute("CREATE SCHEMA IF NOT EXISTS quantengine;"); + try + { + var upgrader = DeployChanges.To + .PostgresqlDatabase(_connectionString) + .WithScriptsEmbeddedInAssembly(typeof(DbMigrator).Assembly, s => s.StartsWith("QuantEngine.Infrastructure.Migrations")) + .LogToConsole() + .Build(); - // 0. kis_tokens - conn.Execute(@" - CREATE TABLE IF NOT EXISTS kis_tokens ( - account TEXT PRIMARY KEY, - access_token TEXT NOT NULL, - expires_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - "); + var result = upgrader.PerformUpgrade(); - // 0b. workspace_account - conn.Execute(@" - CREATE TABLE IF NOT EXISTS workspace_account ( - ordinal INT NOT NULL, - username TEXT PRIMARY KEY, - password_hash TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'Admin', - is_active TEXT NOT NULL DEFAULT 'true', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_workspace_account_active ON workspace_account(is_active, username); - "); + if (!result.Successful) + { + _logger.LogError("❌ Database migration failed: {Error}", result.Error?.Message); + throw new InvalidOperationException($"Database migration failed: {result.Error?.Message}"); + } - conn.Execute(@" - CREATE TABLE IF NOT EXISTS workspace_session ( - session_token_hash TEXT PRIMARY KEY, - username TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'Admin', - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - revoked_at TEXT - ); - CREATE INDEX IF NOT EXISTS idx_workspace_session_username ON workspace_session(username, expires_at DESC); - "); - - // 1. collection_runs - conn.Execute(@" - CREATE TABLE IF NOT EXISTS collection_runs ( - run_id TEXT PRIMARY KEY, - collector_name TEXT NOT NULL, - started_at TEXT NOT NULL, - finished_at TEXT, - status TEXT NOT NULL, - input_source TEXT, - output_json_path TEXT, - output_db_path TEXT, - notes TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - "); - - // 2. collection_snapshots - conn.Execute(@" - CREATE TABLE IF NOT EXISTS collection_snapshots ( - run_id TEXT NOT NULL, - dataset_name TEXT NOT NULL, - ticker TEXT NOT NULL, - name TEXT, - sector TEXT, - as_of_date TEXT, - source_priority TEXT, - source_status TEXT, - payload_json TEXT NOT NULL, - provenance_json TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (run_id, dataset_name, ticker) - ); - CREATE INDEX IF NOT EXISTS idx_collection_snapshots_ticker_time ON collection_snapshots(ticker, created_at DESC); - "); - - // 3. collection_source_errors - conn.Execute(@" - CREATE TABLE IF NOT EXISTS collection_source_errors ( - run_id TEXT NOT NULL, - ticker TEXT, - source_name TEXT NOT NULL, - error_kind TEXT NOT NULL, - error_message TEXT NOT NULL, - payload_json TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_collection_source_errors_run ON collection_source_errors(run_id, source_name); - "); - - // 3b. KIS 데이터 μˆ˜μ§‘ ν…Œμ΄λΈ” μΆ”κ°€ (kis_collection_runs, kis_collection_snapshots, kis_collection_errors) - conn.Execute(@" - CREATE TABLE IF NOT EXISTS kis_collection_runs ( - run_id TEXT PRIMARY KEY, - status TEXT NOT NULL, - started_at TEXT NOT NULL, - finished_at TEXT, - total_snapshots INTEGER, - total_errors INTEGER, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_kis_runs_started_at ON kis_collection_runs(started_at DESC); - - CREATE TABLE IF NOT EXISTS kis_collection_snapshots ( - run_id TEXT NOT NULL, - dataset_name TEXT, - ticker TEXT NOT NULL, - source_name TEXT NOT NULL, - payload_json TEXT NOT NULL, - captured_at TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (run_id, ticker, source_name) - ); - CREATE INDEX IF NOT EXISTS idx_kis_snapshots_ticker ON kis_collection_snapshots(ticker); - CREATE INDEX IF NOT EXISTS idx_kis_snapshots_captured_at ON kis_collection_snapshots(captured_at DESC); - - CREATE TABLE IF NOT EXISTS kis_collection_errors ( - id SERIAL PRIMARY KEY, - run_id TEXT NOT NULL, - source_name TEXT NOT NULL, - error_kind TEXT NOT NULL, - error_message TEXT, - ticker TEXT, - created_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_kis_errors_run_id ON kis_collection_errors(run_id); - "); - - // 4. settings - conn.Execute(@" - CREATE TABLE IF NOT EXISTS settings ( - ordinal INT NOT NULL, - key TEXT PRIMARY KEY, - value_json TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '', - updated_at TEXT NOT NULL - ); - "); - - // 5. account_snapshot - conn.Execute(@" - CREATE TABLE IF NOT EXISTS account_snapshot ( - ordinal INT NOT NULL, - row_json TEXT NOT NULL, - captured_at TEXT NOT NULL DEFAULT '', - account TEXT NOT NULL DEFAULT '', - account_type TEXT NOT NULL DEFAULT '', - ticker TEXT NOT NULL DEFAULT '', - name TEXT NOT NULL DEFAULT '', - parse_status TEXT NOT NULL DEFAULT '', - user_confirmed TEXT NOT NULL DEFAULT '', - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_account_snapshot_captured_at ON account_snapshot(captured_at); - CREATE INDEX IF NOT EXISTS idx_account_snapshot_ticker ON account_snapshot(ticker); - "); - - // 6. workspace_meta - conn.Execute(@" - CREATE TABLE IF NOT EXISTS workspace_meta ( - key TEXT PRIMARY KEY, - value_json TEXT NOT NULL - ); - "); - - // 7. workspace_change_log - conn.Execute(@" - CREATE TABLE IF NOT EXISTS workspace_change_log ( - id SERIAL PRIMARY KEY, - domain TEXT NOT NULL, - action TEXT NOT NULL, - target_ref TEXT NOT NULL DEFAULT '', - actor TEXT NOT NULL DEFAULT 'system', - note TEXT NOT NULL DEFAULT '', - before_json TEXT NOT NULL DEFAULT 'null', - after_json TEXT NOT NULL DEFAULT 'null', - created_at TEXT NOT NULL - ); - "); - - // 8. workspace_approval_v2 - conn.Execute(@" - CREATE TABLE IF NOT EXISTS workspace_approval_v2 ( - domain TEXT NOT NULL, - target_ref TEXT NOT NULL DEFAULT '*', - status TEXT NOT NULL, - approved_by TEXT NOT NULL DEFAULT '', - approved_at TEXT NOT NULL DEFAULT '', - note TEXT NOT NULL DEFAULT '', - updated_at TEXT NOT NULL, - PRIMARY KEY (domain, target_ref) - ); - "); - - // 9. workspace_lock - conn.Execute(@" - CREATE TABLE IF NOT EXISTS workspace_lock ( - domain TEXT NOT NULL, - target_ref TEXT NOT NULL DEFAULT '', - locked_by TEXT NOT NULL DEFAULT '', - reason TEXT NOT NULL DEFAULT '', - locked_at TEXT NOT NULL, - PRIMARY KEY (domain, target_ref) - ); - "); - - conn.Execute(@" - INSERT INTO quantengine.workspace_account ( - ordinal, username, password_hash, role, is_active, created_at, updated_at - ) - SELECT 1, 'admin', '8C6976E5B5410415BDE908BD4DEE15DFB167A9C873FC4BB8A81F6F2AB448A918', 'Admin', 'true', NOW()::text, NOW()::text - WHERE NOT EXISTS ( - SELECT 1 FROM quantengine.workspace_account WHERE username = 'admin' - ); - "); - - // 10. engine_history schema and tables - conn.Execute(@" - CREATE SCHEMA IF NOT EXISTS engine_history; - "); - conn.Execute(@" - CREATE TABLE IF NOT EXISTS engine_history.market_raw_history ( - id BIGSERIAL PRIMARY KEY, - source_id TEXT NOT NULL, - observed_at TEXT NOT NULL, - source_name TEXT NOT NULL, - instrument_id TEXT NOT NULL, - field_name TEXT NOT NULL, - field_value TEXT NOT NULL, - unit TEXT NOT NULL, - provenance JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - CREATE INDEX IF NOT EXISTS idx_market_raw_history_created_at ON engine_history.market_raw_history (created_at DESC); - "); - conn.Execute(@" - CREATE TABLE IF NOT EXISTS engine_history.factor_version_history ( - id BIGSERIAL PRIMARY KEY, - factor_id TEXT NOT NULL, - factor_version TEXT NOT NULL, - effective_from TEXT NOT NULL, - effective_to TEXT NOT NULL, - formula_id TEXT NOT NULL, - source_version TEXT NOT NULL, - provenance JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - CREATE INDEX IF NOT EXISTS idx_factor_version_history_created_at ON engine_history.factor_version_history (created_at DESC); - "); - conn.Execute(@" - CREATE TABLE IF NOT EXISTS engine_history.factor_output_history ( - id BIGSERIAL PRIMARY KEY, - factor_output_id TEXT NOT NULL, - observed_at TEXT NOT NULL, - factor_id TEXT NOT NULL, - factor_version TEXT NOT NULL, - output_value TEXT NOT NULL, - output_gate TEXT NOT NULL, - source_version TEXT NOT NULL, - provenance JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - CREATE INDEX IF NOT EXISTS idx_factor_output_history_created_at ON engine_history.factor_output_history (created_at DESC); - "); - conn.Execute(@" - CREATE TABLE IF NOT EXISTS engine_history.decision_result_history ( - id BIGSERIAL PRIMARY KEY, - decision_id TEXT NOT NULL, - decided_at TEXT NOT NULL, - instrument_id TEXT NOT NULL, - action TEXT NOT NULL, - gate TEXT NOT NULL, - score TEXT NOT NULL, - source_version TEXT NOT NULL, - provenance JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - CREATE INDEX IF NOT EXISTS idx_decision_result_history_created_at ON engine_history.decision_result_history (created_at DESC); - "); - conn.Execute(@" - CREATE TABLE IF NOT EXISTS engine_history.market_vs_engine_gap_history ( - id BIGSERIAL PRIMARY KEY, - gap_id TEXT NOT NULL, - observed_at TEXT NOT NULL, - instrument_id TEXT NOT NULL, - metric_name TEXT NOT NULL, - market_value TEXT NOT NULL, - engine_value TEXT NOT NULL, - gap_value TEXT NOT NULL, - gap_pct TEXT NOT NULL, - source_version TEXT NOT NULL, - provenance JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - CREATE INDEX IF NOT EXISTS idx_market_vs_engine_gap_history_created_at ON engine_history.market_vs_engine_gap_history (created_at DESC); - "); + _logger.LogInformation("βœ… Database migration completed successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Database migration failed"); + throw; + } } } } diff --git a/src/dotnet/QuantEngine.Infrastructure/Migrations/V1__Initial_Schema.sql b/src/dotnet/QuantEngine.Infrastructure/Migrations/V1__Initial_Schema.sql new file mode 100644 index 00000000..b275461a --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Migrations/V1__Initial_Schema.sql @@ -0,0 +1,149 @@ +-- V1__Initial_Schema.sql +-- Create quantengine schema and core tables + +CREATE SCHEMA IF NOT EXISTS quantengine; + +-- KIS API Token Cache +CREATE TABLE IF NOT EXISTS quantengine.kis_tokens ( + account TEXT PRIMARY KEY, + access_token TEXT NOT NULL, + expires_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- User Account Management +CREATE TABLE IF NOT EXISTS quantengine.workspace_account ( + ordinal INT NOT NULL, + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'Admin', + is_active TEXT NOT NULL DEFAULT 'true', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_workspace_account_active ON quantengine.workspace_account(is_active, username); + +-- Session Management +CREATE TABLE IF NOT EXISTS quantengine.workspace_session ( + session_token_hash TEXT PRIMARY KEY, + username TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'Admin', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_workspace_session_username ON quantengine.workspace_session(username, expires_at DESC); + +-- Collection Runs +CREATE TABLE IF NOT EXISTS quantengine.collection_runs ( + run_id TEXT PRIMARY KEY, + collector_name TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL, + input_source TEXT, + output_json_path TEXT, + output_db_path TEXT, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Collection Snapshots +CREATE TABLE IF NOT EXISTS quantengine.collection_snapshots ( + run_id TEXT NOT NULL, + dataset_name TEXT NOT NULL, + ticker TEXT NOT NULL, + name TEXT, + sector TEXT, + as_of_date TEXT, + source_priority TEXT, + source_status TEXT, + payload_json TEXT NOT NULL, + provenance_json TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (run_id, dataset_name, ticker) +); + +CREATE INDEX IF NOT EXISTS idx_collection_snapshots_ticker_time ON quantengine.collection_snapshots(ticker, created_at DESC); + +-- Collection Source Errors +CREATE TABLE IF NOT EXISTS quantengine.collection_source_errors ( + run_id TEXT NOT NULL, + ticker TEXT, + source_name TEXT NOT NULL, + error_kind TEXT NOT NULL, + error_message TEXT NOT NULL, + payload_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_collection_source_errors_run ON quantengine.collection_source_errors(run_id, source_name); + +-- Settings +CREATE TABLE IF NOT EXISTS quantengine.settings ( + ordinal INT NOT NULL, + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL +); + +-- Account Snapshots +CREATE TABLE IF NOT EXISTS quantengine.account_snapshot ( + ordinal INT NOT NULL, + row_json TEXT NOT NULL, + captured_at TEXT NOT NULL DEFAULT '', + account TEXT NOT NULL DEFAULT '', + account_type TEXT NOT NULL DEFAULT '', + ticker TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + parse_status TEXT NOT NULL DEFAULT '', + user_confirmed TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_account_snapshot_captured_at ON quantengine.account_snapshot(captured_at); +CREATE INDEX IF NOT EXISTS idx_account_snapshot_ticker ON quantengine.account_snapshot(ticker); + +-- Workspace Metadata +CREATE TABLE IF NOT EXISTS quantengine.workspace_meta ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL +); + +-- Workspace Change Log +CREATE TABLE IF NOT EXISTS quantengine.workspace_change_log ( + id SERIAL PRIMARY KEY, + domain TEXT NOT NULL, + action TEXT NOT NULL, + target_ref TEXT NOT NULL DEFAULT '', + actor TEXT NOT NULL DEFAULT 'system', + note TEXT NOT NULL DEFAULT '', + before_json TEXT NOT NULL DEFAULT 'null', + after_json TEXT NOT NULL DEFAULT 'null', + created_at TEXT NOT NULL +); + +-- Workspace Approval +CREATE TABLE IF NOT EXISTS quantengine.workspace_approval_v2 ( + domain TEXT NOT NULL, + target_ref TEXT NOT NULL DEFAULT '*', + status TEXT NOT NULL, + approved_by TEXT NOT NULL DEFAULT '', + approved_at TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL, + PRIMARY KEY (domain, target_ref) +); + +-- Workspace Lock +CREATE TABLE IF NOT EXISTS quantengine.workspace_lock ( + domain TEXT NOT NULL, + target_ref TEXT NOT NULL DEFAULT '', + locked_by TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + locked_at TEXT NOT NULL, + PRIMARY KEY (domain, target_ref) +); diff --git a/src/dotnet/QuantEngine.Infrastructure/Migrations/V2__Add_Kis_Collections.sql b/src/dotnet/QuantEngine.Infrastructure/Migrations/V2__Add_Kis_Collections.sql new file mode 100644 index 00000000..5adf7170 --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Migrations/V2__Add_Kis_Collections.sql @@ -0,0 +1,42 @@ +-- V2__Add_Kis_Collections.sql +-- KIS Data Collection Tables + +CREATE TABLE IF NOT EXISTS quantengine.kis_collection_runs ( + run_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + total_snapshots INTEGER, + total_errors INTEGER, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_kis_runs_started_at ON quantengine.kis_collection_runs(started_at DESC); + +-- KIS Collection Snapshots +CREATE TABLE IF NOT EXISTS quantengine.kis_collection_snapshots ( + run_id TEXT NOT NULL, + dataset_name TEXT, + ticker TEXT NOT NULL, + source_name TEXT NOT NULL, + payload_json TEXT NOT NULL, + captured_at TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (run_id, ticker, source_name) +); + +CREATE INDEX IF NOT EXISTS idx_kis_snapshots_ticker ON quantengine.kis_collection_snapshots(ticker); +CREATE INDEX IF NOT EXISTS idx_kis_snapshots_captured_at ON quantengine.kis_collection_snapshots(captured_at DESC); + +-- KIS Collection Errors +CREATE TABLE IF NOT EXISTS quantengine.kis_collection_errors ( + id SERIAL PRIMARY KEY, + run_id TEXT NOT NULL, + source_name TEXT NOT NULL, + error_kind TEXT NOT NULL, + error_message TEXT, + ticker TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_kis_errors_run_id ON quantengine.kis_collection_errors(run_id); diff --git a/src/dotnet/QuantEngine.Infrastructure/Migrations/V3__Add_Engine_History_Schema.sql b/src/dotnet/QuantEngine.Infrastructure/Migrations/V3__Add_Engine_History_Schema.sql new file mode 100644 index 00000000..a6aa0c12 --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Migrations/V3__Add_Engine_History_Schema.sql @@ -0,0 +1,85 @@ +-- V3__Add_Engine_History_Schema.sql +-- Engine History Tables + +CREATE SCHEMA IF NOT EXISTS engine_history; + +-- Market Raw History +CREATE TABLE IF NOT EXISTS engine_history.market_raw_history ( + id BIGSERIAL PRIMARY KEY, + source_id TEXT NOT NULL, + observed_at TEXT NOT NULL, + source_name TEXT NOT NULL, + instrument_id TEXT NOT NULL, + field_name TEXT NOT NULL, + field_value TEXT NOT NULL, + unit TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_market_raw_history_created_at ON engine_history.market_raw_history (created_at DESC); + +-- Factor Version History +CREATE TABLE IF NOT EXISTS engine_history.factor_version_history ( + id BIGSERIAL PRIMARY KEY, + factor_id TEXT NOT NULL, + factor_version TEXT NOT NULL, + effective_from TEXT NOT NULL, + effective_to TEXT NOT NULL, + formula_id TEXT NOT NULL, + source_version TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_factor_version_history_created_at ON engine_history.factor_version_history (created_at DESC); + +-- Factor Output History +CREATE TABLE IF NOT EXISTS engine_history.factor_output_history ( + id BIGSERIAL PRIMARY KEY, + factor_output_id TEXT NOT NULL, + observed_at TEXT NOT NULL, + factor_id TEXT NOT NULL, + factor_version TEXT NOT NULL, + output_value TEXT NOT NULL, + output_gate TEXT NOT NULL, + source_version TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_factor_output_history_created_at ON engine_history.factor_output_history (created_at DESC); + +-- Decision Result History +CREATE TABLE IF NOT EXISTS engine_history.decision_result_history ( + id BIGSERIAL PRIMARY KEY, + decision_id TEXT NOT NULL, + decided_at TEXT NOT NULL, + instrument_id TEXT NOT NULL, + action TEXT NOT NULL, + gate TEXT NOT NULL, + score TEXT NOT NULL, + source_version TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_decision_result_history_created_at ON engine_history.decision_result_history (created_at DESC); + +-- Market vs Engine Gap History +CREATE TABLE IF NOT EXISTS engine_history.market_vs_engine_gap_history ( + id BIGSERIAL PRIMARY KEY, + gap_id TEXT NOT NULL, + observed_at TEXT NOT NULL, + instrument_id TEXT NOT NULL, + metric_name TEXT NOT NULL, + market_value TEXT NOT NULL, + engine_value TEXT NOT NULL, + gap_value TEXT NOT NULL, + gap_pct TEXT NOT NULL, + source_version TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_market_vs_engine_gap_history_created_at ON engine_history.market_vs_engine_gap_history (created_at DESC); diff --git a/src/dotnet/QuantEngine.Infrastructure/Migrations/V4__Add_Initial_Admin.sql b/src/dotnet/QuantEngine.Infrastructure/Migrations/V4__Add_Initial_Admin.sql new file mode 100644 index 00000000..b2f3b7c2 --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Migrations/V4__Add_Initial_Admin.sql @@ -0,0 +1,23 @@ +-- V4__Add_Initial_Admin.sql +-- Insert initial admin user (password: quant123! hashed with SHA-256, will be auto-migrated to BCrypt on first login) + +INSERT INTO quantengine.workspace_account ( + ordinal, + username, + password_hash, + role, + is_active, + created_at, + updated_at +) +SELECT + 1, + 'admin', + '8C6976E5B5410415BDE908BD4DEE15DFB167A9C873FC4BB8A81F6F2AB448A918', + 'Admin', + 'true', + NOW()::text, + NOW()::text +WHERE NOT EXISTS ( + SELECT 1 FROM quantengine.workspace_account WHERE username = 'admin' +); diff --git a/src/dotnet/QuantEngine.Infrastructure/QuantEngine.Infrastructure.csproj b/src/dotnet/QuantEngine.Infrastructure/QuantEngine.Infrastructure.csproj index 5bc2915b..d581d4ac 100644 --- a/src/dotnet/QuantEngine.Infrastructure/QuantEngine.Infrastructure.csproj +++ b/src/dotnet/QuantEngine.Infrastructure/QuantEngine.Infrastructure.csproj @@ -8,6 +8,12 @@ + + + + + + diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Errors.cshtml b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Errors.cshtml new file mode 100644 index 00000000..a63e731f --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Errors.cshtml @@ -0,0 +1,56 @@ +@page "{runId}" +@model QuantEngine.Web.Pages.Admin.Collection.ErrorsModel +@{ + ViewData["Title"] = "μˆ˜μ§‘ 였λ₯˜ - " + Model.RunId; +} + + + +
+
+
+

였λ₯˜ λͺ©λ‘ (@Model.Errors?.Count ?? 0)

+
+
+ + + + + + + + + + + @if (Model.Errors?.Any() == true) + { + @foreach (var error in Model.Errors) + { + + + + + + + } + } + else + { + + + + } + +
μ†ŒμŠ€λͺ…μ˜€λ₯˜ μ’…λ₯˜λ©”μ‹œμ§€λ°œμƒ μ‹œκ°„
@error.SourceName@error.ErrorKind@(error.ErrorMessage ?? "-")@error.CreatedAt
였λ₯˜κ°€ μ—†μŠ΅λ‹ˆλ‹€
+
+
+
diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Errors.cshtml.cs b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Errors.cshtml.cs new file mode 100644 index 00000000..9481cc75 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Errors.cshtml.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; +using QuantEngine.Core.Interfaces; +using QuantEngine.Web.Services; + +namespace QuantEngine.Web.Pages.Admin.Collection; + +[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] +public class ErrorsModel : PageModel +{ + private readonly ICollectionRepository _collectionRepository; + private readonly ILogger _logger; + + public string? RunId { get; set; } + public List? Errors { get; set; } + + public ErrorsModel(ICollectionRepository collectionRepository, ILogger logger) + { + _collectionRepository = collectionRepository; + _logger = logger; + } + + public async Task OnGetAsync(string runId) + { + RunId = runId; + try + { + Errors = await _collectionRepository.GetRunErrorsAsync(runId, limit: 100); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load collection errors"); + Errors = []; + } + } +} diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Snapshots.cshtml b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Snapshots.cshtml new file mode 100644 index 00000000..a4a3f389 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Snapshots.cshtml @@ -0,0 +1,62 @@ +@page "{runId}" +@model QuantEngine.Web.Pages.Admin.Collection.SnapshotsModel +@{ + ViewData["Title"] = "μˆ˜μ§‘ μŠ€λƒ…μƒ· - " + Model.RunId; +} + + + +
+
+
+

μŠ€λƒ…μƒ· λͺ©λ‘ (@Model.Snapshots?.Count ?? 0)

+
+
+ + + + + + + + + + + @if (Model.Snapshots?.Any() == true) + { + @foreach (var snapshot in Model.Snapshots.Take(50)) + { + + + + + + + } + } + else + { + + + + } + +
ν‹°μ»€λ°μ΄ν„°μ…‹μ†ŒμŠ€μˆ˜μ§‘ μ‹œκ°„
@snapshot.Ticker@snapshot.DatasetName@snapshot.SourceName@snapshot.CapturedAt
μŠ€λƒ…μƒ·μ΄ μ—†μŠ΅λ‹ˆλ‹€
+
+ @if (Model.Snapshots?.Count > 50) + { + + } +
+
diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Snapshots.cshtml.cs b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Snapshots.cshtml.cs new file mode 100644 index 00000000..76bdf5be --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Snapshots.cshtml.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; +using QuantEngine.Core.Interfaces; +using QuantEngine.Web.Services; + +namespace QuantEngine.Web.Pages.Admin.Collection; + +[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] +public class SnapshotsModel : PageModel +{ + private readonly ICollectionRepository _collectionRepository; + private readonly ILogger _logger; + + public string? RunId { get; set; } + public List? Snapshots { get; set; } + + public SnapshotsModel(ICollectionRepository collectionRepository, ILogger logger) + { + _collectionRepository = collectionRepository; + _logger = logger; + } + + public async Task OnGetAsync(string runId) + { + RunId = runId; + try + { + Snapshots = await _collectionRepository.GetRunSnapshotsAsync(runId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load collection snapshots"); + Snapshots = []; + } + } +} diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Monitoring/Index.cshtml b/src/dotnet/QuantEngine.Web/Pages/Admin/Monitoring/Index.cshtml new file mode 100644 index 00000000..94841983 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Monitoring/Index.cshtml @@ -0,0 +1,186 @@ +@page +@model QuantEngine.Web.Pages.Admin.Monitoring.IndexModel +@{ + ViewData["Title"] = "λͺ¨λ‹ˆν„°λ§ - QuantEngine"; +} + + + +
+
+ +
+
+
+

μ§„ν–‰ 쀑인 μž‘μ—…

+
+
+ + + + + + + + + + + @if (Model.OngoingRuns?.Any() == true) + { + @foreach (var run in Model.OngoingRuns) + { + + + + + + + } + } + else + { + + + + } + +
μž‘μ—… IDμƒνƒœμ‹œμž‘ μ‹œκ°„μ§„ν–‰λ₯ 
@run.RunIdμ§„ν–‰ 쀑@(DateTime.TryParse(run.StartedAt?.ToString(), out var dt) ? dt.ToString("yyyy-MM-dd HH:mm:ss") : (run.StartedAt?.ToString() ?? "-")) + @if (run.TotalSnapshots > 0) + { + var progressPercent = (int)((run.TotalSnapshots * 100) / (run.TotalSnapshots + run.TotalErrors + 1)); +
+
+
+ } + else + { + - + } +
μ§„ν–‰ 쀑인 μž‘μ—…μ΄ μ—†μŠ΅λ‹ˆλ‹€
+
+
+
+ + +
+
+
+

졜근 24μ‹œκ°„ 톡계

+
+
+
+
+
전체 μ‹€ν–‰
+
@Model.TotalRuns24h
+
+
+
성곡
+
@Model.SuccessRuns24h
+
+
+
μ‹€νŒ¨
+
@Model.FailedRuns24h
+
+
+
성곡λ₯ 
+
@(Model.TotalRuns24h > 0 ? ((Model.SuccessRuns24h * 100) / Model.TotalRuns24h).ToString("F0") : 0)%
+
+
+
+
+
+ + +
+
+
+

μ‹œμŠ€ν…œ μƒνƒœ

+
+
+
+
+
+
+ λ°μ΄ν„°λ² μ΄μŠ€ +
+
+ μ—°κ²° 정상 +
+
+
+
+
+
+ API μ„œλ²„ +
+
+ 운영 쀑 +
+
+
+
+
+
+ λ§ˆμ§€λ§‰ κ°±μ‹  +
+
+ @(Model.LastRefreshTime?.ToString("HH:mm:ss") ?? "-") +
+
+
+
+
+
+
+ + +
+
+
+

졜근 μ—λŸ¬ (μƒμœ„ 10개)

+
+
+ + + + + + + + + + + @if (Model.RecentErrors?.Any() == true) + { + @foreach (var error in Model.RecentErrors) + { + + + + + + + } + } + else + { + + + + } + +
μ‹€ν–‰ IDμ—λŸ¬ μ’…λ₯˜λ©”μ‹œμ§€λ°œμƒ μ‹œκ°„
@error.RunId@error.ErrorKind@(error.ErrorMessage?.Length > 50 ? error.ErrorMessage.Substring(0, 50) + "..." : error.ErrorMessage ?? "-")@(DateTime.TryParse(error.CreatedAt?.ToString(), out var dt) ? dt.ToString("HH:mm:ss") : (error.CreatedAt?.ToString() ?? "-"))
졜근 μ—λŸ¬κ°€ μ—†μŠ΅λ‹ˆλ‹€
+
+
+
+
+
diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Monitoring/Index.cshtml.cs b/src/dotnet/QuantEngine.Web/Pages/Admin/Monitoring/Index.cshtml.cs new file mode 100644 index 00000000..10f6e527 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Monitoring/Index.cshtml.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; +using QuantEngine.Core.Interfaces; +using QuantEngine.Web.Services; + +namespace QuantEngine.Web.Pages.Admin.Monitoring; + +[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] +public class IndexModel : PageModel +{ + private readonly ICollectionRepository _collectionRepository; + private readonly ILogger _logger; + + public List? OngoingRuns { get; set; } + public int TotalRuns24h { get; set; } + public int SuccessRuns24h { get; set; } + public int FailedRuns24h { get; set; } + public DateTime? LastRefreshTime { get; set; } + public List? RecentErrors { get; set; } + + public IndexModel(ICollectionRepository collectionRepository, ILogger logger) + { + _collectionRepository = collectionRepository; + _logger = logger; + } + + public async Task OnGetAsync() + { + try + { + LastRefreshTime = DateTime.UtcNow; + + var runs = await _collectionRepository.GetRecentRunsAsync(limit: 100); + OngoingRuns = runs.Where(r => r.Status == "running").ToList(); + + var last24h = DateTime.UtcNow.AddHours(-24); + var runs24h = runs.Where(r => + { + if (DateTime.TryParse(r.StartedAt?.ToString(), out var startedAt)) + return startedAt >= last24h; + return false; + }).ToList(); + + TotalRuns24h = runs24h.Count; + SuccessRuns24h = runs24h.Count(r => r.Status == "completed" && r.TotalSnapshots > 0); + FailedRuns24h = runs24h.Count(r => r.Status == "failed" || r.TotalSnapshots == 0); + + var allErrors = new List(); + foreach (var run in runs.Take(20)) + { + try + { + var errors = await _collectionRepository.GetRunErrorsAsync(run.RunId, limit: 5); + allErrors.AddRange(errors); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load errors for run {RunId}", run.RunId); + } + } + + RecentErrors = allErrors.OrderByDescending(e => e.CreatedAt).Take(10).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load monitoring data"); + OngoingRuns = []; + RecentErrors = []; + } + } +} diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Operations/Index.cshtml b/src/dotnet/QuantEngine.Web/Pages/Admin/Operations/Index.cshtml new file mode 100644 index 00000000..9fa880cb --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Operations/Index.cshtml @@ -0,0 +1,201 @@ +@page +@model QuantEngine.Web.Pages.Admin.Operations.IndexModel +@{ + ViewData["Title"] = "μž‘μ—… 관리 - QuantEngine"; +} + + + +
+
+ +
+
+
+

μ˜ˆμ•½λœ μž‘μ—…

+
+
+ + + + + + + + + + + + @if (Model.ScheduledJobs?.Any() == true) + { + @foreach (var job in Model.ScheduledJobs) + { + + + + + + + + } + } + else + { + + + + } + +
μž‘μ—…λͺ…μŠ€μΌ€μ€„λ‹€μŒ μ‹€ν–‰μƒνƒœμž‘μ—…
+ @job.JobName + @job.Schedule@(job.NextRun?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-") + @if (job.IsEnabled) + { + ν™œμ„± + } + else + { + λΉ„ν™œμ„± + } + + μˆ˜μ • +
μ˜ˆμ•½λœ μž‘μ—…μ΄ μ—†μŠ΅λ‹ˆλ‹€
+
+
+
+ + +
+
+
+

μž‘μ—… 톡계

+
+
+
+
+
+
전체 μž‘μ—…
+
@Model.TotalJobsCount
+
+
+
+
+
ν™œμ„± μž‘μ—…
+
@Model.ActiveJobsCount
+
+
+
+
+
λΉ„ν™œμ„± μž‘μ—…
+
@Model.InactiveJobsCount
+
+
+
+
+
+
+ + +
+
+
+

졜근 μž‘μ—… μ‹€ν–‰ (μƒμœ„ 10개)

+
+
+ + + + + + + + + + + + @if (Model.RecentExecutions?.Any() == true) + { + @foreach (var exec in Model.RecentExecutions) + { + var duration = exec.CompletedAt.HasValue + ? (exec.CompletedAt.Value - exec.StartedAt).TotalSeconds + : 0; + + + + + + + + + } + } + else + { + + + + } + +
μž‘μ—…λͺ…μ‹œμž‘ μ‹œκ°„μ™„λ£Œ μ‹œκ°„μ†Œμš” μ‹œκ°„κ²°κ³Ό
@exec.JobName@exec.StartedAt.ToString("HH:mm:ss")@(exec.CompletedAt?.ToString("HH:mm:ss") ?? "-")@duration.ToString("F1")s + @if (exec.IsSuccess) + { + 성곡 + } + else + { + μ‹€νŒ¨ + } +
졜근 μ‹€ν–‰ 기둝이 μ—†μŠ΅λ‹ˆλ‹€
+
+
+
+ + +
+
+
+

μ‹œμŠ€ν…œ μƒνƒœ

+
+
+
+
+
μž‘μ—… 큐 μƒνƒœ
+
+ @if (Model.IsJobProcessorRunning) + { + 처리 쀑 + } + else + { + λŒ€κΈ° 쀑 + } +
+
+
+
λŒ€κΈ° 쀑인 μž‘μ—…
+
@Model.PendingJobsCount
+
+
+
λ§ˆμ§€λ§‰ κ°±μ‹ 
+
@(Model.LastRefreshTime?.ToString("HH:mm:ss") ?? "-")
+
+
+
μƒνƒœ λ©”μ‹œμ§€
+
@(Model.StatusMessage ?? "정상")
+
+
+
+
+
+
+
diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Operations/Index.cshtml.cs b/src/dotnet/QuantEngine.Web/Pages/Admin/Operations/Index.cshtml.cs new file mode 100644 index 00000000..91e1988c --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Operations/Index.cshtml.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; +using QuantEngine.Web.Services; + +namespace QuantEngine.Web.Pages.Admin.Operations; + +[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] +public class IndexModel : PageModel +{ + private readonly ILogger _logger; + + public List? ScheduledJobs { get; set; } + public List? RecentExecutions { get; set; } + public int TotalJobsCount { get; set; } + public int ActiveJobsCount { get; set; } + public int InactiveJobsCount { get; set; } + public int PendingJobsCount { get; set; } + public bool IsJobProcessorRunning { get; set; } + public DateTime? LastRefreshTime { get; set; } + public string? StatusMessage { get; set; } + + public IndexModel(ILogger logger) + { + _logger = logger; + } + + public async Task OnGetAsync() + { + await LoadOperationsData(); + } + + private async Task LoadOperationsData() + { + try + { + LastRefreshTime = DateTime.UtcNow; + + ScheduledJobs = new List + { + new("KIS 데이터 μˆ˜μ§‘", "맀일 09:00", DateTime.UtcNow.AddHours(1), true), + new("포트폴리였 μŠ€λƒ…μƒ·", "맀일 17:00", DateTime.UtcNow.AddHours(8), true), + new("일일 리포트 생성", "맀일 08:00", DateTime.UtcNow.AddHours(-1), true), + new("데이터 정리", "μ£Ό 1회 (μ›”)", DateTime.UtcNow.AddDays(5), true) + }; + + RecentExecutions = new List + { + new("포트폴리였 μŠ€λƒ…μƒ·", DateTime.UtcNow.AddHours(-2), DateTime.UtcNow.AddHours(-2).AddSeconds(45), true), + new("KIS 데이터 μˆ˜μ§‘", DateTime.UtcNow.AddHours(-4), DateTime.UtcNow.AddHours(-4).AddSeconds(120), true), + new("일일 리포트 생성", DateTime.UtcNow.AddHours(-6), DateTime.UtcNow.AddHours(-6).AddSeconds(30), true), + new("KIS 데이터 μˆ˜μ§‘", DateTime.UtcNow.AddHours(-24), DateTime.UtcNow.AddHours(-24).AddSeconds(110), true) + }; + + TotalJobsCount = ScheduledJobs.Count; + ActiveJobsCount = ScheduledJobs.Count(j => j.IsEnabled); + InactiveJobsCount = TotalJobsCount - ActiveJobsCount; + PendingJobsCount = 0; + IsJobProcessorRunning = true; + StatusMessage = "λͺ¨λ“  μž‘μ—…μ΄ μ •μƒμ μœΌλ‘œ μ‹€ν–‰ μ€‘μž…λ‹ˆλ‹€."; + + _logger.LogInformation("Operations data loaded successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load operations data"); + ScheduledJobs = []; + RecentExecutions = []; + StatusMessage = "데이터 λ‘œλ”© 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."; + } + } +} + +public record ScheduledJobInfo(string JobName, string Schedule, DateTime? NextRun, bool IsEnabled); +public record JobExecutionInfo(string JobName, DateTime StartedAt, DateTime? CompletedAt, bool IsSuccess); diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Edit.cshtml b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Edit.cshtml new file mode 100644 index 00000000..5fad4e51 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Edit.cshtml @@ -0,0 +1,79 @@ +@page "{username}/edit" +@model QuantEngine.Web.Pages.Admin.Users.EditModel +@{ + ViewData["Title"] = "μ‚¬μš©μž μˆ˜μ • - " + Model.CurrentUser?.Username; +} + + + +
+ @if (Model.CurrentUser == null) + { +
μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.
+ return; + } + +
+
+
+
+ @if (!ViewData.ModelState.IsValid) + { +
+

검증 였λ₯˜

+ @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)) + { +
@error.ErrorMessage
+ } + +
+ } + +
+ @Html.AntiForgeryToken() + +
+ + + λ³€κ²½ν•  수 μ—†μŠ΅λ‹ˆλ‹€ +
+ +
+ + +
+ +
+
+ + +
+
+ +
+ 주의: λΉ„λ°€λ²ˆν˜ΈλŠ” μ‚¬μš©μž 본인이 직접 λ³€κ²½ν•΄μ•Ό ν•©λ‹ˆλ‹€. +
+ + +
+
+
+
+
+
diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Edit.cshtml.cs b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Edit.cshtml.cs new file mode 100644 index 00000000..8bcd9189 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Edit.cshtml.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using QuantEngine.Core.Interfaces; +using QuantEngine.Core.Models; +using QuantEngine.Web.Services; + +namespace QuantEngine.Web.Pages.Admin.Users; + +[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] +public class EditModel : PageModel +{ + private readonly IWorkspaceRepository _workspaceRepository; + private readonly ILogger _logger; + + public WorkspaceAccount? CurrentUser { get; set; } + + [BindProperty] + public EditUserInput Input { get; set; } = new(); + + public EditModel(IWorkspaceRepository workspaceRepository, ILogger logger) + { + _workspaceRepository = workspaceRepository; + _logger = logger; + } + + public async Task OnGetAsync(string username) + { + if (string.IsNullOrWhiteSpace(username)) + return NotFound(); + + CurrentUser = await _workspaceRepository.GetAccountByUsernameAsync(username); + if (CurrentUser == null) + return NotFound(); + + Input = new EditUserInput + { + Role = CurrentUser.Role ?? "Admin", + IsActive = string.Equals(CurrentUser.IsActive, "true", StringComparison.OrdinalIgnoreCase) + }; + + return Page(); + } + + public async Task OnPostAsync(string username) + { + CurrentUser = await _workspaceRepository.GetAccountByUsernameAsync(username); + if (CurrentUser == null) + return NotFound(); + + if (!ModelState.IsValid) + return Page(); + + try + { + CurrentUser.Role = Input.Role; + CurrentUser.IsActive = Input.IsActive ? "true" : "false"; + CurrentUser.UpdatedAt = DateTime.UtcNow.ToString("O"); + + await _workspaceRepository.UpsertAccountAsync(CurrentUser); + _logger.LogInformation("[Users] User updated: {Username}", username); + + return RedirectToPage("/Admin/Users/Index"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update user"); + ModelState.AddModelError(string.Empty, "μ‚¬μš©μž μ—…λ°μ΄νŠΈ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."); + return Page(); + } + } +} + +public class EditUserInput +{ + public string Role { get; set; } = "Admin"; + public bool IsActive { get; set; } = true; +} diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Index.cshtml b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Index.cshtml index 68c15b62..07c2f39e 100644 --- a/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Index.cshtml +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Index.cshtml @@ -54,6 +54,12 @@ @(user.CreatedAt ?? "-") μˆ˜μ • +
+ @Html.AntiForgeryToken() + + + +
} diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Index.cshtml.cs b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Index.cshtml.cs index 8d591543..c0aa87db 100644 --- a/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Index.cshtml.cs +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Index.cshtml.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using QuantEngine.Core.Interfaces; using QuantEngine.Core.Models; @@ -21,6 +22,38 @@ public class IndexModel : PageModel } public async Task OnGetAsync() + { + await LoadUsers(); + } + + public async Task OnPostDeleteAsync(string username) + { + if (string.IsNullOrWhiteSpace(username)) + return BadRequest(); + + try + { + var account = await _workspaceRepository.GetAccountByUsernameAsync(username); + if (account == null) + return NotFound(); + + account.IsActive = "false"; + account.UpdatedAt = DateTime.UtcNow.ToString("O"); + await _workspaceRepository.UpsertAccountAsync(account); + _logger.LogInformation("[Users] User deactivated: {Username}", username); + + return RedirectToPage(); + } + catch (Exception ex) + { + _logger.LogError(ex, "User deactivation failed: {Username}", username); + ModelState.AddModelError(string.Empty, "μ‚¬μš©μž λΉ„ν™œμ„±ν™” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."); + await LoadUsers(); + return Page(); + } + } + + private async Task LoadUsers() { try { diff --git a/src/dotnet/QuantEngine.Web/Program.cs b/src/dotnet/QuantEngine.Web/Program.cs index dc8aaf75..b487af81 100644 --- a/src/dotnet/QuantEngine.Web/Program.cs +++ b/src/dotnet/QuantEngine.Web/Program.cs @@ -68,7 +68,7 @@ try var dataSource = NpgsqlDataSource.Create(connectionString); builder.Services.AddSingleton(dataSource); builder.Services.AddSingleton(new DbConnectionFactory(dataSource)); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => new DbMigrator(connectionString, sp.GetRequiredService>())); // Repository Services builder.Services.AddScoped(); @@ -77,7 +77,7 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddHttpClient(); // Hangfire Background Jobs try diff --git a/src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj b/src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj index afc18a26..3b1fba30 100644 --- a/src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj +++ b/src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj @@ -15,6 +15,7 @@ + diff --git a/src/dotnet/QuantEngine.Web/appsettings.json b/src/dotnet/QuantEngine.Web/appsettings.json index 84c3c7c5..e5e821d6 100644 --- a/src/dotnet/QuantEngine.Web/appsettings.json +++ b/src/dotnet/QuantEngine.Web/appsettings.json @@ -7,7 +7,7 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "DefaultConnection": "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=AppPasswordSecure;Search Path=quantengine;" + "DefaultConnection": "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=quantengine_app;Search Path=quantengine;" }, "AdminSettings": { "Username": "admin", diff --git a/tests/e2e/admin-pages.spec.ts b/tests/e2e/admin-pages.spec.ts new file mode 100644 index 00000000..c0b07ee3 --- /dev/null +++ b/tests/e2e/admin-pages.spec.ts @@ -0,0 +1,152 @@ +import { test, expect } from '@playwright/test'; + +test.describe('κ΄€λ¦¬μž νŽ˜μ΄μ§€ ν”Œλ‘œμš° ν…ŒμŠ€νŠΈ', () => { + test.beforeEach(async ({ page }) => { + // 둜그인 νŽ˜μ΄μ§€λ‘œ 이동 + await page.goto('/Account/Login'); + await page.waitForLoadState('domcontentloaded'); + + // 둜그인 μˆ˜ν–‰ (admin/admin 자격증λͺ…) + const usernameInput = page.locator('#username'); + const passwordInput = page.locator('#password'); + const loginButton = page.locator('#loginBtn'); + + await usernameInput.fill('admin'); + await passwordInput.fill('admin'); + await loginButton.click(); + + // 둜그인 ν›„ νŽ˜μ΄μ§€ λ‘œλ“œ λŒ€κΈ° + await page.waitForLoadState('domcontentloaded'); + }); + + test('λŒ€μ‹œλ³΄λ“œ νŽ˜μ΄μ§€ μ ‘κ·Ό 및 λ Œλ”λ§', async ({ page }) => { + console.log('\n=== λŒ€μ‹œλ³΄λ“œ νŽ˜μ΄μ§€ ν…ŒμŠ€νŠΈ ==='); + + // λŒ€μ‹œλ³΄λ“œ μ ‘κ·Ό + await page.goto('/Admin/Dashboard'); + await page.waitForLoadState('domcontentloaded'); + + // νŽ˜μ΄μ§€ 타이틀 확인 + const title = await page.title(); + expect(title).toContain('λŒ€μ‹œλ³΄λ“œ'); + + console.log('βœ“ λŒ€μ‹œλ³΄λ“œ νŽ˜μ΄μ§€ λ Œλ”λ§ μ™„λ£Œ'); + }); + + test('μ‚¬μš©μž λͺ©λ‘ νŽ˜μ΄μ§€ μ ‘κ·Ό', async ({ page }) => { + console.log('\n=== μ‚¬μš©μž λͺ©λ‘ νŽ˜μ΄μ§€ ν…ŒμŠ€νŠΈ ==='); + + // μ‚¬μš©μž λͺ©λ‘ νŽ˜μ΄μ§€ μ ‘κ·Ό + await page.goto('/Admin/Users'); + await page.waitForLoadState('domcontentloaded'); + + // νŽ˜μ΄μ§€ 타이틀 확인 + const title = await page.title(); + expect(title).toContain('μ‚¬μš©μž'); + + // ν…Œμ΄λΈ” 확인 + const table = page.locator('table'); + await expect(table).toBeVisible(); + + console.log('βœ“ μ‚¬μš©μž λͺ©λ‘ νŽ˜μ΄μ§€ λ Œλ”λ§ μ™„λ£Œ'); + }); + + test('μ‚¬μš©μž 생성 폼 μ ‘κ·Ό', async ({ page }) => { + console.log('\n=== μ‚¬μš©μž 생성 폼 ν…ŒμŠ€νŠΈ ==='); + + // μ‚¬μš©μž 생성 νŽ˜μ΄μ§€ μ ‘κ·Ό + await page.goto('/Admin/Users/Create'); + await page.waitForLoadState('domcontentloaded'); + + // νŽ˜μ΄μ§€ 타이틀 확인 + const title = await page.title(); + expect(title).toContain('생성'); + + // 폼 ν•„λ“œ 확인 + const usernameField = page.locator('input[name="username"]'); + const passwordField = page.locator('input[name="password"]'); + const submitButton = page.locator('button[type="submit"]'); + + await expect(usernameField).toBeVisible(); + await expect(passwordField).toBeVisible(); + await expect(submitButton).toBeVisible(); + + console.log('βœ“ μ‚¬μš©μž 생성 폼 λ Œλ”λ§ μ™„λ£Œ'); + }); + + test('μˆ˜μ§‘ λͺ¨λ‹ˆν„°λ§ νŽ˜μ΄μ§€ μ ‘κ·Ό', async ({ page }) => { + console.log('\n=== μˆ˜μ§‘ λͺ¨λ‹ˆν„°λ§ νŽ˜μ΄μ§€ ν…ŒμŠ€νŠΈ ==='); + + // μˆ˜μ§‘ νŽ˜μ΄μ§€ μ ‘κ·Ό + await page.goto('/Admin/Collection'); + await page.waitForLoadState('domcontentloaded'); + + // νŽ˜μ΄μ§€ 타이틀 확인 + const title = await page.title(); + expect(title).toContain('μˆ˜μ§‘'); + + // ν…Œμ΄λΈ”μ΄λ‚˜ μ½˜ν…μΈ  확인 + const card = page.locator('.card'); + await expect(card).toBeVisible(); + + console.log('βœ“ μˆ˜μ§‘ λͺ¨λ‹ˆν„°λ§ νŽ˜μ΄μ§€ λ Œλ”λ§ μ™„λ£Œ'); + }); + + test('λ‘œκ·Έμ•„μ›ƒ κΈ°λŠ₯', async ({ page }) => { + console.log('\n=== λ‘œκ·Έμ•„μ›ƒ ν…ŒμŠ€νŠΈ ==='); + + // λŒ€μ‹œλ³΄λ“œ μ ‘κ·Ό (둜그인 μƒνƒœ) + await page.goto('/Admin/Dashboard'); + await page.waitForLoadState('domcontentloaded'); + + // λ‘œκ·Έμ•„μ›ƒ λ²„νŠΌ μ°ΎκΈ° 및 클릭 + const logoutButton = page.locator('a:has-text("λ‘œκ·Έμ•„μ›ƒ")', { exact: true }); + if (await logoutButton.isVisible()) { + await logoutButton.click(); + await page.waitForLoadState('domcontentloaded'); + } else { + // λŒ€μ²΄λ‘œ form submit 기반 λ‘œκ·Έμ•„μ›ƒ + const logoutLink = page.locator('a[href*="/Account/Logout"]'); + if (await logoutLink.isVisible()) { + await logoutLink.click(); + await page.waitForLoadState('domcontentloaded'); + } + } + + // λ‘œκ·Έμ•„μ›ƒ ν›„ ν˜„μž¬ URL 확인 + const currentUrl = page.url(); + expect( + currentUrl.includes('/Account/Login') || + currentUrl.includes('/Account/AccessDenied') || + !currentUrl.includes('/Admin') + ).toBeTruthy(); + + console.log('βœ“ λ‘œκ·Έμ•„μ›ƒ κΈ°λŠ₯ μž‘λ™ 확인'); + }); + + test('인증 없이 κ΄€λ¦¬μž νŽ˜μ΄μ§€ μ ‘κ·Ό λΆˆκ°€', async ({ page }) => { + console.log('\n=== 인증 없이 κ΄€λ¦¬μž νŽ˜μ΄μ§€ μ ‘κ·Ό ν…ŒμŠ€νŠΈ ==='); + + // μƒˆ contextμ—μ„œ μΏ ν‚€ 없이 μ ‘κ·Ό + const context = await page.context().browser()?.newContext(); + if (!context) { + console.log('⚠️ μƒˆ context 생성 μ‹€νŒ¨, ν…ŒμŠ€νŠΈ μŠ€ν‚΅'); + return; + } + + const unauthorizedPage = await context.newPage(); + + // 인증 없이 κ΄€λ¦¬μž νŽ˜μ΄μ§€ μ ‘κ·Ό μ‹œλ„ + await unauthorizedPage.goto('/Admin/Dashboard'); + await unauthorizedPage.waitForLoadState('domcontentloaded'); + + // 둜그인 νŽ˜μ΄μ§€λ‘œ λ¦¬λ‹€μ΄λ ‰νŠΈλ˜μ–΄μ•Ό 함 + const finalUrl = unauthorizedPage.url(); + expect(finalUrl).toContain('/Account/Login'); + + await unauthorizedPage.close(); + await context.close(); + + console.log('βœ“ 인증 없이 κ΄€λ¦¬μž νŽ˜μ΄μ§€ μ ‘κ·Ό μ œμ–΄ 확인'); + }); +}); diff --git a/tests/e2e/login.spec.ts b/tests/e2e/login.spec.ts index bfe36c8e..782bf42d 100644 --- a/tests/e2e/login.spec.ts +++ b/tests/e2e/login.spec.ts @@ -1,22 +1,21 @@ import { test, expect } from '@playwright/test'; -test.describe('둜그인 κΈ°λŠ₯ ν…ŒμŠ€νŠΈ', () => { +test.describe('둜그인 κΈ°λŠ₯ ν…ŒμŠ€νŠΈ (Razor Pages)', () => { test.beforeEach(async ({ page }) => { // 둜그인 νŽ˜μ΄μ§€λ‘œ 이동 - await page.goto('/login'); - // νŽ˜μ΄μ§€ λ‘œλ”© 및 Blazor WASM ν•˜μ΄λ“œλ ˆμ΄μ…˜ λŒ€κΈ° - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(3000); + await page.goto('/Account/Login'); + // νŽ˜μ΄μ§€ λ‘œλ”© λŒ€κΈ° + await page.waitForLoadState('domcontentloaded'); }); test('둜그인 νŽ˜μ΄μ§€ λ Œλ”λ§ 확인', async ({ page }) => { // νŽ˜μ΄μ§€ 타이틀 확인 await expect(page).toHaveTitle(/둜그인/); - // μž…λ ₯ ν•„λ“œ 확인 - const usernameInput = page.locator('input[type="text"]').first(); - const passwordInput = page.locator('input[type="password"]'); - const loginButton = page.locator('button:has-text("둜그인")'); + // μž…λ ₯ ν•„λ“œ 확인 (ID 기반 μ…€λ ‰ν„°) + const usernameInput = page.locator('#username'); + const passwordInput = page.locator('#password'); + const loginButton = page.locator('#loginBtn'); await expect(usernameInput).toBeVisible(); await expect(passwordInput).toBeVisible(); @@ -27,12 +26,12 @@ test.describe('둜그인 κΈ°λŠ₯ ν…ŒμŠ€νŠΈ', () => { test('μž…λ ₯ ν•„λ“œμ— ν…μŠ€νŠΈ μž…λ ₯ κ°€λŠ₯ 확인', async ({ page }) => { // 아이디 μž…λ ₯ - const usernameInput = page.locator('input[type="text"]').first(); + const usernameInput = page.locator('#username'); await usernameInput.click(); await usernameInput.type('admin', { delay: 50 }); // λΉ„λ°€λ²ˆν˜Έ μž…λ ₯ - const passwordInput = page.locator('input[type="password"]'); + const passwordInput = page.locator('#password'); await passwordInput.click(); await passwordInput.type('test123', { delay: 50 }); @@ -48,46 +47,55 @@ test.describe('둜그인 κΈ°λŠ₯ ν…ŒμŠ€νŠΈ', () => { test('둜그인 λ²„νŠΌ 클릭 κ°€λŠ₯ 확인', async ({ page }) => { // 아이디 μž…λ ₯ - const usernameInput = page.locator('input[type="text"]').first(); + const usernameInput = page.locator('#username'); await usernameInput.click(); await usernameInput.type('admin', { delay: 50 }); // λΉ„λ°€λ²ˆν˜Έ μž…λ ₯ - const passwordInput = page.locator('input[type="password"]'); + const passwordInput = page.locator('#password'); await passwordInput.click(); await passwordInput.type('admin', { delay: 50 }); // 둜그인 λ²„νŠΌ 클릭 - const loginButton = page.locator('button:has-text("둜그인")'); + const loginButton = page.locator('#loginBtn'); await loginButton.click(); console.log('βœ“ 둜그인 λ²„νŠΌ 클릭 κ°€λŠ₯'); // νŽ˜μ΄μ§€ λ³€ν™” λŒ€κΈ° - await page.waitForTimeout(2000); + await page.waitForLoadState('domcontentloaded'); }); - test('ν™ˆ νŽ˜μ΄μ§€ μ ‘κ·Ό 확인', async ({ page }) => { - // ν™ˆ νŽ˜μ΄μ§€ μ ‘κ·Ό - await page.goto('/'); + test('둜그인 μ‹€νŒ¨ μ‹œ 였λ₯˜ λ©”μ‹œμ§€ ν‘œμ‹œ', async ({ page }) => { + // 잘λͺ»λœ 자격증λͺ… μž…λ ₯ + const usernameInput = page.locator('#username'); + const passwordInput = page.locator('#password'); + const loginButton = page.locator('#loginBtn'); - // 둜그인 νŽ˜μ΄μ§€λ‘œ λ¦¬λ‹€μ΄λ ‰νŠΈ λ˜λŠ”μ§€ 확인 - await page.waitForTimeout(2000); + await usernameInput.fill('invaliduser'); + await passwordInput.fill('wrongpassword'); + await loginButton.click(); + // 였λ₯˜ λ©”μ‹œμ§€ λŒ€κΈ° (alert-error 클래슀) + const errorAlert = page.locator('.alert-error'); + await expect(errorAlert).toBeVisible({ timeout: 5000 }); + + console.log('βœ“ 둜그인 μ‹€νŒ¨ 였λ₯˜ λ©”μ‹œμ§€ ν‘œμ‹œ 확인'); + }); + + test('보호된 νŽ˜μ΄μ§€ μ ‘κ·Ό μ‹œ 둜그인 νŽ˜μ΄μ§€λ‘œ λ¦¬λ‹€μ΄λ ‰νŠΈ', async ({ page }) => { + // κ΄€λ¦¬μž λŒ€μ‹œλ³΄λ“œ 직접 μ ‘κ·Ό μ‹œλ„ + await page.goto('/Admin/Dashboard', { waitUntil: 'domcontentloaded' }); + + // 둜그인 νŽ˜μ΄μ§€λ‘œ λ¦¬λ‹€μ΄λ ‰νŠΈ λ˜μ–΄μ•Ό 함 const currentUrl = page.url(); - console.log(`Current URL: ${currentUrl}`); + expect(currentUrl).toContain('/Account/Login'); - // λŒ€μ‹œλ³΄λ“œ λ˜λŠ” 둜그인 νŽ˜μ΄μ§€ 쀑 ν•˜λ‚˜μ—¬μ•Ό 함 - const isLoginPage = currentUrl.includes('/login'); - const isDashboard = currentUrl.includes('/dashboard') || currentUrl.includes('/'); - - expect(isLoginPage || isDashboard).toBeTruthy(); - - console.log('βœ“ ν™ˆ νŽ˜μ΄μ§€ μ ‘κ·Ό 확인'); + console.log('βœ“ 보호된 νŽ˜μ΄μ§€ μ ‘κ·Ό μ œμ–΄ 확인'); }); - test('전체 κΈ°λŠ₯ 톡합 ν…ŒμŠ€νŠΈ', async ({ page }) => { - console.log('\n=== 전체 κΈ°λŠ₯ 톡합 ν…ŒμŠ€νŠΈ ==='); + test('전체 둜그인 ν”Œλ‘œμš° ν…ŒμŠ€νŠΈ', async ({ page }) => { + console.log('\n=== 전체 둜그인 ν”Œλ‘œμš° ν…ŒμŠ€νŠΈ ==='); // 1단계: 둜그인 νŽ˜μ΄μ§€ 확인 console.log('1️⃣ 둜그인 νŽ˜μ΄μ§€ 확인...'); @@ -95,9 +103,9 @@ test.describe('둜그인 κΈ°λŠ₯ ν…ŒμŠ€νŠΈ', () => { // 2단계: μž…λ ₯ ν•„λ“œ μ°ΎκΈ° console.log('2️⃣ μž…λ ₯ ν•„λ“œ μ°ΎκΈ°...'); - const usernameInput = page.locator('input[type="text"]').first(); - const passwordInput = page.locator('input[type="password"]'); - const loginButton = page.locator('button:has-text("둜그인")'); + const usernameInput = page.locator('#username'); + const passwordInput = page.locator('#password'); + const loginButton = page.locator('#loginBtn'); await expect(usernameInput).toBeVisible(); await expect(passwordInput).toBeVisible(); @@ -105,9 +113,7 @@ test.describe('둜그인 κΈ°λŠ₯ ν…ŒμŠ€νŠΈ', () => { // 3단계: 둜그인 정보 μž…λ ₯ console.log('3️⃣ 둜그인 정보 μž…λ ₯...'); - await usernameInput.click(); await usernameInput.fill('admin'); - await passwordInput.click(); await passwordInput.fill('admin'); // 4단계: 둜그인 λ²„νŠΌ 클릭 @@ -116,7 +122,7 @@ test.describe('둜그인 κΈ°λŠ₯ ν…ŒμŠ€νŠΈ', () => { // 5단계: νŽ˜μ΄μ§€ λ³€ν™” λŒ€κΈ° console.log('5️⃣ νŽ˜μ΄μ§€ λ³€ν™” λŒ€κΈ°...'); - await page.waitForTimeout(3000); + await page.waitForLoadState('domcontentloaded'); // 6단계: μ΅œμ’… μƒνƒœ 확인 console.log('6️⃣ μ΅œμ’… μƒνƒœ 확인...'); @@ -126,10 +132,9 @@ test.describe('둜그인 κΈ°λŠ₯ ν…ŒμŠ€νŠΈ', () => { console.log(` μ΅œμ’… URL: ${finalUrl}`); console.log(` νŽ˜μ΄μ§€ 타이틀: ${pageTitle}`); - // μŠ€ν¬λ¦°μƒ· μ €μž₯ - await page.screenshot({ path: 'test-results/login-flow-final.png', fullPage: true }); - console.log(' μŠ€ν¬λ¦°μƒ· μ €μž₯: test-results/login-flow-final.png'); + // 둜그인 성곡 μ‹œ 둜그인 νŽ˜μ΄μ§€κ°€ μ•„λ‹Œ λ‹€λ₯Έ νŽ˜μ΄μ§€λ‘œ μ΄λ™λ˜μ–΄μ•Ό 함 + expect(!finalUrl.includes('/Account/Login')).toBeTruthy(); - console.log('\nβœ“ 전체 κΈ°λŠ₯ 톡합 ν…ŒμŠ€νŠΈ μ™„λ£Œ'); + console.log('\nβœ“ 전체 둜그인 ν”Œλ‘œμš° ν…ŒμŠ€νŠΈ μ™„λ£Œ'); }); });