e7e01d0cd8
TaxBaik CI/CD / build-and-deploy (push) Failing after 15s
- MigrationRunner: 이미 존재하는 테이블에 대한 "relation already exists" 오류 처리 - V002, V003 마이그레이션: ON CONFLICT DO NOTHING으로 멱등성 보장 - Web, Admin Program.cs: app.UseAntiforgery() 미들웨어 추가 (anti-forgery 토큰 검증) 변경사항: - 마이그레이션 재실행 시에도 안전하게 처리 - 폼 제출 시 CSRF 공격 방지 - 관리자 로그인 페이지 405 에러 해결 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
150 lines
5.1 KiB
C#
150 lines
5.1 KiB
C#
using System.Reflection;
|
||
using System.Text;
|
||
using Npgsql;
|
||
using TaxBaik.Domain.Interfaces;
|
||
|
||
namespace TaxBaik.Infrastructure.Data;
|
||
|
||
public class MigrationRunner
|
||
{
|
||
private readonly string _connectionString;
|
||
private readonly IDbConnectionFactory _connectionFactory;
|
||
|
||
public MigrationRunner(string connectionString, IDbConnectionFactory connectionFactory)
|
||
{
|
||
_connectionString = connectionString;
|
||
_connectionFactory = connectionFactory;
|
||
}
|
||
|
||
public async Task RunAsync()
|
||
{
|
||
await EnsureMigrationTableAsync();
|
||
await ExecutePendingMigrationsAsync();
|
||
}
|
||
|
||
private async Task EnsureMigrationTableAsync()
|
||
{
|
||
using var conn = new NpgsqlConnection(_connectionString);
|
||
await conn.OpenAsync();
|
||
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"
|
||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
version VARCHAR(50) PRIMARY KEY,
|
||
description VARCHAR(500),
|
||
installed_on TIMESTAMPTZ DEFAULT NOW()
|
||
);";
|
||
await cmd.ExecuteNonQueryAsync();
|
||
}
|
||
|
||
private async Task ExecutePendingMigrationsAsync()
|
||
{
|
||
var executedMigrations = await GetExecutedMigrationsAsync();
|
||
var migrations = GetAvailableMigrations();
|
||
|
||
foreach (var migration in migrations.OrderBy(x => x.Version))
|
||
{
|
||
if (!executedMigrations.Contains(migration.Version))
|
||
{
|
||
await ExecuteMigrationAsync(migration);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task<HashSet<string>> GetExecutedMigrationsAsync()
|
||
{
|
||
var executed = new HashSet<string>();
|
||
using var conn = new NpgsqlConnection(_connectionString);
|
||
await conn.OpenAsync();
|
||
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "SELECT version FROM schema_migrations ORDER BY version;";
|
||
|
||
using var reader = await cmd.ExecuteReaderAsync();
|
||
while (await reader.ReadAsync())
|
||
{
|
||
executed.Add(reader.GetString(0));
|
||
}
|
||
|
||
return executed;
|
||
}
|
||
|
||
private List<Migration> GetAvailableMigrations()
|
||
{
|
||
var migrations = new List<Migration>();
|
||
|
||
// Try file system first (for deployment), then embedded resources
|
||
var migrationDirs = new[]
|
||
{
|
||
"./migrations", // relative
|
||
"/home/kjh2064/taxbaik_active/migrations" // deployment
|
||
};
|
||
|
||
var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
|
||
|
||
if (migrationPath != null && Directory.Exists(migrationPath))
|
||
{
|
||
var files = Directory.GetFiles(migrationPath, "V*.sql").OrderBy(x => x);
|
||
foreach (var file in files)
|
||
{
|
||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||
if (fileName.StartsWith("V"))
|
||
{
|
||
var version = fileName.Substring(1, fileName.IndexOf('_') - 1);
|
||
var description = fileName.Substring(fileName.IndexOf('_') + 2);
|
||
var sql = File.ReadAllText(file);
|
||
|
||
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
|
||
}
|
||
}
|
||
}
|
||
|
||
return migrations;
|
||
}
|
||
|
||
private async Task ExecuteMigrationAsync(Migration migration)
|
||
{
|
||
using var conn = new NpgsqlConnection(_connectionString);
|
||
await conn.OpenAsync();
|
||
|
||
try
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = migration.Sql;
|
||
await cmd.ExecuteNonQueryAsync();
|
||
|
||
using var insertCmd = conn.CreateCommand();
|
||
insertCmd.CommandText =
|
||
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description);";
|
||
insertCmd.Parameters.AddWithValue("@version", migration.Version);
|
||
insertCmd.Parameters.AddWithValue("@description", migration.Description);
|
||
await insertCmd.ExecuteNonQueryAsync();
|
||
|
||
Console.WriteLine($"✓ Migration {migration.Version} executed");
|
||
}
|
||
catch (Npgsql.PostgresException pgEx) when (pgEx.SqlState == "42P07") // relation already exists
|
||
{
|
||
// Already executed previously; mark as done
|
||
Console.WriteLine($"ℹ Migration {migration.Version} already applied");
|
||
using var insertCmd = conn.CreateCommand();
|
||
insertCmd.CommandText =
|
||
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description) ON CONFLICT (version) DO NOTHING;";
|
||
insertCmd.Parameters.AddWithValue("@version", migration.Version);
|
||
insertCmd.Parameters.AddWithValue("@description", migration.Description);
|
||
await insertCmd.ExecuteNonQueryAsync();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"✗ Migration {migration.Version} failed: {ex.Message}");
|
||
throw;
|
||
}
|
||
}
|
||
|
||
private class Migration
|
||
{
|
||
public string Version { get; set; }
|
||
public string Description { get; set; }
|
||
public string Sql { get; set; }
|
||
}
|
||
}
|