서브도메인설정
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m28s

This commit is contained in:
2026-04-24 04:56:40 +09:00
parent 94c9b4b602
commit 8be7e16e56
43 changed files with 6464 additions and 26 deletions
@@ -2,8 +2,12 @@ package com.erp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
public class ErpApplication {
public static void main(String[] args) {
SpringApplication.run(ErpApplication.class, args);
@@ -0,0 +1,45 @@
package com.erp.provisioning;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
/**
* 신규 회사 DB 에 {prefix}_admin (COMPANY_ADMIN) 계정을 생성.
* BCrypt 해시만 저장되며, 초기 비밀번호는 호출자가 생성/전달.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AdminAccountCreator {
private final PasswordEncoder passwordEncoder;
/**
* @param dst 신규 회사 DB 커넥션 (autoCommit 무관, 호출자가 트랜잭션 관리)
* @param companyCode COMPANY_MNG.company_code (FK 성격)
* @param userId 예: qnc_admin
* @param rawPassword 평문 비밀번호 (BCrypt 해시 후 저장됨)
* @param userName 표시 이름
*/
public void createCompanyAdmin(Connection dst, String companyCode, String userId,
String rawPassword, String userName) throws SQLException {
String hash = passwordEncoder.encode(rawPassword);
String sql = "INSERT INTO user_info " +
"(user_id, user_password, user_name, company_code, user_type, status, created_date) " +
"VALUES (?, ?, ?, ?, 'COMPANY_ADMIN', 'active', NOW())";
try (PreparedStatement ps = dst.prepareStatement(sql)) {
ps.setString(1, userId);
ps.setString(2, hash);
ps.setString(3, userName);
ps.setString(4, companyCode);
int affected = ps.executeUpdate();
log.info("[Provisioning] CREATE ADMIN: {} (company={}, rows={})", userId, companyCode, affected);
}
}
}
@@ -0,0 +1,228 @@
package com.erp.provisioning;
import com.erp.tenant.CompanyResolver;
import com.erp.tenant.DbContextHolder;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.DriverManager;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 회사 프로비저닝 오케스트레이션.
*
* 6 단계:
* 1. REGISTER_META — COMPANY_MNG 에 db_status='provisioning' 으로 row 선반영 (initiate)
* 2. CREATE_DATABASE — CREATE DATABASE "{prefix}_vexplor"
* 3. COPY_SCHEMA — pg_dump --schema-only | psql
* 4. COPY_DATA — 선택 + 필수 그룹의 테이블들 JDBC 복사
* 5. CREATE_ADMIN — user_info 에 {prefix}_admin INSERT (BCrypt)
* 6. FINALIZE — COMPANY_MNG.db_status='active' + CompanyResolver 캐시 무효화
*
* 실패 시 보상:
* - DROP DATABASE
* - COMPANY_MNG.db_status='failed'
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyProvisioningService {
private final TenantDbSettings tenantDbSettings;
private final ProvisioningRegistry registry;
private final DatabaseCreator dbCreator;
private final SchemaCopier schemaCopier;
private final DataCopier dataCopier;
private final AdminAccountCreator adminCreator;
private final CompanyResolver companyResolver;
private final SqlSession sqlSession;
@Value("${spring.datasource.url}")
private String metaJdbcUrl;
private String metaDbName() {
int slash = metaJdbcUrl.lastIndexOf('/');
if (slash < 0) return "vexplor";
String tail = metaJdbcUrl.substring(slash + 1);
int q = tail.indexOf('?');
return q < 0 ? tail : tail.substring(0, q);
}
/** Step 1: 메타 DB 에 row 선반영 + 레지스트리 생성. 동기 실행. */
public ProvisioningStatus initiate(Map<String, Object> req) {
String companyCode = (String) req.get("company_code");
String dbPrefix = (String) req.get("db_prefix");
String dbName = dbPrefix + "_vexplor";
String subdomain = (String) req.get("subdomain");
// 템플릿 그룹 수 집계: 필수(3) + 선택된 optional
@SuppressWarnings("unchecked")
List<String> selectedGroupIds = (List<String>) req.getOrDefault("selected_groups", List.of());
int templatesCount = (int) java.util.Arrays.stream(TableGroup.values())
.filter(g -> g.isRequired() || selectedGroupIds.contains(g.name()))
.count();
Map<String, Object> insertParams = new HashMap<>(req);
insertParams.put("db_name", dbName);
insertParams.put("db_host", tenantDbSettings.host());
insertParams.put("db_status", "provisioning");
insertParams.put("templates_count", templatesCount);
insertParams.putIfAbsent("status", "active");
insertParams.putIfAbsent("writer", "SUPER_ADMIN");
sqlSession.insert("provisioning.insertCompanyWithTenant", insertParams);
ProvisioningStatus s = new ProvisioningStatus();
s.setId(UUID.randomUUID().toString());
s.setCompanyCode(companyCode);
s.setDbName(dbName);
s.setSubdomain(subdomain);
s.setCurrentStep(ProvisioningStatus.Step.REGISTER_META.name());
s.setProgress(1);
s.setTotalSteps(6);
s.setStatus("in_progress");
s.setStartedAt(Instant.now());
registry.register(s);
return s;
}
/** Step 2~6: 비동기 실행. Controller 가 accepted(202) 리턴 후 백그라운드 진행. */
@Async
public void provisionAsync(String jobId, Map<String, Object> req) {
// @Async 스레드는 새로 생성되어 ThreadLocal 을 상속받지 않음.
// sqlSession 이 tenant 라우팅을 타므로 메타 DB 를 명시적으로 고정.
DbContextHolder.setMeta();
try {
doProvision(jobId, req);
} finally {
DbContextHolder.clear();
}
}
private void doProvision(String jobId, Map<String, Object> req) {
ProvisioningStatus status = registry.get(jobId);
if (status == null) {
log.error("[Provisioning] jobId not found: {}", jobId);
return;
}
String dbName = status.getDbName();
String subdomain = status.getSubdomain();
String companyCode = status.getCompanyCode();
String dbPrefix = (String) req.get("db_prefix");
String initialPassword = (String) req.get("initial_password");
String companyName = (String) req.get("company_name");
@SuppressWarnings("unchecked")
List<String> selectedGroupIds = (List<String>) req.getOrDefault("selected_groups", List.of());
List<String> tables = gatherTables(selectedGroupIds);
try {
// Step 2: CREATE DATABASE
advance(jobId, ProvisioningStatus.Step.CREATE_DATABASE, 2);
dbCreator.createDatabase(dbName);
// Step 3: Copy schema
advance(jobId, ProvisioningStatus.Step.COPY_SCHEMA, 3);
schemaCopier.copySchema(metaDbName(), dbName);
// Step 4 & 5: Copy data + admin (같은 dst Connection 재사용)
advance(jobId, ProvisioningStatus.Step.COPY_DATA, 4);
try (Connection src = DriverManager.getConnection(
tenantDbSettings.buildJdbcUrl(metaDbName()),
tenantDbSettings.username(), tenantDbSettings.password());
Connection dst = DriverManager.getConnection(
tenantDbSettings.buildJdbcUrl(dbName),
tenantDbSettings.username(), tenantDbSettings.password())) {
src.setAutoCommit(true);
dst.setAutoCommit(false);
int totalRows = 0;
try {
// 테이블 하나라도 실패하면 catch 에서 rollback 후 전체 실패 처리.
// (Codex 리뷰 #3 원자성 보장, 2026-04-24).
for (String t : tables) {
totalRows += dataCopier.copyTable(src, dst, t);
}
dst.commit();
} catch (Exception copyErr) {
try { dst.rollback(); } catch (Exception rb) { log.warn("rollback failed: {}", rb.getMessage()); }
throw copyErr;
}
log.info("[Provisioning] DATA total {} rows across {} tables", totalRows, tables.size());
// 시퀀스 재설정은 반드시 autoCommit=true 로. 개별 setval 실패가 전체 트랜잭션을
// abort 시켜 이후 setval 이 "current transaction is aborted" 로 전부 실패하는 것 방지.
dst.setAutoCommit(true);
dataCopier.resetSequences(dst);
// Step 5: Admin account
advance(jobId, ProvisioningStatus.Step.CREATE_ADMIN, 5);
String adminId = dbPrefix + "_admin";
String adminName = (companyName == null ? companyCode : companyName) + " 관리자";
dst.setAutoCommit(true);
adminCreator.createCompanyAdmin(dst, companyCode, adminId, initialPassword, adminName);
}
// Step 6: Finalize
advance(jobId, ProvisioningStatus.Step.FINALIZE, 6);
Map<String, Object> upd = new HashMap<>();
upd.put("company_code", companyCode);
upd.put("db_status", "active");
sqlSession.update("provisioning.updateDbStatus", upd);
companyResolver.invalidate(subdomain);
registry.update(jobId, s -> {
s.setStatus("completed");
s.setCompletedAt(Instant.now());
});
log.info("[Provisioning] ✅ completed: company={} db={} subdomain={}", companyCode, dbName, subdomain);
} catch (Exception e) {
String failedStep = registry.get(jobId).getCurrentStep();
log.error("[Provisioning] ❌ FAILED at {} for {} ({})", failedStep, dbName, e.getMessage(), e);
registry.update(jobId, s -> {
s.setStatus("failed");
s.setFailedStep(failedStep);
s.setErrorMessage(e.getMessage());
s.setCompletedAt(Instant.now());
});
// 보상
dbCreator.dropDatabase(dbName);
try {
Map<String, Object> upd = new HashMap<>();
upd.put("company_code", companyCode);
upd.put("db_status", "failed");
sqlSession.update("provisioning.updateDbStatus", upd);
} catch (Exception ex) {
log.error("[Provisioning] failed marking db_status=failed: {}", ex.getMessage());
}
}
}
private void advance(String jobId, ProvisioningStatus.Step step, int progress) {
registry.update(jobId, s -> {
s.setCurrentStep(step.name());
s.setProgress(progress);
});
}
private List<String> gatherTables(List<String> selectedGroupIds) {
List<String> result = new ArrayList<>();
for (TableGroup g : TableGroup.values()) {
if (g.isRequired() || selectedGroupIds.contains(g.name())) {
result.addAll(g.getTables());
}
}
return result;
}
}
@@ -0,0 +1,107 @@
package com.erp.provisioning;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.List;
import java.util.Map;
/**
* 회사관리 UI (v9 accordion) 렌더용 집계 서비스.
*
* 정적 필드는 {@code provisioning.listCompaniesForUi} 에서 조회.
* Derived 필드는 회사마다 런타임 계산:
* - {@code users} — tenant DB: {@code SELECT count(*) FROM user_info}
* - {@code db_size_bytes} — 메타 서버: {@code SELECT pg_database_size(?)}
* - {@code db_size} — 위 값 포맷팅 ("4.2 GB", "820 MB" 등)
* - {@code db_pct} — db_size / DB_QUOTA_GB * 100
* - {@code active30} — MVP 0. (last_login_date 컬럼 없음. 생기면 구현)
* - {@code spark} — MVP 빈 배열. (일자별 사용자 집계 테이블 생기면 구현)
*
* ⚠ N+1 주의: 회사 N 개면 tenant DB 에 N 번 접속 + 메타 서버에 N 번 접속.
* 회사 50 개 넘어가면 병렬 (CompletableFuture) 또는 집계 캐시 도입. 지금은 MVP.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyStatsService {
private final TenantDbSettings settings;
private final SqlSession sqlSession;
public List<Map<String, Object>> listWithStats() {
List<Map<String, Object>> rows = sqlSession.selectList("provisioning.listCompaniesForUi");
for (Map<String, Object> r : rows) {
enrichOne(r);
}
return rows;
}
private void enrichOne(Map<String, Object> r) {
String dbName = (String) r.get("db_name");
String dbStatus = (String) r.get("db_status");
int quotaGb = toInt(r.get("db_quota_gb"), 20);
// Placeholder defaults
r.putIfAbsent("users", 0);
r.putIfAbsent("active30", 0);
r.putIfAbsent("db_size", "");
r.putIfAbsent("db_size_bytes", 0L);
r.putIfAbsent("db_pct", 0);
r.putIfAbsent("spark", List.of());
if (!"active".equals(dbStatus) || dbName == null) {
return;
}
// users — tenant DB
try (Connection c = DriverManager.getConnection(
settings.buildJdbcUrl(dbName), settings.username(), settings.password());
Statement s = c.createStatement();
ResultSet rs = s.executeQuery("SELECT count(*) FROM user_info")) {
if (rs.next()) r.put("users", rs.getInt(1));
} catch (Exception e) {
log.debug("[Stats] users failed for {}: {}", dbName, e.getMessage());
}
// db size — 메타 postgres DB 에서 pg_database_size
try (Connection c = DriverManager.getConnection(
String.format("jdbc:postgresql://%s:%d/postgres", settings.host(), settings.port()),
settings.username(), settings.password());
PreparedStatement ps = c.prepareStatement("SELECT pg_database_size(?)")) {
ps.setString(1, dbName);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
long bytes = rs.getLong(1);
r.put("db_size_bytes", bytes);
r.put("db_size", formatBytes(bytes));
long quotaBytes = (long) quotaGb * 1024L * 1024L * 1024L;
r.put("db_pct", quotaBytes > 0 ? (int) ((bytes * 100) / quotaBytes) : 0);
}
}
} catch (Exception e) {
log.debug("[Stats] db_size failed for {}: {}", dbName, e.getMessage());
}
}
private static String formatBytes(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024L * 1024) return String.format("%.1f KB", bytes / 1024.0);
if (bytes < 1024L * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024));
}
private static int toInt(Object v, int defaultVal) {
if (v == null) return defaultVal;
if (v instanceof Number n) return n.intValue();
try { return Integer.parseInt(v.toString()); } catch (Exception e) { return defaultVal; }
}
}
@@ -0,0 +1,163 @@
package com.erp.provisioning;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 선택된 테이블을 원본 DB → 신규 DB 로 복사.
*
* 정책:
* - company_code 컬럼이 있는 테이블: ('*','TEMPLATE') + NULL 만 복사 (공통 템플릿).
* - 없는 테이블: 전체 복사.
*
* ★ 실패 시: 테이블 하나라도 예외가 터지면 호출자에게 그대로 throw.
* CompanyProvisioningService 가 dst.rollback() + DROP DATABASE 로 원자성 보장.
*
* ★ 2026-04-24 추가: resetSequences() — 데이터 복사 후 모든 시퀀스의 current value 를
* max(column)+1 로 재설정. pg_dump --schema-only 는 시퀀스 값을 안 복제하므로 필수.
*/
@Service
@Slf4j
public class DataCopier {
private static final int BATCH = 500;
public int copyTable(Connection src, Connection dst, String table) throws SQLException {
boolean hasCompanyCode = columnExists(src, table, "company_code");
List<String> cols = listColumns(src, table);
if (cols.isEmpty()) {
log.warn("[Provisioning] {} has no columns in source — skip", table);
return 0;
}
String where = hasCompanyCode ? " WHERE company_code IS NULL OR company_code IN ('*', 'TEMPLATE')" : "";
String quotedCols = cols.stream().map(c -> "\"" + c + "\"").collect(Collectors.joining(","));
String selectSql = "SELECT " + quotedCols + " FROM \"" + table + "\"" + where;
String placeholders = cols.stream().map(c -> "?").collect(Collectors.joining(","));
String insertSql = "INSERT INTO \"" + table + "\" (" + quotedCols + ") VALUES (" + placeholders + ")";
int rows = 0;
try (PreparedStatement selectStmt = src.prepareStatement(selectSql);
ResultSet rs = selectStmt.executeQuery();
PreparedStatement insertStmt = dst.prepareStatement(insertSql)) {
ResultSetMetaData meta = rs.getMetaData();
int colCount = meta.getColumnCount();
while (rs.next()) {
for (int i = 1; i <= colCount; i++) {
insertStmt.setObject(i, rs.getObject(i));
}
insertStmt.addBatch();
rows++;
if (rows % BATCH == 0) insertStmt.executeBatch();
}
insertStmt.executeBatch();
}
log.info("[Provisioning] COPY DATA {}: {} rows (filter={})", table, rows, hasCompanyCode);
return rows;
}
/**
* 모든 시퀀스의 current value 를 연결된 컬럼의 MAX()+1 로 재설정.
* pg_depend 조회로 seq↔table↔column 관계를 얻으므로 휴리스틱 없음.
*
* 이 쿼리의 테이블/컬럼/시퀀스 이름은 pg_class 에서 직접 나온 값이라 신뢰 가능 → SQL injection 위험 없음.
* 빈 테이블은 setval(seq, 1) 로 안전하게 초기화.
*/
public int resetSequences(Connection dst) throws SQLException {
// format_type 으로 컬럼 실제 타입까지 조회 → integer 계열만 setval.
// 레거시 DB 에선 SERIAL 이었다가 나중에 TEXT 로 타입 변경된 컬럼이 있을 수 있음 (시퀀스 의존성만 남음).
// 이런 컬럼에 setval 을 호출하면 "COALESCE types text and integer cannot be matched" 예외 발생.
String findSql = """
SELECT s.relname AS seq, t.relname AS tbl, a.attname AS col,
format_type(a.atttypid, a.atttypmod) AS coltype
FROM pg_class s
JOIN pg_depend d ON d.objid = s.oid
JOIN pg_class t ON t.oid = d.refobjid
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE s.relkind = 'S' AND d.deptype = 'a'
""";
List<String[]> rows = new ArrayList<>();
try (Statement s = dst.createStatement();
ResultSet rs = s.executeQuery(findSql)) {
while (rs.next()) {
rows.add(new String[]{
rs.getString("seq"), rs.getString("tbl"),
rs.getString("col"), rs.getString("coltype")});
}
}
int updated = 0, skippedType = 0, skippedErr = 0;
try (Statement us = dst.createStatement()) {
for (String[] r : rows) {
String seq = r[0], tbl = r[1], col = r[2], coltype = r[3];
if (!isIntegerLike(coltype)) {
skippedType++;
continue;
}
String sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
seq.replace("'", "''"), col, tbl);
try {
us.execute(sql);
updated++;
} catch (SQLException e) {
skippedErr++;
log.warn("[Provisioning] setval failed seq={} tbl={} col={} type={}: {}",
seq, tbl, col, coltype, e.getMessage());
}
}
}
// invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때
// nextval 을 사용하지 않으므로 setval 은 no-op. skipped_non_integer 값이 높아도 정상.
if (updated == 0 && skippedErr == 0) {
log.info("[Provisioning] RESET SEQUENCES: skipped all {} (string-PK schema, no-op)", rows.size());
} else {
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_integer={} skipped_error={} total={}",
updated, skippedType, skippedErr, rows.size());
}
return updated;
}
private static boolean isIntegerLike(String coltype) {
if (coltype == null) return false;
String t = coltype.toLowerCase();
return t.startsWith("integer") || t.startsWith("bigint") || t.startsWith("smallint")
|| t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2");
}
private List<String> listColumns(Connection conn, String table) throws SQLException {
List<String> cols = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement(
"SELECT column_name FROM information_schema.columns " +
"WHERE table_schema='public' AND lower(table_name)=lower(?) " +
"ORDER BY ordinal_position")) {
ps.setString(1, table);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) cols.add(rs.getString(1));
}
}
return cols;
}
private boolean columnExists(Connection conn, String table, String col) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(
"SELECT 1 FROM information_schema.columns " +
"WHERE table_schema='public' AND lower(table_name)=lower(?) AND lower(column_name)=lower(?)")) {
ps.setString(1, table);
ps.setString(2, col);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
}
@@ -0,0 +1,94 @@
package com.erp.provisioning;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.regex.Pattern;
/**
* CREATE DATABASE / DROP DATABASE 를 superuser 로 실행.
*
* ★ 보안 강화 (2026-04-24 Codex 리뷰 반영):
* - DB 이름은 ^[a-z][a-z0-9_]{{2,40}}$ 화이트리스트 재검증 (Controller 와 2중 방어).
* - pg_terminate_backend 는 PreparedStatement 로 파라미터 바인딩.
* - CREATE/DROP DATABASE 는 PG 문법상 파라미터 바인딩 불가 → 검증된 이름만 큰따옴표로 quote.
*
* ★ 복구 강화:
* - dropDatabase 는 3회 재시도 + 기하급수 백오프 (300ms, 600ms, 1.2s).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DatabaseCreator {
private static final Pattern SAFE_DB_NAME = Pattern.compile("^[a-z][a-z0-9_]{2,40}$");
private final TenantDbSettings settings;
public void createDatabase(String dbName) throws SQLException {
requireSafeDbName(dbName);
try (Connection c = adminConnection()) {
c.setAutoCommit(true);
try (Statement s = c.createStatement()) {
s.execute("CREATE DATABASE \"" + dbName + "\" WITH TEMPLATE template0 ENCODING 'UTF8'");
}
log.info("[Provisioning] CREATE DATABASE: {}", dbName);
}
}
/** 보상 롤백. 재시도 3회 + 백오프. 실패 기록은 남기지만 호출자에게 예외 던지지 않음 (이미 실패 처리 중이므로). */
public void dropDatabase(String dbName) {
try {
requireSafeDbName(dbName);
} catch (IllegalArgumentException e) {
log.error("[Provisioning] drop skipped — unsafe name: {}", dbName);
return;
}
long backoffMs = 300;
for (int attempt = 1; attempt <= 3; attempt++) {
try (Connection c = adminConnection()) {
c.setAutoCommit(true);
try (PreparedStatement p = c.prepareStatement(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity " +
"WHERE datname = ? AND pid <> pg_backend_pid()")) {
p.setString(1, dbName);
p.execute();
}
try (Statement s = c.createStatement()) {
s.execute("DROP DATABASE IF EXISTS \"" + dbName + "\"");
}
log.warn("[Provisioning] ROLLBACK — dropped DB: {} (attempt {})", dbName, attempt);
return;
} catch (SQLException e) {
log.warn("[Provisioning] drop attempt {}/3 failed for {}: {}", attempt, dbName, e.getMessage());
if (attempt < 3) {
try { Thread.sleep(backoffMs); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
backoffMs *= 2;
}
}
}
log.error("[Provisioning] drop FAILED after 3 attempts: {} — manual cleanup required", dbName);
}
private Connection adminConnection() throws SQLException {
String url = String.format("jdbc:postgresql://%s:%d/postgres", settings.host(), settings.port());
return DriverManager.getConnection(url, settings.username(), settings.password());
}
private static void requireSafeDbName(String name) {
if (name == null || !SAFE_DB_NAME.matcher(name).matches()) {
throw new IllegalArgumentException("unsafe db name: " + name);
}
}
}
@@ -0,0 +1,262 @@
package com.erp.provisioning;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Phase 3-A 프로비저닝 API.
*
* GET /api/admin/provisioning/table-groups — Step 2 체크박스 렌더용
* GET /api/admin/provisioning/check — 실시간 중복/포맷 검증
* POST /api/admin/provisioning/companies — 마법사 최종 submit (202 accepted)
* GET /api/admin/provisioning/status/{id} — Step 4 폴링
*
* ⚠ 인증: Phase 3-A 에선 SUPER_ADMIN 권한 체크를 생략하고 SecurityConfig 차원에서 permitAll.
* 실운영 배포 전에 Controller 앞단에 권한 검증을 추가해야 함 (JwtAuthenticationFilter 가 세팅한 user_type attribute 검사).
*/
@RestController
@RequestMapping("/api/admin/provisioning")
@RequiredArgsConstructor
@Slf4j
public class ProvisioningController {
private final CompanyProvisioningService service;
private final ProvisioningRegistry registry;
private final SqlSession sqlSession;
private final CompanyStatsService statsService;
/**
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
*/
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
private static final Set<String> RESERVED_SUBDOMAINS = Set.of(
"www", "admin", "api", "app", "static", "assets",
"main", "mail", "blog", "dev", "test", "staging", "prod", "console"
);
@GetMapping("/table-groups")
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
enforceSuperAdmin(request);
return ResponseEntity.ok(Arrays.stream(TableGroup.values()).map(TableGroup::toMap).toList());
}
/**
* 회사관리 UI (v9 accordion 메인 화면) 전용 리스트 + derived 집계.
* 정적 필드는 COMPANY_MNG 에서, users/db_size 등은 tenant DB/메타 서버 런타임 조회.
* N+1 — MVP. 회사 50 개 넘어가면 병렬/캐시 도입 필요.
*/
@GetMapping("/companies-stats")
public ResponseEntity<List<Map<String, Object>>> companiesStats(HttpServletRequest request) {
enforceSuperAdmin(request);
return ResponseEntity.ok(statsService.listWithStats());
}
@GetMapping("/check")
public ResponseEntity<Map<String, Object>> check(
HttpServletRequest request,
@RequestParam(required = false) String subdomain,
@RequestParam(required = false) String dbPrefix,
@RequestParam(required = false) String companyCode) {
enforceSuperAdmin(request);
Map<String, Object> result = new LinkedHashMap<>();
if (subdomain != null) {
boolean formatOk = isValidSubdomain(subdomain);
boolean exists = false;
if (formatOk) {
Map<String, Object> p = new HashMap<>();
p.put("subdomain", subdomain);
exists = sqlSession.selectOne("provisioning.existsSubdomain", p) != null;
}
Map<String, Object> sub = new LinkedHashMap<>();
sub.put("value", subdomain);
sub.put("valid_format", formatOk);
sub.put("reserved", RESERVED_SUBDOMAINS.contains(subdomain));
sub.put("available", formatOk && !exists);
sub.put("url_preview", subdomain + ".invyone.com");
result.put("subdomain", sub);
}
if (dbPrefix != null) {
boolean formatOk = isValidDbPrefix(dbPrefix);
String dbName = dbPrefix + "_vexplor";
boolean exists = false;
if (formatOk) {
Map<String, Object> p = new HashMap<>();
p.put("db_name", dbName);
exists = sqlSession.selectOne("provisioning.existsDbName", p) != null;
}
Map<String, Object> pre = new LinkedHashMap<>();
pre.put("value", dbPrefix);
pre.put("db_name", dbName);
pre.put("valid_format", formatOk);
pre.put("available", formatOk && !exists);
result.put("db_prefix", pre);
}
if (companyCode != null) {
boolean formatOk = isValidCompanyCode(companyCode);
boolean exists = false;
if (formatOk) {
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
exists = sqlSession.selectOne("provisioning.existsCompanyCode", p) != null;
}
Map<String, Object> cc = new LinkedHashMap<>();
cc.put("value", companyCode);
cc.put("valid_format", formatOk);
cc.put("available", formatOk && !exists);
result.put("company_code", cc);
}
return ResponseEntity.ok(result);
}
@PostMapping("/companies")
public ResponseEntity<Map<String, Object>> create(HttpServletRequest request,
@RequestBody Map<String, Object> req) {
enforceSuperAdmin(request);
String companyCode = asStr(req.get("company_code"));
String companyName = asStr(req.get("company_name"));
String subdomain = asStr(req.get("subdomain"));
String dbPrefix = asStr(req.getOrDefault("db_prefix", subdomain));
// 필수 필드
if (companyCode == null || companyName == null || subdomain == null || dbPrefix == null) {
return ResponseEntity.badRequest().body(Map.of(
"error", "missing_required",
"message", "company_code, company_name, subdomain, db_prefix 필수"));
}
// 포맷
if (!isValidCompanyCode(companyCode)) {
return ResponseEntity.badRequest().body(Map.of(
"error", "invalid_format", "field", "company_code"));
}
if (!isValidSubdomain(subdomain)) {
return ResponseEntity.badRequest().body(Map.of(
"error", "invalid_format", "field", "subdomain"));
}
if (!isValidDbPrefix(dbPrefix)) {
return ResponseEntity.badRequest().body(Map.of(
"error", "invalid_format", "field", "db_prefix"));
}
// 중복
String dbName = dbPrefix + "_vexplor";
if (sqlSession.selectOne("provisioning.existsCompanyCode", Map.of("company_code", companyCode)) != null) {
return ResponseEntity.status(409).body(Map.of("error", "duplicate", "field", "company_code"));
}
if (sqlSession.selectOne("provisioning.existsSubdomain", Map.of("subdomain", subdomain)) != null) {
return ResponseEntity.status(409).body(Map.of("error", "duplicate", "field", "subdomain"));
}
if (sqlSession.selectOne("provisioning.existsDbName", Map.of("db_name", dbName)) != null) {
return ResponseEntity.status(409).body(Map.of("error", "duplicate", "field", "db_prefix"));
}
Map<String, Object> reqMutable = new HashMap<>(req);
reqMutable.put("db_prefix", dbPrefix);
if (reqMutable.get("initial_password") == null || ((String) reqMutable.get("initial_password")).isBlank()) {
reqMutable.put("initial_password", generateRandomPassword());
}
ProvisioningStatus status;
try {
status = service.initiate(reqMutable);
} catch (DataIntegrityViolationException e) {
// 사전 체크 후 race condition 으로 끼어든 동일 요청 → DB UNIQUE 제약이 잡아줌
return ResponseEntity.status(409).body(Map.of(
"error", "duplicate",
"message", "동시 요청에서 회사 코드/서브도메인/DB 이름이 이미 사용됨"));
}
service.provisionAsync(status.getId(), reqMutable);
Map<String, Object> body = new LinkedHashMap<>();
body.put("provisioning_id", status.getId());
body.put("company_code", companyCode);
body.put("db_name", status.getDbName());
body.put("subdomain", subdomain);
body.put("admin_user_id", dbPrefix + "_admin");
body.put("initial_password", reqMutable.get("initial_password"));
body.put("status_url", "/api/admin/provisioning/status/" + status.getId());
// 초기 비밀번호가 본문에 포함되므로 캐시·프록시·브라우저 히스토리에 저장 금지
return ResponseEntity.accepted()
.header("Cache-Control", "no-store")
.header("Pragma", "no-cache")
.body(body);
}
@GetMapping("/status/{id}")
public ResponseEntity<Map<String, Object>> status(HttpServletRequest request,
@PathVariable("id") String id) {
enforceSuperAdmin(request);
ProvisioningStatus s = registry.get(id);
if (s == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok(s.toMap());
}
// ------------------------------------------------------------------
// 권한 체크
//
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
// ------------------------------------------------------------------
private void enforceSuperAdmin(HttpServletRequest request) {
String userType = (String) request.getAttribute("user_type");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
"tenant.provisioning.require-super-admin=true in production)");
return;
}
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
}
// --- Validation helpers ---
private static boolean isValidCompanyCode(String s) {
return s != null && s.matches("^[A-Z][A-Z0-9_]{2,30}$");
}
private static boolean isValidSubdomain(String s) {
return s != null
&& s.matches("^[a-z][a-z0-9-]{2,30}$")
&& !RESERVED_SUBDOMAINS.contains(s);
}
private static boolean isValidDbPrefix(String s) {
return s != null && s.matches("^[a-z][a-z0-9_]{2,30}$");
}
private static String asStr(Object v) {
return (v == null) ? null : v.toString().trim().isEmpty() ? null : v.toString().trim();
}
private static String generateRandomPassword() {
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#$";
SecureRandom r = new SecureRandom();
StringBuilder sb = new StringBuilder(12);
for (int i = 0; i < 12; i++) sb.append(chars.charAt(r.nextInt(chars.length())));
return sb.toString();
}
}
@@ -0,0 +1,66 @@
package com.erp.provisioning;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
/**
* 진행 중인 프로비저닝 작업의 인메모리 레지스트리.
* 완료/실패한 최종 상태는 COMPANY_MNG.db_status 에 영속화되므로, 이 레지스트리는
* "지금 진행 중이거나 방금 끝난" 단기 상태 전용.
*
* ★ 2026-04-24 Codex 리뷰 반영:
* - 완료/실패 후 TTL 24h 초과하면 자동 제거 (매시간 cleanup).
* - 이전 버전: 서버 무중단 운영 시 Map 이 영구 누적.
*/
@Service
@Slf4j
public class ProvisioningRegistry {
private static final int CLEANUP_TTL_HOURS = 24;
private final ConcurrentHashMap<String, ProvisioningStatus> jobs = new ConcurrentHashMap<>();
public void register(ProvisioningStatus status) {
jobs.put(status.getId(), status);
}
public ProvisioningStatus get(String id) {
return jobs.get(id);
}
public void update(String id, Consumer<ProvisioningStatus> updater) {
jobs.computeIfPresent(id, (k, v) -> {
updater.accept(v);
return v;
});
}
public Collection<ProvisioningStatus> list() {
return Collections.unmodifiableCollection(jobs.values());
}
/** 매시간 실행. 완료/실패 상태로 24h 지난 job 을 메모리에서 해제. */
@Scheduled(fixedDelayString = "${tenant.provisioning.cleanup-ms:3600000}", initialDelay = 600_000)
public void cleanupExpiredJobs() {
Instant threshold = Instant.now().minus(CLEANUP_TTL_HOURS, ChronoUnit.HOURS);
int before = jobs.size();
jobs.entrySet().removeIf(e -> {
ProvisioningStatus s = e.getValue();
String state = s.getStatus();
boolean done = "completed".equals(state) || "failed".equals(state);
return done && s.getCompletedAt() != null && s.getCompletedAt().isBefore(threshold);
});
int after = jobs.size();
if (before != after) {
log.info("[Provisioning] cleanup — removed {} expired jobs, {} remain", before - after, after);
}
}
}
@@ -0,0 +1,71 @@
package com.erp.provisioning;
import lombok.Data;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 프로비저닝 작업 진행 상태. 프론트가 /status/{id} 폴링으로 받아감.
* 인메모리 ProvisioningRegistry 가 보관. 서버 재시작 시 유실되며, 그 경우 failed 로 간주.
*/
@Data
public class ProvisioningStatus {
/** 프로비저닝 작업 고유 ID (UUID) */
private String id;
/** 생성 요청한 회사 코드 */
private String companyCode;
/** 생성 대상 DB 이름 (예: qnc_vexplor) */
private String dbName;
/** 서브도메인 */
private String subdomain;
/** Step.name() — 프론트에서 로컬라이즈 */
private String currentStep;
/** 1부터. 완료하면 progress == totalSteps */
private int progress;
private int totalSteps;
/** in_progress | completed | failed */
private String status;
/** 실패 시 어느 Step 에서 터졌는지 */
private String failedStep;
private String errorMessage;
private Instant startedAt;
private Instant completedAt;
public enum Step {
/** 메타 DB 에 COMPANY_MNG row 선반영 (status=pending) */
REGISTER_META,
/** CREATE DATABASE */
CREATE_DATABASE,
/** pg_dump --schema-only | psql */
COPY_SCHEMA,
/** 선택된 테이블 데이터 복사 (company_code 필터) */
COPY_DATA,
/** {prefix}_admin 계정 생성 */
CREATE_ADMIN,
/** COMPANY_MNG.db_status=active */
FINALIZE
}
public Map<String, Object> toMap() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", id);
m.put("companyCode", companyCode);
m.put("dbName", dbName);
m.put("subdomain", subdomain);
m.put("currentStep", currentStep);
m.put("progress", progress);
m.put("totalSteps", totalSteps);
m.put("status", status);
m.put("failedStep", failedStep);
m.put("errorMessage", errorMessage);
m.put("startedAt", startedAt == null ? null : startedAt.toString());
m.put("completedAt", completedAt == null ? null : completedAt.toString());
return m;
}
}
@@ -0,0 +1,132 @@
package com.erp.provisioning;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.regex.Pattern;
/**
* pg_dump → psql 파이프로 스키마 복제.
*
* ★ 보안 강화 (2026-04-24 Codex 리뷰 반영):
* - 이전 버전: "sh -c 'pg_dump ... | psql ...'" 로 쉘 문자열 구성 → command injection 표면.
* - 현재: ProcessBuilder 에 각 인자를 배열로 전달 + JVM 내부에서 stdout→stdin 파이프.
* - DB 이름은 ^[a-z][a-z0-9_]{{2,40}}$ 화이트리스트 재검증.
* - PGPASSWORD 는 여전히 env 로 전달. (.pgpass 전환은 별도 작업)
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SchemaCopier {
private static final Pattern SAFE_DB_NAME = Pattern.compile("^[a-z][a-z0-9_]{2,40}$");
private static final int BUFFER_SIZE = 64 * 1024;
private static final long TIMEOUT_MILLIS = 5 * 60 * 1000; // 5분
private final TenantDbSettings settings;
public void copySchema(String srcDb, String dstDb) throws IOException, InterruptedException {
requireSafeDbName(srcDb);
requireSafeDbName(dstDb);
ProcessBuilder dumpPb = new ProcessBuilder(
"pg_dump",
"-h", settings.host(),
"-p", String.valueOf(settings.port()),
"-U", settings.username(),
"-s", // schema only
"--no-owner",
"--no-privileges",
srcDb
);
dumpPb.environment().put("PGPASSWORD", settings.password());
dumpPb.redirectErrorStream(false);
ProcessBuilder restorePb = new ProcessBuilder(
"psql",
"-h", settings.host(),
"-p", String.valueOf(settings.port()),
"-U", settings.username(),
"-d", dstDb,
"-v", "ON_ERROR_STOP=1"
);
restorePb.environment().put("PGPASSWORD", settings.password());
restorePb.redirectErrorStream(false);
Process dump = dumpPb.start();
Process restore = restorePb.start();
// dump stdout -> restore stdin
Thread pipe = new Thread(() -> {
try (InputStream in = dump.getInputStream();
OutputStream out = restore.getOutputStream()) {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
} catch (IOException e) {
log.warn("[Provisioning] pipe error (dump→psql): {}", e.getMessage());
}
}, "schema-copy-pipe");
pipe.setDaemon(true);
// stderr 캡처 (비동기)
StringBuilder dumpErr = new StringBuilder();
StringBuilder restoreErr = new StringBuilder();
Thread dumpErrT = asyncCollect(dump.getErrorStream(), dumpErr, "pg_dump-stderr");
Thread restoreErrT = asyncCollect(restore.getErrorStream(), restoreErr, "psql-stderr");
pipe.start();
dumpErrT.start();
restoreErrT.start();
long deadline = System.currentTimeMillis() + TIMEOUT_MILLIS;
boolean dumpDone = dump.waitFor(TIMEOUT_MILLIS, java.util.concurrent.TimeUnit.MILLISECONDS);
if (!dumpDone) { dump.destroyForcibly(); restore.destroyForcibly(); throw new IOException("pg_dump timed out"); }
long remaining = Math.max(1, deadline - System.currentTimeMillis());
boolean restoreDone = restore.waitFor(remaining, java.util.concurrent.TimeUnit.MILLISECONDS);
if (!restoreDone) { restore.destroyForcibly(); throw new IOException("psql timed out"); }
pipe.join(5_000);
dumpErrT.join(5_000);
restoreErrT.join(5_000);
int dumpExit = dump.exitValue();
int restoreExit = restore.exitValue();
if (dumpExit != 0 || restoreExit != 0) {
String dumpLog = dumpErr.length() > 2000 ? dumpErr.substring(0, 2000) + "" : dumpErr.toString();
String restoreLog = restoreErr.length() > 2000 ? restoreErr.substring(0, 2000) + "" : restoreErr.toString();
throw new IOException(String.format(
"schema copy failed (dumpExit=%d, restoreExit=%d)\n--pg_dump stderr--\n%s\n--psql stderr--\n%s",
dumpExit, restoreExit, dumpLog, restoreLog));
}
log.info("[Provisioning] COPY SCHEMA: {} -> {}", srcDb, dstDb);
}
private static void requireSafeDbName(String name) {
if (name == null || !SAFE_DB_NAME.matcher(name).matches()) {
throw new IllegalArgumentException("unsafe db name: " + name);
}
}
private static Thread asyncCollect(InputStream in, StringBuilder sink, String threadName) {
Thread t = new Thread(() -> {
try (InputStream is = in) {
byte[] buf = new byte[4096];
int n;
while ((n = is.read(buf)) != -1) {
sink.append(new String(buf, 0, n, StandardCharsets.UTF_8));
if (sink.length() > 10_000) break; // runaway 방어
}
} catch (IOException ignored) { }
}, threadName);
t.setDaemon(true);
return t;
}
}
@@ -0,0 +1,66 @@
package com.erp.provisioning;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
/**
* 회사 프로비저닝 시 복사 가능한 테이블 그룹.
* 그룹 id = enum 이름 (SCREEN 등). UI 체크박스와 1:1 매핑.
* required=true 그룹은 UI 에서 해제 불가 (disabled checkbox).
*/
public enum TableGroup {
SCREEN("화면관리", true, List.of(
"screen_definitions", "screen_groups", "screen_group_screens",
"screen_layouts_v1", "screen_layouts_v2", "screen_layouts_v3",
"screen_layouts_pop", "screen_menu_assignments",
"screen_conditional_zones", "screen_data_flows", "screen_data_transfer",
"screen_embedding", "screen_field_joins", "screen_split_panel",
"screen_table_relations", "screen_templates"
)),
CONTROL("제어관리", true, List.of("dtg_management")),
BATCH("배치관리", true, List.of("batch_configs", "batch_mappings", "batch_execution_logs")),
DASHBOARD("대시보드", false, List.of("dashboard_cards", "dashboard_elements")),
DATAFLOW("데이터플로우", false, List.of("dataflow_diagrams", "node_flows")),
MENU_AUTH("권한/메뉴 기본값", false, List.of(
"menu_info", "authority_master", "authority_master_history",
"authority_sub_menu", "authority_sub_user"
));
private final String label;
private final boolean required;
private final List<String> tables;
TableGroup(String label, boolean required, List<String> tables) {
this.label = label;
this.required = required;
this.tables = tables;
}
public String getLabel() { return label; }
public boolean isRequired() { return required; }
public List<String> getTables() { return tables; }
/** API 응답용 Map 변환. UI 에서 체크박스 렌더에 필요한 모든 정보. */
public Map<String, Object> toMap() {
return Map.of(
"id", name(),
"label", label,
"required", required,
"tables", tables
);
}
public static TableGroup parse(String id) {
try {
return TableGroup.valueOf(id);
} catch (IllegalArgumentException e) {
return null;
}
}
public static List<TableGroup> requiredGroups() {
return Arrays.stream(values()).filter(TableGroup::isRequired).toList();
}
}
@@ -1,6 +1,11 @@
package com.erp.security;
import com.erp.tenant.CompanyResolver;
import com.erp.tenant.SubdomainResolverFilter;
import com.erp.tenant.TenantDbSettings;
import com.erp.tenant.TenantRoutingDataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -22,9 +27,13 @@ import java.util.List;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final CompanyResolver companyResolver;
private final TenantRoutingDataSource tenantRoutingDataSource;
private final TenantDbSettings tenantDbSettings;
/**
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invion.com").
@@ -48,7 +57,12 @@ public class SecurityConfig {
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
UsernamePasswordAuthenticationFilter.class)
// Phase 2 (2026-04-24): 서브도메인 → CompanyResolver → TenantRoutingDataSource 라우팅.
// JwtAuthenticationFilter 보다 앞에서 실행되어야 tenant 컨텍스트가 먼저 결정됨.
.addFilterBefore(
new SubdomainResolverFilter(companyResolver, tenantRoutingDataSource, tenantDbSettings),
JwtAuthenticationFilter.class);
return http.build();
}
@@ -61,12 +75,16 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// 와일드카드 금지 — 환경변수에서 받은 화이트리스트만 허
List<String> origins = Arrays.stream(corsAllowedOrigins.split(","))
// 테넌트 서브도메인 지원을 위해 setAllowedOriginPatterns 사
// (setAllowedOrigins 는 정확한 매칭만 허용해서 *.invyone.com 같은 패턴이 안 됨)
// 전체 와일드카드 '*' 는 금지 — 반드시 명시된 prefix 만 허용.
List<String> patterns = Arrays.stream(corsAllowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
config.setAllowedOrigins(origins);
log.info("[CORS] raw value from @Value = '{}'", corsAllowedOrigins);
log.info("[CORS] parsed patterns ({}): {}", patterns.size(), patterns);
config.setAllowedOriginPatterns(patterns);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
@@ -0,0 +1,55 @@
package com.erp.tenant;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 서브도메인 → tenant DB 이름 매핑.
* 메타 DB (COMPANY_MNG) 에서 조회, ConcurrentHashMap 으로 캐시.
* null 결과(= 매핑 없음)는 캐시하지 않음 — 신규 회사 프로비저닝 직후 바로 반영되게.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyResolver {
private final SqlSession sqlSession;
private final Map<String, String> cache = new ConcurrentHashMap<>();
/**
* @return tenant DB 이름, 매핑 없으면 null.
*/
public String resolveDbName(String subdomain) {
if (subdomain == null || subdomain.isBlank()) return null;
String cached = cache.get(subdomain);
if (cached != null) return cached;
Map<String, Object> params = new HashMap<>();
params.put("subdomain", subdomain);
String dbName = sqlSession.selectOne("tenant.resolveDbNameBySubdomain", params);
if (dbName != null) {
cache.put(subdomain, dbName);
log.debug("[CompanyResolver] resolved: {} -> {}", subdomain, dbName);
}
return dbName;
}
/** 특정 서브도메인 캐시 무효화 (회사 수정/삭제 시) */
public void invalidate(String subdomain) {
if (subdomain != null) cache.remove(subdomain);
}
/** 전체 캐시 무효화 (운영 도구/디버그용) */
public void invalidateAll() {
cache.clear();
}
}
@@ -0,0 +1,35 @@
package com.erp.tenant;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* 기본 DataSource 를 {@link TenantRoutingDataSource} 로 교체.
* - @Bean DataSource 를 제공하므로 Spring Boot DataSourceAutoConfiguration 의 자동 DataSource 는 스킵됨.
* - MyBatis / TransactionManager 등 하위 컴포넌트는 이 @Primary bean 을 자동으로 사용.
*/
@Configuration
@Slf4j
public class DataSourceConfig {
@Bean
public TenantDbSettings tenantDbSettings(DataSourceProperties props) {
TenantDbSettings s = TenantDbSettings.fromJdbcUrl(props.getUrl(), props.getUsername(), props.getPassword());
log.info("[DataSourceConfig] tenant default endpoint: {}:{}", s.host(), s.port());
return s;
}
@Bean
@Primary
public TenantRoutingDataSource dataSource(DataSourceProperties props) {
HikariDataSource meta = TenantDataSourceFactory.createMeta(
props.getUrl(), props.getUsername(), props.getPassword());
TenantRoutingDataSource routing = new TenantRoutingDataSource();
routing.registerMeta(meta);
return routing;
}
}
@@ -0,0 +1,36 @@
package com.erp.tenant;
/**
* 요청 단위 tenant DB 컨텍스트.
* SubdomainResolverFilter 가 세팅하고, (Phase 2 이후) TenantRoutingDataSource 가 읽는다.
* Phase 1 단계에선 세팅/클리어만 수행. 실제 라우팅은 Phase 2에서 붙음.
*/
public final class DbContextHolder {
/** 메타 DB (invyone.com / admin.invyone.com / 서브도메인 없음) 를 가리키는 센티넬 */
public static final String META = "__META__";
private static final ThreadLocal<String> CTX = new ThreadLocal<>();
private DbContextHolder() {}
public static void set(String dbName) {
CTX.set(dbName);
}
public static void setMeta() {
CTX.set(META);
}
public static String get() {
return CTX.get();
}
public static boolean isMeta() {
return META.equals(CTX.get());
}
public static void clear() {
CTX.remove();
}
}
@@ -0,0 +1,104 @@
package com.erp.tenant;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.regex.Pattern;
/**
* Host 헤더에서 서브도메인을 파싱 → CompanyResolver 로 DB 이름 조회 →
* TenantRoutingDataSource 에 tenant 풀이 없으면 lazy 생성 → DbContextHolder 세팅.
*
* Phase 2 (실제 라우팅):
* - localhost / IP / invyone.com / www / admin → META
* - 알 수 없는 서브도메인 → META (log.debug 만)
* - 등록된 서브도메인 → tenant DB 로 라우팅
*
* SecurityConfig 에서 JwtAuthenticationFilter 앞에 등록됨.
*/
@Slf4j
@RequiredArgsConstructor
public class SubdomainResolverFilter extends OncePerRequestFilter {
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
private final CompanyResolver companyResolver;
private final TenantRoutingDataSource routingDataSource;
private final TenantDbSettings tenantDbSettings;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
try {
String host = request.getHeader("Host");
String subdomain = extractSubdomain(host);
if (subdomain == null) {
DbContextHolder.setMeta();
} else {
// resolve 쿼리 자체는 메타 DB 를 타야 하므로 먼저 META 로 세팅.
DbContextHolder.setMeta();
String dbName = companyResolver.resolveDbName(subdomain);
if (dbName == null) {
log.debug("[Tenant] unknown subdomain '{}' → META fallback (host={})", subdomain, host);
// META 유지
} else {
ensureTenantPool(dbName);
DbContextHolder.set(dbName);
log.info("[Tenant] routed: subdomain={} → dbName={} (path={})",
subdomain, dbName, request.getRequestURI());
}
}
chain.doFilter(request, response);
} finally {
DbContextHolder.clear();
}
}
/** 회사 DB 풀이 없으면 최초 1회 생성. minIdle=0 정책은 Factory 가 책임. */
private void ensureTenantPool(String dbName) {
if (routingDataSource.hasTenant(dbName)) return;
synchronized (routingDataSource) {
if (routingDataSource.hasTenant(dbName)) return;
HikariDataSource ds = TenantDataSourceFactory.createTenant(
tenantDbSettings.buildJdbcUrl(dbName),
tenantDbSettings.username(),
tenantDbSettings.password(),
dbName);
routingDataSource.addTenant(dbName, ds);
}
}
/**
* Host 헤더에서 서브도메인 추출. 포트 제거 + IP/localhost/www/admin 제외.
*/
static String extractSubdomain(String host) {
if (host == null || host.isBlank()) return null;
int colon = host.indexOf(':');
if (colon != -1) host = host.substring(0, colon);
host = host.toLowerCase();
if ("localhost".equals(host)) return null;
if (IPV4.matcher(host).matches()) return null;
String[] parts = host.split("\\.");
if (parts.length < 3) return null; // invyone.com (2파트) → null
String first = parts[0];
if ("www".equals(first) || "admin".equals(first)) return null;
return first;
}
}
@@ -0,0 +1,46 @@
package com.erp.tenant;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
/**
* HikariDataSource 빌더.
*
* - META 용: 앱 상시 접근. 기존 application.yml 의 설정값과 호환 (minIdle=2, max=10).
* - Tenant 용: 회사 수만큼 풀이 복제되므로 ★ minIdle=0 ★ + idleTimeout=1분.
* → 안 쓰는 회사 풀은 커넥션 0으로 수축, Postgres max_connections 압박 방지.
* (실행계획 문서 2.1 참조)
*/
public final class TenantDataSourceFactory {
private TenantDataSourceFactory() {}
public static HikariDataSource createMeta(String jdbcUrl, String username, String password) {
HikariConfig cfg = base(jdbcUrl, username, password);
cfg.setPoolName("meta-hikari");
cfg.setMaximumPoolSize(10);
cfg.setMinimumIdle(2);
cfg.setIdleTimeout(600_000);
return new HikariDataSource(cfg);
}
public static HikariDataSource createTenant(String jdbcUrl, String username, String password, String dbName) {
HikariConfig cfg = base(jdbcUrl, username, password);
cfg.setPoolName("tenant-" + dbName);
cfg.setMaximumPoolSize(5);
cfg.setMinimumIdle(0); // ★ 회사 풀 폭증 방지의 핵심
cfg.setIdleTimeout(60_000); // 1분 idle → 반납
return new HikariDataSource(cfg);
}
private static HikariConfig base(String jdbcUrl, String username, String password) {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(jdbcUrl);
cfg.setUsername(username);
cfg.setPassword(password);
cfg.setDriverClassName("org.postgresql.Driver");
cfg.setConnectionTimeout(30_000);
cfg.setMaxLifetime(1_800_000);
return cfg;
}
}
@@ -0,0 +1,36 @@
package com.erp.tenant;
import java.net.URI;
/**
* tenant DB 연결 공통 설정. 기존 {@code spring.datasource.url} 을 파싱해서
* host/port/credential 을 회사별 pool 생성 시 재사용한다.
* application.yml 에 별도 {@code tenant.*} prefix 를 추가하지 않는 이유:
* - 중복 저장 금지
* - 회사별 DB 는 기본적으로 같은 Postgres 인스턴스에 생성되므로 credential 1셋으로 충분
* - 다중 서버로 분산할 때는 COMPANY_MNG.DB_HOST 컬럼을 사용 (Phase 6)
*/
public record TenantDbSettings(String host, int port, String username, String password) {
/**
* jdbc:postgresql://host:port/dbname 에서 host/port 추출.
* port 생략 시 5432 기본값.
*/
public static TenantDbSettings fromJdbcUrl(String jdbcUrl, String username, String password) {
if (jdbcUrl == null || !jdbcUrl.startsWith("jdbc:")) {
throw new IllegalArgumentException("Unsupported jdbcUrl: " + jdbcUrl);
}
// "jdbc:postgresql://host:port/db?..." → URI 파싱 가능하게 "jdbc:" 제거
URI uri = URI.create(jdbcUrl.substring("jdbc:".length()));
String host = uri.getHost();
int port = uri.getPort() == -1 ? 5432 : uri.getPort();
if (host == null) {
throw new IllegalArgumentException("Cannot parse host from jdbcUrl: " + jdbcUrl);
}
return new TenantDbSettings(host, port, username, password);
}
public String buildJdbcUrl(String dbName) {
return String.format("jdbc:postgresql://%s:%d/%s", host, port, dbName);
}
}
@@ -0,0 +1,96 @@
package com.erp.tenant;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.concurrent.ConcurrentHashMap;
/**
* {@link DbContextHolder#get()} 값을 lookup key 로 사용해 요청마다 tenant DataSource 로 라우팅.
*
* ★ 2026-04-24 Codex 리뷰 반영:
* - 이전 버전은 {@code setTargetDataSources() + afterPropertiesSet()} 를 add/remove 때마다 재호출해
* 내부 {@code resolvedDataSources} 를 rebuild 했음. 그 사이 lookup 이 들어오면 기존 체크아웃
* 커넥션과 새 풀이 충돌할 수 있음.
* - 지금은 {@link #determineTargetDataSource()} 자체를 override 하고 {@link ConcurrentHashMap} 을
* 직접 조회. {@code afterPropertiesSet()} 은 no-op 로 override 해서 super 의 rebuild 로직을
* 사용하지 않음.
* - add/remove 는 put/remove 단일 연산이라 락 없이 안전.
*/
@Slf4j
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
private final ConcurrentHashMap<String, DataSource> sources = new ConcurrentHashMap<>();
public void registerMeta(DataSource meta) {
sources.put(DbContextHolder.META, meta);
log.info("[TenantRouting] META DataSource registered");
}
public void addTenant(String dbName, DataSource ds) {
DataSource prev = sources.putIfAbsent(dbName, ds);
if (prev != null) {
log.warn("[TenantRouting] tenant '{}' already registered — new instance ignored", dbName);
closeIfHikari(ds);
return;
}
log.info("[TenantRouting] tenant DataSource added: {}", dbName);
}
/** ★ LRU/TTL eviction 전 단계 — 수동으로 풀을 닫고 싶을 때 사용. Phase 6 에서 자동화 예정. */
public boolean evictTenant(String dbName) {
DataSource removed = sources.remove(dbName);
if (removed == null) return false;
closeIfHikari(removed);
log.warn("[TenantRouting] tenant DataSource evicted: {}", dbName);
return true;
}
public boolean hasTenant(String dbName) {
return sources.containsKey(dbName);
}
public DataSource getMetaDataSource() {
return sources.get(DbContextHolder.META);
}
// ------------------------------------------------------------------
// AbstractRoutingDataSource override
// ------------------------------------------------------------------
/** super 의 resolvedDataSources rebuild 를 타지 않도록 no-op. */
@Override
public void afterPropertiesSet() {
// intentionally empty
}
@Override
protected DataSource determineTargetDataSource() {
String key = (String) determineCurrentLookupKey();
if (key == null) key = DbContextHolder.META;
DataSource ds = sources.get(key);
if (ds != null) return ds;
// 알 수 없는 tenant key → META 로 안전 fallback
DataSource meta = sources.get(DbContextHolder.META);
if (meta == null) {
throw new IllegalStateException("META DataSource not registered yet");
}
log.debug("[TenantRouting] key '{}' not found, falling back to META", key);
return meta;
}
@Override
protected Object determineCurrentLookupKey() {
String key = DbContextHolder.get();
return key != null ? key : DbContextHolder.META;
}
private static void closeIfHikari(DataSource ds) {
if (ds instanceof HikariDataSource h) {
try { h.close(); } catch (Exception ignored) { }
}
}
}