This commit is contained in:
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user