Files
invyone/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java
T
johngreen e1be30f8ca
Build & Deploy to K8s / build-and-deploy (push) Failing after 6m54s
fix: 메뉴 순서 V017 을 모든 활성 테넌트 DB 에도 적용
Flyway V017 은 메타 DB 만 갱신하지만 MENU_INFO 의 슈퍼관리자 메뉴
(COMPANY_CODE='*') 는 회사 프로비저닝 때 각 테넌트 DB 로 복사되어
박혀있다. StartupSchemaMigrator 에 동일 UPDATE 를 넣어 부팅 시 메타 +
모든 활성 테넌트에 자동 동기화. SEQ 만 갱신하므로 멱등.
2026-05-04 13:16:10 +09:00

123 lines
4.9 KiB
Java

package com.erp.migration;
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.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.util.List;
/**
* 앱 부팅 시 idempotent 스키마 마이그레이션 실행.
*
* 왜 필요:
* DB-per-tenant 구조 → 메타 DB 1개 + 활성 테넌트 DB N개 에 동일 ALTER 필요.
* runbook 문서 (`db/migrations/RUN_*.md`) 로만 남기면 배포 때 사람이 까먹으면 장애.
* 부팅 때 `IF NOT EXISTS` 로 안전하게 돌려두면 배포 순서/인적 실수와 무관.
*
* 원칙:
* - 각 ALTER 는 반드시 idempotent (`IF NOT EXISTS` / 재실행 안전)
* - 테넌트 DB 하나 실패해도 다른 DB 는 계속 진행 (ERROR 로그만)
* - 메타 DB 가 실패하면 앱 시작은 계속되지만 WARN 크게 남김
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class StartupSchemaMigrator {
private final TenantDbSettings tenantDbSettings;
private final SqlSession sqlSession;
@Value("${spring.datasource.url}")
private String metaJdbcUrl;
private static final List<String> MIGRATIONS = List.of(
// RUN_082: 첫 로그인 비밀번호 강제 변경 플래그
"ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE",
// V017: 회사 관리 그룹 하위 관리자 메뉴 순서 재배열
// 조직 계층(회사→부서→사용자) + 권한 체계(메뉴→권한→권한그룹)
// 메타 DB 는 Flyway V017 로도 적용되지만 프로비저닝된 테넌트 DB 는
// 회사 생성 시점 스냅샷이 박혀있으므로 부팅 때 모든 활성 DB 에 동기화.
// SEQ 만 갱신 → 멱등.
"""
UPDATE MENU_INFO
SET SEQ = CASE MENU_NAME_KOR
WHEN '회사관리' THEN 100
WHEN '부서관리' THEN 200
WHEN '사용자관리' THEN 300
WHEN '메뉴관리' THEN 400
WHEN '권한관리' THEN 500
WHEN '권한 그룹관리' THEN 600
ELSE SEQ
END
WHERE MENU_TYPE = '0'
AND COMPANY_CODE = '*'
AND PARENT_OBJ_ID IS NOT NULL
AND PARENT_OBJ_ID <> '0'
AND MENU_NAME_KOR IN (
'회사관리', '부서관리', '사용자관리',
'메뉴관리', '권한관리', '권한 그룹관리'
)
"""
);
@EventListener(ApplicationReadyEvent.class)
public void run() {
log.info("[SchemaMigrator] start — {} migration(s)", MIGRATIONS.size());
String metaDb = parseMetaDbName(metaJdbcUrl);
applyTo(metaDb, "meta");
// 테넌트 목록 조회는 반드시 메타 라우팅으로
DbContextHolder.setMeta();
List<String> tenantDbs;
try {
tenantDbs = sqlSession.selectList("provisioning.listActiveDbNames");
} catch (Exception e) {
log.warn("[SchemaMigrator] tenant list query failed — skipping tenants: {}", e.getMessage());
tenantDbs = List.of();
} finally {
DbContextHolder.clear();
}
int ok = 0, fail = 0;
for (String db : tenantDbs) {
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
if (applyTo(db, "tenant")) ok++; else fail++;
}
log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail);
}
private boolean applyTo(String dbName, String kind) {
String url = tenantDbSettings.buildJdbcUrl(dbName);
try (Connection c = DriverManager.getConnection(url, tenantDbSettings.username(), tenantDbSettings.password());
Statement s = c.createStatement()) {
for (String ddl : MIGRATIONS) {
s.execute(ddl);
}
log.info("[SchemaMigrator] {} db='{}' OK", kind, dbName);
return true;
} catch (Exception e) {
log.error("[SchemaMigrator] {} db='{}' FAILED: {}", kind, dbName, e.getMessage());
return false;
}
}
static String parseMetaDbName(String jdbcUrl) {
int slash = jdbcUrl.lastIndexOf('/');
if (slash < 0) return "invyone";
String tail = jdbcUrl.substring(slash + 1);
int q = tail.indexOf('?');
return q < 0 ? tail : tail.substring(0, q);
}
}