diff --git a/backend-spring/src/main/java/com/erp/ErpApplication.java b/backend-spring/src/main/java/com/erp/ErpApplication.java index 061ac6f5..f1dbfd44 100644 --- a/backend-spring/src/main/java/com/erp/ErpApplication.java +++ b/backend-spring/src/main/java/com/erp/ErpApplication.java @@ -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); diff --git a/backend-spring/src/main/java/com/erp/provisioning/AdminAccountCreator.java b/backend-spring/src/main/java/com/erp/provisioning/AdminAccountCreator.java new file mode 100644 index 00000000..22eadf45 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/provisioning/AdminAccountCreator.java @@ -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); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/provisioning/CompanyProvisioningService.java b/backend-spring/src/main/java/com/erp/provisioning/CompanyProvisioningService.java new file mode 100644 index 00000000..e03be17a --- /dev/null +++ b/backend-spring/src/main/java/com/erp/provisioning/CompanyProvisioningService.java @@ -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 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 selectedGroupIds = (List) 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 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 req) { + // @Async 스레드는 새로 생성되어 ThreadLocal 을 상속받지 않음. + // sqlSession 이 tenant 라우팅을 타므로 메타 DB 를 명시적으로 고정. + DbContextHolder.setMeta(); + try { + doProvision(jobId, req); + } finally { + DbContextHolder.clear(); + } + } + + private void doProvision(String jobId, Map 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 selectedGroupIds = (List) req.getOrDefault("selected_groups", List.of()); + List 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 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 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 gatherTables(List selectedGroupIds) { + List result = new ArrayList<>(); + for (TableGroup g : TableGroup.values()) { + if (g.isRequired() || selectedGroupIds.contains(g.name())) { + result.addAll(g.getTables()); + } + } + return result; + } +} diff --git a/backend-spring/src/main/java/com/erp/provisioning/CompanyStatsService.java b/backend-spring/src/main/java/com/erp/provisioning/CompanyStatsService.java new file mode 100644 index 00000000..2c04fd2b --- /dev/null +++ b/backend-spring/src/main/java/com/erp/provisioning/CompanyStatsService.java @@ -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> listWithStats() { + List> rows = sqlSession.selectList("provisioning.listCompaniesForUi"); + for (Map r : rows) { + enrichOne(r); + } + return rows; + } + + private void enrichOne(Map 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; } + } +} diff --git a/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java b/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java new file mode 100644 index 00000000..165b56be --- /dev/null +++ b/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java @@ -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 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 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 listColumns(Connection conn, String table) throws SQLException { + List 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(); + } + } + } +} diff --git a/backend-spring/src/main/java/com/erp/provisioning/DatabaseCreator.java b/backend-spring/src/main/java/com/erp/provisioning/DatabaseCreator.java new file mode 100644 index 00000000..bbb5812f --- /dev/null +++ b/backend-spring/src/main/java/com/erp/provisioning/DatabaseCreator.java @@ -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); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java b/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java new file mode 100644 index 00000000..9d3e11cc --- /dev/null +++ b/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java @@ -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 RESERVED_SUBDOMAINS = Set.of( + "www", "admin", "api", "app", "static", "assets", + "main", "mail", "blog", "dev", "test", "staging", "prod", "console" + ); + + @GetMapping("/table-groups") + public ResponseEntity>> 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>> companiesStats(HttpServletRequest request) { + enforceSuperAdmin(request); + return ResponseEntity.ok(statsService.listWithStats()); + } + + @GetMapping("/check") + public ResponseEntity> check( + HttpServletRequest request, + @RequestParam(required = false) String subdomain, + @RequestParam(required = false) String dbPrefix, + @RequestParam(required = false) String companyCode) { + enforceSuperAdmin(request); + Map result = new LinkedHashMap<>(); + + if (subdomain != null) { + boolean formatOk = isValidSubdomain(subdomain); + boolean exists = false; + if (formatOk) { + Map p = new HashMap<>(); + p.put("subdomain", subdomain); + exists = sqlSession.selectOne("provisioning.existsSubdomain", p) != null; + } + Map 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 p = new HashMap<>(); + p.put("db_name", dbName); + exists = sqlSession.selectOne("provisioning.existsDbName", p) != null; + } + Map 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 p = new HashMap<>(); + p.put("company_code", companyCode); + exists = sqlSession.selectOne("provisioning.existsCompanyCode", p) != null; + } + Map 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> create(HttpServletRequest request, + @RequestBody Map 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 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 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> 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(); + } +} diff --git a/backend-spring/src/main/java/com/erp/provisioning/ProvisioningRegistry.java b/backend-spring/src/main/java/com/erp/provisioning/ProvisioningRegistry.java new file mode 100644 index 00000000..b95cb2ad --- /dev/null +++ b/backend-spring/src/main/java/com/erp/provisioning/ProvisioningRegistry.java @@ -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 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 updater) { + jobs.computeIfPresent(id, (k, v) -> { + updater.accept(v); + return v; + }); + } + + public Collection 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); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/provisioning/ProvisioningStatus.java b/backend-spring/src/main/java/com/erp/provisioning/ProvisioningStatus.java new file mode 100644 index 00000000..d1b667ff --- /dev/null +++ b/backend-spring/src/main/java/com/erp/provisioning/ProvisioningStatus.java @@ -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 toMap() { + Map 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; + } +} diff --git a/backend-spring/src/main/java/com/erp/provisioning/SchemaCopier.java b/backend-spring/src/main/java/com/erp/provisioning/SchemaCopier.java new file mode 100644 index 00000000..2be4d126 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/provisioning/SchemaCopier.java @@ -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; + } +} diff --git a/backend-spring/src/main/java/com/erp/provisioning/TableGroup.java b/backend-spring/src/main/java/com/erp/provisioning/TableGroup.java new file mode 100644 index 00000000..6c211f87 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/provisioning/TableGroup.java @@ -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 tables; + + TableGroup(String label, boolean required, List tables) { + this.label = label; + this.required = required; + this.tables = tables; + } + + public String getLabel() { return label; } + public boolean isRequired() { return required; } + public List getTables() { return tables; } + + /** API 응답용 Map 변환. UI 에서 체크박스 렌더에 필요한 모든 정보. */ + public Map 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 requiredGroups() { + return Arrays.stream(values()).filter(TableGroup::isRequired).toList(); + } +} diff --git a/backend-spring/src/main/java/com/erp/security/SecurityConfig.java b/backend-spring/src/main/java/com/erp/security/SecurityConfig.java index 83f8fc33..534ec153 100644 --- a/backend-spring/src/main/java/com/erp/security/SecurityConfig.java +++ b/backend-spring/src/main/java/com/erp/security/SecurityConfig.java @@ -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 origins = Arrays.stream(corsAllowedOrigins.split(",")) + // 테넌트 서브도메인 지원을 위해 setAllowedOriginPatterns 사용 + // (setAllowedOrigins 는 정확한 매칭만 허용해서 *.invyone.com 같은 패턴이 안 됨) + // 전체 와일드카드 '*' 는 금지 — 반드시 명시된 prefix 만 허용. + List 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); diff --git a/backend-spring/src/main/java/com/erp/tenant/CompanyResolver.java b/backend-spring/src/main/java/com/erp/tenant/CompanyResolver.java new file mode 100644 index 00000000..b88b58b6 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/tenant/CompanyResolver.java @@ -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 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 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(); + } +} diff --git a/backend-spring/src/main/java/com/erp/tenant/DataSourceConfig.java b/backend-spring/src/main/java/com/erp/tenant/DataSourceConfig.java new file mode 100644 index 00000000..b62ed42f --- /dev/null +++ b/backend-spring/src/main/java/com/erp/tenant/DataSourceConfig.java @@ -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; + } +} diff --git a/backend-spring/src/main/java/com/erp/tenant/DbContextHolder.java b/backend-spring/src/main/java/com/erp/tenant/DbContextHolder.java new file mode 100644 index 00000000..22559ae2 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/tenant/DbContextHolder.java @@ -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 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(); + } +} diff --git a/backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java b/backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java new file mode 100644 index 00000000..2e157161 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java @@ -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; + } +} diff --git a/backend-spring/src/main/java/com/erp/tenant/TenantDataSourceFactory.java b/backend-spring/src/main/java/com/erp/tenant/TenantDataSourceFactory.java new file mode 100644 index 00000000..8864f7cd --- /dev/null +++ b/backend-spring/src/main/java/com/erp/tenant/TenantDataSourceFactory.java @@ -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; + } +} diff --git a/backend-spring/src/main/java/com/erp/tenant/TenantDbSettings.java b/backend-spring/src/main/java/com/erp/tenant/TenantDbSettings.java new file mode 100644 index 00000000..e4fc3043 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/tenant/TenantDbSettings.java @@ -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); + } +} diff --git a/backend-spring/src/main/java/com/erp/tenant/TenantRoutingDataSource.java b/backend-spring/src/main/java/com/erp/tenant/TenantRoutingDataSource.java new file mode 100644 index 00000000..71ab8d2d --- /dev/null +++ b/backend-spring/src/main/java/com/erp/tenant/TenantRoutingDataSource.java @@ -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 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) { } + } + } +} diff --git a/backend-spring/src/main/resources/application.yml b/backend-spring/src/main/resources/application.yml index 85101ee2..f5cad107 100644 --- a/backend-spring/src/main/resources/application.yml +++ b/backend-spring/src/main/resources/application.yml @@ -39,8 +39,11 @@ jwt: expiration: ${JWT_EXPIRATION:86400000} cors: - # 콤마 구분 문자열. dev 디폴트는 localhost 와 사무실 Tailscale IP - allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772} + # 콤마 구분 문자열. setAllowedOriginPatterns 로 매칭됨. + # Spring CORS 문법: 포트 와일드카드는 `[*]` 로 표기. YAML 이 `[...]` 를 sequence 로 해석하지 + # 않도록 반드시 따옴표로 감싸기. + # dev 디폴트: localhost + 사무실 Tailscale IP + 테넌트 서브도메인 (모든 포트) 패턴. + allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}" file: upload-dir: ./uploads diff --git a/backend-spring/src/main/resources/mapper/provisioning.xml b/backend-spring/src/main/resources/mapper/provisioning.xml new file mode 100644 index 00000000..54277a63 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/provisioning.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + INSERT INTO COMPANY_MNG ( + COMPANY_CODE + , COMPANY_NAME + , BUSINESS_REGISTRATION_NUMBER + , REPRESENTATIVE_NAME + , REPRESENTATIVE_PHONE + , EMAIL + , WEBSITE + , ADDRESS + , STATUS + , DB_NAME + , SUBDOMAIN + , DB_HOST + , DB_STATUS + , PLAN + , INDUSTRY + , TEMPLATES_COUNT + , WRITER + , CREATED_DATE + ) VALUES ( + #{company_code} + , #{company_name} + , #{business_registration_number} + , #{representative_name} + , #{representative_phone} + , #{email} + , #{website} + , #{address} + , COALESCE(#{status}, 'active') + , #{db_name} + , #{subdomain} + , #{db_host} + , COALESCE(#{db_status}, 'provisioning') + , COALESCE(#{plan}, 'Starter') + , #{industry} + , COALESCE(#{templates_count}, 0) + , #{writer} + , NOW() + ) + + + + + + + UPDATE COMPANY_MNG + SET DB_STATUS = #{db_status} + WHERE COMPANY_CODE = #{company_code} + + + diff --git a/backend-spring/src/main/resources/mapper/tenant.xml b/backend-spring/src/main/resources/mapper/tenant.xml new file mode 100644 index 00000000..b53314d1 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/tenant.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/db/migrations/RUN_079_MIGRATION.md b/db/migrations/RUN_079_MIGRATION.md new file mode 100644 index 00000000..3d398fdc --- /dev/null +++ b/db/migrations/RUN_079_MIGRATION.md @@ -0,0 +1,104 @@ +# 079 마이그레이션 실행 가이드 — 멀티테넌시 Phase 1 + +작성일: 2026-04-24 +작성자: gbpark +관련 문서: `notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md` + +## 목적 + +회사별 DB 자동 프로비저닝 & 서브도메인 라우팅을 위한 메타 컬럼을 `COMPANY_MNG` 테이블에 추가. + +Phase 1 은 **라우팅 no-op** 단계 — 컬럼만 추가, 실제 라우팅은 Phase 2에서. + +## 추가되는 컬럼 + +| 컬럼 | 타입 | 설명 | +|---|---|---| +| `DB_NAME` | VARCHAR(64) | 실제 DB 이름 (예: `qnc_vexplor`). Phase 3 이전엔 NULL 허용 | +| `SUBDOMAIN` | VARCHAR(64) | 접속 서브도메인 (예: `qnc`). UNIQUE | +| `DB_HOST` | VARCHAR(128) | DB 서버 호스트 (향후 분산 대비). 기본 NULL | +| `DB_STATUS` | VARCHAR(20) | `provisioning` / `schema_copied` / `admin_created` / `active` / `failed` / `suspended`. 기본 `active` | + +> 기존 `STATUS` 컬럼('active'/'inactive')과 혼동 주의. `DB_STATUS` 는 **DB 프로비저닝 상태**로 별개 역할. + +## SQL + +```sql +-- 079: COMPANY_MNG 멀티테넌시 메타 컬럼 추가 +ALTER TABLE COMPANY_MNG + ADD COLUMN IF NOT EXISTS DB_NAME VARCHAR(64), + ADD COLUMN IF NOT EXISTS SUBDOMAIN VARCHAR(64), + ADD COLUMN IF NOT EXISTS DB_HOST VARCHAR(128), + ADD COLUMN IF NOT EXISTS DB_STATUS VARCHAR(20) DEFAULT 'active'; + +-- 서브도메인 UNIQUE 인덱스 (NULL 다수 허용) +CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_SUBDOMAIN + ON COMPANY_MNG (SUBDOMAIN) + WHERE SUBDOMAIN IS NOT NULL; + +-- 기존 회사들은 DB_STATUS 기본값 'active' 로 채워짐 (DEFAULT 덕분). +-- 확인용 조회: +-- SELECT COMPANY_CODE, COMPANY_NAME, DB_NAME, SUBDOMAIN, DB_HOST, DB_STATUS FROM COMPANY_MNG; +``` + +## 실행 방법 + +### 방법 1: DBeaver / pgAdmin +1. `vexplor` DB 연결 +2. 위 SQL 블록 복사 & 실행 +3. 확인 쿼리 돌려서 컬럼 생성 여부 확인 + +### 방법 2: psql +```bash +psql -h 183.99.177.40 -U postgres -d vexplor <<'SQL' +ALTER TABLE COMPANY_MNG + ADD COLUMN IF NOT EXISTS DB_NAME VARCHAR(64), + ADD COLUMN IF NOT EXISTS SUBDOMAIN VARCHAR(64), + ADD COLUMN IF NOT EXISTS DB_HOST VARCHAR(128), + ADD COLUMN IF NOT EXISTS DB_STATUS VARCHAR(20) DEFAULT 'active'; + +CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_SUBDOMAIN + ON COMPANY_MNG (SUBDOMAIN) + WHERE SUBDOMAIN IS NOT NULL; +SQL +``` + +## 확인 쿼리 + +```sql +-- 1. 컬럼 생성 확인 +SELECT column_name, data_type, column_default, is_nullable +FROM information_schema.columns +WHERE table_name = 'company_mng' + AND column_name IN ('db_name', 'subdomain', 'db_host', 'db_status') +ORDER BY column_name; + +-- 2. 기존 회사 DB_STATUS 자동 채워짐 확인 +SELECT COMPANY_CODE, COMPANY_NAME, STATUS, DB_STATUS FROM COMPANY_MNG; + +-- 3. UNIQUE 인덱스 확인 +SELECT indexname, indexdef FROM pg_indexes +WHERE tablename = 'company_mng' AND indexname = 'ux_company_mng_subdomain'; +``` + +## 롤백 + +```sql +DROP INDEX IF EXISTS UX_COMPANY_MNG_SUBDOMAIN; +ALTER TABLE COMPANY_MNG + DROP COLUMN IF EXISTS DB_NAME, + DROP COLUMN IF EXISTS SUBDOMAIN, + DROP COLUMN IF EXISTS DB_HOST, + DROP COLUMN IF EXISTS DB_STATUS; +``` + +## 기존 API/화면 영향 + +- `admin.xml` 의 `insertCompany` / `updateCompany` 는 이 컬럼들을 참조 안 함. 신규 컬럼은 기본값/NULL 로 채워짐 → **회귀 없음**. +- 기존 회사관리 UI (`frontend/app/(main)/admin/userMng/companyList`) 변경 불필요. Phase 3 에서 마법사 UI 신설 시 함께 수정. + +## 체크리스트 + +- [ ] `vexplor` DB 에 위 SQL 실행 +- [ ] 확인 쿼리 1, 2, 3 모두 통과 +- [ ] `SELECT * FROM COMPANY_MNG LIMIT 1;` 로 기존 데이터 정상 조회 확인 diff --git a/db/migrations/RUN_080_MIGRATION.md b/db/migrations/RUN_080_MIGRATION.md new file mode 100644 index 00000000..8dc2b07a --- /dev/null +++ b/db/migrations/RUN_080_MIGRATION.md @@ -0,0 +1,52 @@ +# 080 마이그레이션 실행 가이드 — DB_NAME UNIQUE + race condition 방어 + +작성일: 2026-04-24 +작성자: gbpark +관련: Codex 리뷰 #4 — ProvisioningController 중복 체크 race condition 방어 + +## 목적 + +회사 프로비저닝 중복 체크와 INSERT 사이 **race condition** 을 DB 레벨에서 차단. + +- 현재 Controller: `exists → insert` 두 단계 사이 동시에 같은 subdomain 요청 들어오면 둘 다 통과 → DB 두 건 INSERT. +- 해결: `SUBDOMAIN`, `DB_NAME` 에 UNIQUE 제약. INSERT 실패 시 `DuplicateKeyException` → Controller 에서 409 응답. +- `SUBDOMAIN` UNIQUE 는 079 마이그레이션에 이미 포함됨. 이번엔 `DB_NAME` 만 추가. + +## SQL + +```sql +-- 080: DB_NAME UNIQUE 제약 +CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_DB_NAME + ON COMPANY_MNG (DB_NAME) + WHERE DB_NAME IS NOT NULL; +``` + +> `COMPANY_CODE` 는 이미 테이블 생성 시 PK 이므로 별도 제약 불필요. + +## 실행 방법 + +### DBeaver / pgAdmin +위 SQL 블록을 `vexplor` DB 에 실행. + +### psql +```bash +psql -h 183.99.177.40 -U postgres -d vexplor <<'SQL' +CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_DB_NAME + ON COMPANY_MNG (DB_NAME) + WHERE DB_NAME IS NOT NULL; +SQL +``` + +## 확인 쿼리 + +```sql +SELECT indexname, indexdef FROM pg_indexes + WHERE tablename = 'company_mng' + AND indexname IN ('ux_company_mng_subdomain', 'ux_company_mng_db_name'); +``` + +## 롤백 + +```sql +DROP INDEX IF EXISTS UX_COMPANY_MNG_DB_NAME; +``` diff --git a/db/migrations/RUN_081_MIGRATION.md b/db/migrations/RUN_081_MIGRATION.md new file mode 100644 index 00000000..5c2f2cea --- /dev/null +++ b/db/migrations/RUN_081_MIGRATION.md @@ -0,0 +1,75 @@ +# 081 마이그레이션 — 회사관리 UI 메타 컬럼 + +작성일: 2026-04-24 +작성자: gbpark +관련: Phase 3-B 회사관리 메인 화면(v9 목업) 렌더용 필드 확보 + +## 목적 + +목업 accordion row 에서 표시하는 필드 중 `COMPANY_MNG` 에 **상주해야 하는 정적 필드**만 컬럼으로 추가. +런타임 계산되는 필드(users / db_size / spark)는 별도 `CompanyStatsService` 가 집계. + +## 추가 컬럼 + +| 컬럼 | 타입 | 기본값 | 용도 | +|---|---|---|---| +| `PLAN` | VARCHAR(20) | `'Starter'` | 과금 플랜 (`Starter` / `Standard` / `Enterprise`). 목업의 PLAN badge | +| `INDUSTRY` | VARCHAR(50) | NULL | 업종 (예: `반도체`, `제조`). 목업 accordion 의 `industry` | +| `TEMPLATES_COUNT` | INT | `0` | 프로비저닝 시 복제한 그룹 수(필수 3 + 선택 n). 정적 집계용 | +| `DB_QUOTA_GB` | INT | `20` | 회사별 DB 할당량. `db_pct` 계산 기준값 | + +> 목업에 있었지만 **컬럼 안 만든 것** (기존 필드 재사용 또는 런타임): +> - `owner` → 기존 `REPRESENTATIVE_NAME` 사용 +> - `brn` → 기존 `BUSINESS_REGISTRATION_NUMBER` 사용 +> - `created` → 기존 `CREATED_DATE` 사용 +> - `writer` → 기존 `WRITER` 사용 +> - `users` / `active30` / `db_size` / `db_pct` / `spark` → 런타임 집계 (아래 문서 참조) + +## SQL + +```sql +-- 081: 회사관리 UI 메타 컬럼 추가 +ALTER TABLE COMPANY_MNG + ADD COLUMN IF NOT EXISTS PLAN VARCHAR(20) DEFAULT 'Starter', + ADD COLUMN IF NOT EXISTS INDUSTRY VARCHAR(50), + ADD COLUMN IF NOT EXISTS TEMPLATES_COUNT INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS DB_QUOTA_GB INT DEFAULT 20; +``` + +## 실행 + +```bash +psql -h 183.99.177.40 -U postgres -d vexplor <<'SQL' +ALTER TABLE COMPANY_MNG + ADD COLUMN IF NOT EXISTS PLAN VARCHAR(20) DEFAULT 'Starter', + ADD COLUMN IF NOT EXISTS INDUSTRY VARCHAR(50), + ADD COLUMN IF NOT EXISTS TEMPLATES_COUNT INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS DB_QUOTA_GB INT DEFAULT 20; +SQL +``` + +## 확인 쿼리 + +```sql +SELECT column_name, data_type, column_default +FROM information_schema.columns +WHERE table_name = 'company_mng' + AND column_name IN ('plan', 'industry', 'templates_count', 'db_quota_gb') +ORDER BY column_name; +``` + +## 롤백 + +```sql +ALTER TABLE COMPANY_MNG + DROP COLUMN IF EXISTS PLAN, + DROP COLUMN IF EXISTS INDUSTRY, + DROP COLUMN IF EXISTS TEMPLATES_COUNT, + DROP COLUMN IF EXISTS DB_QUOTA_GB; +``` + +## 기존 API 영향 + +- `AdminController.listCompanies` (기존 회사관리 CRUD) — 새 컬럼 무시. 회귀 없음. +- `ProvisioningController.create` → `insertCompanyWithTenant` 에 `templates_count` 파라미터 추가됨 (`CompanyProvisioningService.initiate()` 에서 계산). +- 신규: `GET /api/admin/provisioning/companies-stats` — 목업 메인 화면 렌더용. 전 필드 + derived 집계 반환. diff --git a/docker/dev/backend-spring.Dockerfile b/docker/dev/backend-spring.Dockerfile index c54d3962..65ead131 100644 --- a/docker/dev/backend-spring.Dockerfile +++ b/docker/dev/backend-spring.Dockerfile @@ -3,8 +3,10 @@ FROM eclipse-temurin:21-jdk-alpine WORKDIR /app -# curl 설치 (헬스체크용) -RUN apk add --no-cache curl +# curl 설치 (헬스체크용) + postgresql16-client (회사 DB 프로비저닝 pg_dump/psql 용). +# ★ 서버 PG 16.13 과 버전 맞춰야 함 — alpine 기본 postgresql-client 는 18 이라 pg_dump 18 이 +# "SET transaction_timeout" (17+ 신규) 을 dump 에 포함 → 서버가 거부. 버전 고정 필수. +RUN apk add --no-cache curl postgresql16-client # Gradle Wrapper 복사 및 의존성 캐싱 COPY gradlew ./ diff --git a/docker/dev/docker-compose.invyone.yml b/docker/dev/docker-compose.invyone.yml index 36b463ad..d76e6730 100644 --- a/docker/dev/docker-compose.invyone.yml +++ b/docker/dev/docker-compose.invyone.yml @@ -36,7 +36,7 @@ services: # JWT_SECRET 은 docker/dev/.env 에서 주입 (이 파일은 git 추적, .env 는 gitignored + syncthing 동기화) JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 환경변수 필요. docker/dev/.env 파일 확인} JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000} - CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:9772,http://100.126.230.80:9772} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:9772,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com} FILE_UPLOAD_DIR: ./uploads volumes: - ../../backend-spring:/app diff --git a/docs/MULTI_TENANCY_ARCHITECTURE.md b/docs/MULTI_TENANCY_ARCHITECTURE.md new file mode 100644 index 00000000..ee8eada7 --- /dev/null +++ b/docs/MULTI_TENANCY_ARCHITECTURE.md @@ -0,0 +1,290 @@ +# INVYONE 멀티테넌시 · 서브도메인 라우팅 아키텍처 + +> **DB-per-tenant + 서브도메인 기반 자동 라우팅.** +> 회사 하나 = 전용 PostgreSQL DB 하나 = 전용 서브도메인 하나. +> 회사 간 데이터 물리적 격리 + 코드 변경 없이 무제한 확장. + +--- + +## 1. 개요 + +INVYONE 은 **회사(=tenant)별로 독립된 PostgreSQL 데이터베이스**를 가진다. 접속한 서브도메인에 따라 요청이 자동으로 해당 회사 DB 로 라우팅된다. + +``` +qnc.invyone.com → qnc_vexplor DB +kookje.invyone.com → kookje_vexplor DB +vexplor (메타 DB) → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이터 +``` + +### 설계 원칙 + +- **데이터 격리**: 회사 간 테이블/쿼리 섞일 가능성 원천 차단 (다른 DB). +- **운영 격리**: 회사별 백업·복구 독립. 한 회사 DB 장애가 다른 회사에 영향 없음. +- **스키마 불변 전제**: 최고 관리자 기준 베이스 템플릿은 DDL 변경 없음. 각 회사 DB 내부에서 컬럼 추가해도 해당 회사 DB 안에만 영향. +- **제로 코드 확장**: 회사 추가 시 API 호출 한 번으로 전체 세팅 (DB 생성 + 스키마 복제 + 템플릿 데이터 + 관리자 계정). + +--- + +## 2. 핵심 구성 요소 + +### 2.1 백엔드 (Spring) + +| 파일 | 역할 | +|---|---| +| `com.erp.tenant.DbContextHolder` | 요청 스레드의 현재 tenant DB 이름 보관 (ThreadLocal) | +| `com.erp.tenant.SubdomainResolverFilter` | Host 헤더에서 서브도메인 파싱 → `DbContextHolder.set(dbName)` | +| `com.erp.tenant.CompanyResolver` | 서브도메인 → `COMPANY_MNG.DB_NAME` 조회 (ConcurrentHashMap 캐시) | +| `com.erp.tenant.TenantRoutingDataSource` | `AbstractRoutingDataSource` 확장. `DbContextHolder` 값으로 실제 DataSource 라우팅 | +| `com.erp.tenant.TenantDataSourceFactory` | Hikari 풀 빌더. META=`minIdle:2/max:10`, Tenant=**`minIdle:0`**/max:5 | +| `com.erp.tenant.DataSourceConfig` | `@Primary DataSource` 로 Spring Boot auto-config 덮음 | +| `com.erp.provisioning.*` | 회사 생성 오케스트레이션 (6단계) + REST API | + +### 2.2 데이터 흐름 + +``` +[브라우저: qnc.invyone.com] + ↓ (HTTP Host 헤더) +[SubdomainResolverFilter] + ↓ subdomain=qnc → CompanyResolver.resolveDbName("qnc") + ↓ DbContextHolder.set("qnc_vexplor") +[JwtAuthenticationFilter] + ↓ +[Controller → Service → sqlSession.selectList(...)] + ↓ +[TenantRoutingDataSource.determineTargetDataSource()] + ↓ DbContextHolder.get() = "qnc_vexplor" +[HikariDataSource for qnc_vexplor] → 실제 SQL 실행 +``` + +### 2.3 메타 DB 의 역할 + +`vexplor` DB (COMPANY_MNG 테이블) 는 **라우팅 룩업 테이블**만 담당: + +| 컬럼 | 용도 | +|---|---| +| `COMPANY_CODE` | 회사 식별자 (PK) | +| `SUBDOMAIN` | 서브도메인 prefix (UNIQUE) | +| `DB_NAME` | 실제 tenant DB 이름 (예: `qnc_vexplor`) (UNIQUE) | +| `DB_HOST` | tenant DB 호스트 (분산 대비, 현재 단일) | +| `DB_STATUS` | `provisioning` / `active` / `failed` / `suspended` | +| `PLAN`, `INDUSTRY`, `TEMPLATES_COUNT`, `DB_QUOTA_GB` | UI 메타 | + +--- + +## 3. 회사 생성 플로우 (프로비저닝) + +### 3.1 API + +| Method | URL | 설명 | +|---|---|---| +| GET | `/api/admin/provisioning/table-groups` | 복사 가능한 템플릿 그룹 (필수 3 + 선택 3) | +| GET | `/api/admin/provisioning/check` | subdomain / db_prefix / company_code 실시간 검증 | +| POST | `/api/admin/provisioning/companies` | 회사 생성 시작 (202 Accepted + provisioning_id) | +| GET | `/api/admin/provisioning/status/{id}` | 진행 상태 폴링 (2초 간격 권장) | +| GET | `/api/admin/provisioning/companies-stats` | 메인 화면용 회사 목록 + derived 집계 | + +### 3.2 6단계 상태 머신 + +``` +1. REGISTER_META COMPANY_MNG 에 status='provisioning' 선반영 +2. CREATE_DATABASE CREATE DATABASE "{prefix}_vexplor" (postgres 기본 DB 경유) +3. COPY_SCHEMA pg_dump --schema-only | psql (ProcessBuilder 배열 인자) +4. COPY_DATA 선택된 그룹 테이블 JDBC 복사 (company_code 필터) +5. CREATE_ADMIN {prefix}_admin BCrypt 계정 생성 +6. FINALIZE db_status='active' + CompanyResolver 캐시 무효화 +``` + +**실패 시 보상**: `DROP DATABASE IF EXISTS` (3회 백오프) + `db_status='failed'` 표시. +모든 단계 idempotent. UNIQUE 제약으로 race condition 방어. + +### 3.3 필수 필드 (강빈 스펙) + +최소 **4개**만 필수: + +- `company_code` — `^[A-Z][A-Z0-9_]{2,30}$` +- `company_name` +- `subdomain` — `^[a-z][a-z0-9-]{2,30}$` (예약어 `www|admin|api|app|static|assets|...` 금지) +- `db_prefix` — `^[a-z][a-z0-9_]{2,30}$` + +사업자번호·대표자·이메일 등 선택. 나중에 수정 가능. + +--- + +## 4. 환경별 도메인 전략 ★★★ + +서브도메인 기반 멀티테넌시는 **환경마다 DNS 해결 방식이 달라야** 한다. 이 절이 이 문서 전체에서 가장 중요. + +### 4.1 운영 (프로덕션) + +**와일드카드 DNS + Nginx 리버스 프록시.** 한 번 세팅하면 회사 무한히 추가해도 추가 작업 0. + +``` +[DNS Provider] + A *.invyone.com → 운영서버공인IP + +[Nginx conf] + server { + listen 443 ssl; + server_name *.invyone.com; + ssl_certificate .../fullchain.pem; # Let's Encrypt wildcard + location / { + proxy_pass http://frontend:3000; + proxy_set_header Host $host; # ★ Host 헤더 보존 필수 + } + location /api/ { + proxy_pass http://backend:8081; + proxy_set_header Host $host; # ★ + } + } +``` + +- **Let's Encrypt 와일드카드 인증서**: `certbot certonly --manual --preferred-challenges=dns -d invyone.com -d '*.invyone.com'` +- 이후 사용자 `http://qnc.invyone.com` 접속 → Nginx 가 Host 헤더 보존해 backend 에 프록시 → SubdomainResolverFilter → 자동 라우팅. + +### 4.2 로컬 개발 (본인 머신에서 docker up) + +**`*.localhost` 자동 매핑** 사용. RFC 6761 + 최신 브라우저가 자동 해석. + +``` +브라우저가 test07.localhost:9772 요청 + → Chrome/Firefox/Edge 가 자동으로 127.0.0.1:9772 해석 + → hosts 편집 0, 설정 0 +``` + +**조건**: 본인 PC 에서 `docker compose up` 으로 프론트/백엔드를 직접 띄워야 함. + +### 4.3 공유 개발 서버 (원격 서버 한 대로 여러 명이 접속) + +로컬 머신이 아닌 원격 IP(예: Tailscale `100.x.x.x`, 사무실 고정 IP 등) 에 도커가 떠 있을 때는 `*.localhost` 안 통함. + +선택지: + +- **옵션 A — `nip.io` / `sslip.io`** (설치 0) + ``` + test07.100-126-230-80.nip.io → 100.126.230.80 (자동) + ``` + CORS 패턴에 `http://*.<서버IP hyphens>.nip.io:[*]` 추가만 하면 됨. + +- **옵션 B — Windows hosts 수동 편집** (hosts 와일드카드 미지원이라 회사마다 한 줄) + ``` + <서버IP> test07.invyone.com + <서버IP> qnc.invyone.com + ``` + 회사 늘어날 때마다 한 줄씩. 테스트 3~4개 회사면 감당 가능. + +- **옵션 C — 개발 서버에 Nginx + DuckDNS/공개 와일드카드 도메인** (운영과 동일 경험) + +### 4.4 요약 표 + +| 환경 | 접속 방식 | 설정 필요도 | +|---|---|---| +| **운영** | 와일드카드 DNS + Nginx + Let's Encrypt wildcard TLS | 최초 1회, 이후 0 | +| **로컬 (직접 up)** | `*.localhost` 자동 | 0 | +| **원격 공유 개발 서버** | `nip.io` (추천) / hosts 편집 / 개발서버 Nginx | 경우 따라 | + +--- + +## 5. CORS 허용 패턴 + +모든 환경에서 브라우저 CORS 가 문제되지 않도록 `.env` 또는 `application.yml` 의 `CORS_ALLOWED_ORIGINS` 에 아래 패턴 **전부** 포함 권장: + +``` +http://localhost:3000, +http://localhost:9772, +http://localhost:9771, +http://*.localhost:[*], # 로컬 직접 up +http://*.invyone.com:[*], # 개발 테스트 (hosts 편집 or 실 와일드카드) +https://*.invyone.com:[*], +http://*.invyone.com, +https://*.invyone.com, +http://*.[개발서버IP].nip.io:[*] # nip.io 사용 시 환경에 맞게 치환 +``` + +> ★ `setAllowedOriginPatterns` 사용 (단순 `setAllowedOrigins` 아님). +> ★ YAML 에서 `[*]` 는 flow sequence 로 해석되므로 **반드시 따옴표로 감싸기**. + +```java +// SecurityConfig.java (발췌) +config.setAllowedOriginPatterns(patterns); // setAllowedOrigins 아님 +config.setAllowCredentials(true); +``` + +```yaml +# application.yml +cors: + allowed-origins: "${CORS_ALLOWED_ORIGINS:...}" # 반드시 quote +``` + +--- + +## 6. 프론트엔드 API URL 규칙 + +`frontend/lib/api/client.ts` 의 분기 **순서가 중요**: + +```typescript +// 1) 테넌트 서브도메인 → 직접 백엔드 포트 (Next rewrite 우회) +if (currentHost.endsWith(".invyone.com")) { + return `http://${currentHost}:8083/api`; +} +// 2) 프로덕션 메인 도메인 +if (currentHost === "v1.invion.com") return "https://api.invion.com/api"; +// 3) NEXT_PUBLIC_API_URL (docker-compose 주입) +// 4) localhost 기본값 +``` + +> ★ NEXT_PUBLIC_API_URL=/api 같은 rewrite 방식은 **Host 헤더를 변조**해 서브도메인 파싱이 실패한다. 테넌트 도메인은 반드시 직접 포트로. + +--- + +## 7. 운영 배포 체크리스트 + +Git push 한 번으로 자동 배포되는 구조라도, 아래 3가지는 **Git 바깥**이라 수동으로 확인해야 한다: + +- [ ] `.env` 의 `CORS_ALLOWED_ORIGINS` 에 **`*.invyone.com:[*]` 패턴 포함** +- [ ] DB 마이그레이션 `RUN_079`, `RUN_080`, `RUN_081` 운영 DB 에서 **1회 실행** +- [ ] Docker 이미지 재빌드 (Dockerfile 의 `postgresql16-client` 반영) — `docker compose build --no-cache backend-spring` +- [ ] `tenant.provisioning.require-super-admin=true` (프로덕션에선 필수, 개발은 false) +- [ ] (최초 1회) 와일드카드 DNS A 레코드 + Nginx wildcard server_name + Let's Encrypt wildcard 인증서 + +--- + +## 8. 관련 마이그레이션 + +| 마이그레이션 | 내용 | +|---|---| +| `db/migrations/RUN_079_MIGRATION.md` | `COMPANY_MNG` 에 `DB_NAME`, `SUBDOMAIN`, `DB_HOST`, `DB_STATUS` 컬럼 + SUBDOMAIN UNIQUE 인덱스 | +| `db/migrations/RUN_080_MIGRATION.md` | `DB_NAME` UNIQUE 인덱스 (race condition 방어) | +| `db/migrations/RUN_081_MIGRATION.md` | `PLAN`, `INDUSTRY`, `TEMPLATES_COUNT`, `DB_QUOTA_GB` (회사관리 UI 메타) | + +--- + +## 9. 설계/실행 노트 (원본) + +상세 설계 문서는 아래 참조: + +- `notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md` — 초기 설계 + Codex 리뷰 반영 + 실행 체크리스트 +- `notes/gbpark/2026-04-24-company-mgmt-ui-schema.md` — 회사관리 UI 필드 ↔ COMPANY_MNG 컬럼 ↔ derived 집계 매핑 + +--- + +## 10. 금지사항 (요약) + +- ❌ 테넌트 도메인에서 `NEXT_PUBLIC_API_URL=/api` rewrite 쓰기 (Host 헤더 소실) +- ❌ `setAllowedOrigins` 로만 CORS 설정 (와일드카드 매칭 안 됨 → `setAllowedOriginPatterns` 사용) +- ❌ `application.yml` 에 `[*]` 를 따옴표 없이 노출 (YAML sequence 로 해석됨) +- ❌ `pg_dump` 버전과 서버 버전 불일치 (`postgresql16-client` 등 서버 버전에 맞춰 고정) +- ❌ DDL 직접 문자열 concat (회사명·컬럼명은 반드시 화이트리스트 정규식 검증 후 큰따옴표 quote) +- ❌ 회사 DB 수만큼 Hikari `minIdle` 증가 (★ 회사 DB 풀은 반드시 `minIdle=0`) + +--- + +## 11. 무한 확장 체크 + +현재 아키텍처는 회사 N 개일 때: + +| N | 영향 | 조치 | +|---|---|---| +| ~20 | 없음 | 0 | +| ~100 | Postgres `max_connections` 기본값(100) 근접 | `max_connections=500` 증가 | +| ~500 | 단일 Postgres 인스턴스 부담 | `COMPANY_MNG.DB_HOST` 로 여러 서버 분산 (이미 컬럼 준비됨) | +| ∞ | 풀 메모리 누적 | tenant pool LRU eviction (Phase 6 에서 자동화) | diff --git a/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx b/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx new file mode 100644 index 00000000..766ee499 --- /dev/null +++ b/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx @@ -0,0 +1,436 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react"; +import { getCompaniesStats } from "@/lib/api/provisioning"; +import CompanyStatsStrip from "@/components/admin/provisioning/CompanyStatsStrip"; +import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordionRow"; +import Wizard from "@/components/admin/provisioning/wizard/Wizard"; + +const PAGE_SIZE = 10; + +/** + * SUPER_ADMIN — 회사 프로비저닝 / 서브도메인 관리. + * 경로: /admin/sysMng/subdomainList + * + * 기존 회사 관리(/admin/userMng/companyList) 는 일반 CRUD 그대로 유지. + * 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅" 전용. + */ +export default function SubdomainListPage() { + const [openKey, setOpenKey] = useState(null); + const [q, setQ] = useState(""); + const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all"); + const [planFilter, setPlanFilter] = useState("all"); + const [wizardOpen, setWizardOpen] = useState(false); + const [page, setPage] = useState(1); + + const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({ + queryKey: ["companies-stats"], + queryFn: getCompaniesStats, + refetchInterval: (query) => { + // provisioning 중인 회사 있으면 3초 폴링, 없으면 30초 + const hasProvisioning = Array.isArray(query.state.data) + ? query.state.data.some((r: any) => r.db_status === "provisioning") + : false; + return hasProvisioning ? 3_000 : 30_000; + }, + staleTime: 2_000, + }); + + const filtered = useMemo( + () => + rows + .filter((r) => filter === "all" || (r.db_status || r.status) === filter) + .filter((r) => planFilter === "all" || (r.plan || "Starter") === planFilter) + .filter((r) => { + if (!q) return true; + const needle = q.toLowerCase(); + return ( + (r.company_name || "").toLowerCase().includes(needle) || + (r.subdomain || "").toLowerCase().includes(needle) || + (r.company_code || "").toLowerCase().includes(needle) + ); + }), + [rows, filter, planFilter, q], + ); + + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const safePage = Math.min(page, totalPages); + const pageStart = (safePage - 1) * PAGE_SIZE; + const paged = filtered.slice(pageStart, pageStart + PAGE_SIZE); + + useEffect(() => { + setPage(1); + }, [q, filter, planFilter]); + + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); + + const activeCount = rows.filter((r) => r.db_status === "active").length; + const provisCount = rows.filter((r) => r.db_status === "provisioning").length; + const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length; + + return ( +
+ + + {/* 페이지 헤더 */} +
+
+
+ · 관리자 · 시스템관리 · 회사 프로비저닝 +
+
+ 회사 프로비저닝 +
+
+ Invy.one 플랫폼에 등록된 테넌트 회사 · 서브도메인 라우팅 +
+
+
+ refetch()} icon={}> + 새로고침 + + }>감사 로그 + }>CSV 내보내기 + } + onClick={() => setWizardOpen(true)} + > + 회사 생성 + +
+
+ + {/* stats strip */} + + + {/* toolbar */} +
+
+ setQ(e.target.value)} + placeholder="회사명 · subdomain · company_code 검색" + style={{ + width: "100%", + padding: "0.4rem 0.45rem 0.4rem 1.7rem", + background: "var(--v5-surface-hover)", + border: "1px solid var(--v5-border)", + borderRadius: 0, + color: "var(--v5-text)", + fontSize: "0.72rem", + fontFamily: "var(--v5-font-mono)", + outline: "none", + }} + /> + + + +
+ + + + + +
+ +
+ 활성 {activeCount} + · + 생성중 {provisCount} + · + 비활성 {inactCount} +
+
+ + {/* list header */} +
+ + 회사 / 서브도메인 + 사용자 + DB 용량 + 상태 + +
+ + {/* rows (scrollable when long) */} +
+ {isLoading && ( +
+ 로딩 중... +
+ )} + {!isLoading && + paged.map((r) => ( + setOpenKey(r.company_code === openKey ? null : r.company_code)} + /> + ))} + {!isLoading && filtered.length === 0 && ( +
+ 조건에 맞는 회사가 없습니다. +
+ )} +
+ + {/* footer + pagination */} +
+ + {filtered.length === 0 ? 0 : pageStart + 1}-{pageStart + paged.length} / {filtered.length} rows + {filtered.length !== rows.length && ( + · 전체 {rows.length} + )} + + + + + + last sync ·{" "} + {new Date(dataUpdatedAt).toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} + +
+ + {wizardOpen && setWizardOpen(false)} />} +
+ ); +} + +function HeaderBtn({ + children, + icon, + variant = "secondary", + onClick, +}: { + children: React.ReactNode; + icon?: React.ReactNode; + variant?: "primary" | "secondary"; + onClick?: () => void; +}) { + const isPrimary = variant === "primary"; + return ( + + ); +} + +function Pagination({ + page, + totalPages, + onChange, +}: { + page: number; + totalPages: number; + onChange: (p: number) => void; +}) { + if (totalPages <= 1) return ; + + const windowSize = 5; + let start = Math.max(1, page - Math.floor(windowSize / 2)); + let end = Math.min(totalPages, start + windowSize - 1); + start = Math.max(1, end - windowSize + 1); + const pages: number[] = []; + for (let i = start; i <= end; i++) pages.push(i); + + const btn = (key: string, label: React.ReactNode, p: number, disabled: boolean, active = false): React.ReactNode => ( + + ); + + return ( +
+ {btn("prev", , page - 1, page <= 1)} + {start > 1 && btn("p-1", 1, 1, false, page === 1)} + {start > 2 && } + {pages.map((p) => btn(`p-${p}`, p, p, false, p === page))} + {end < totalPages - 1 && } + {end < totalPages && btn(`p-${totalPages}`, totalPages, totalPages, false, page === totalPages)} + {btn("next", , page + 1, page >= totalPages)} +
+ ); +} + +const selectStyle: React.CSSProperties = { + padding: "0.4rem 0.5rem", + background: "var(--v5-surface-hover)", + border: "1px solid var(--v5-border)", + borderRadius: 0, + fontSize: "0.72rem", + color: "var(--v5-text)", + fontFamily: "inherit", + fontWeight: 500, +}; diff --git a/frontend/components/admin/provisioning/CompanyAccordionRow.tsx b/frontend/components/admin/provisioning/CompanyAccordionRow.tsx new file mode 100644 index 00000000..8396d9a5 --- /dev/null +++ b/frontend/components/admin/provisioning/CompanyAccordionRow.tsx @@ -0,0 +1,514 @@ +"use client"; + +import { useState } from "react"; +import { + ChevronRight, + ChevronDown, + MoreHorizontal, + Info, + Users, + Layers, + AlertTriangle, + ArrowUpRight, + KeyRound, + Copy, + PauseCircle, + Trash2, + UserPlus, + RefreshCw, +} from "lucide-react"; +import StatusDot from "./StatusDot"; + +/** + * 단일 회사 row. 클릭 시 accordion 펼쳐지며 탭 4개 (개요/구성원/템플릿/위험영역) 표시. + * 목업 v9 AccRow 포팅. API 데이터 스키마는 /companies-stats 응답. + */ +export default function CompanyAccordionRow({ + r, + open, + onToggle, +}: { + r: Record; + open: boolean; + onToggle: () => void; +}) { + const [tab, setTab] = useState<"overview" | "members" | "templates" | "danger">("overview"); + + const sub = r.subdomain || ""; + const name = r.company_name || r.name || r.company_code; + const plan = (r.plan || "Starter").toString(); + const dbName = r.db_name || `${sub}_vexplor`; + const dbPct = Number(r.db_pct) || 0; + const users = Number(r.users) || 0; + const active30 = Number(r.active30) || 0; + + return ( +
+ {open && ( +
+ )} + + + + {open && ( +
+ {/* tabs */} +
+ {([ + ["overview", "개요", Info], + ["members", "구성원", Users], + ["templates", "템플릿", Layers], + ["danger", "위험 영역", AlertTriangle], + ] as const).map(([k, l, IconC]) => ( + + ))} +
+
+ {r.created && <>생성 {formatDate(r.created)}} + {r.writer && <> · writer {r.writer}} +
+
+ + {tab === "overview" && ( +
+ {/* 기본정보 */} +
+
기본 정보
+
+ {( + [ + ["회사 코드", r.company_code, true], + ["회사명", name, false], + ["서브도메인", sub ? : "—", true], + ["DB명", dbName, true], + ["사업자번호", r.brn || "—", true], + ["플랜", plan, false], + ["업종", r.industry || "—", false], + ["대표자", r.owner || "—", false], + ] as const + ).map(([l, v, mono], i) => ( +
+ {l} + + {v as any} + +
+ ))} +
+
+ + {/* 운영지표 + 액션 */} +
+
운영 지표
+
+ {( + [ + ["총 사용자", users], + ["30일 활성", active30], + ["DB", r.db_size || "—"], + ["설치 템플릿", r.templates || 0], + ] as const + ).map(([l, v], i) => ( +
+
+ {l} +
+
+ {v} +
+
+ ))} +
+ +
+ } + onClick={(e) => { + e.stopPropagation(); + if (sub) window.open(`http://${sub}.invyone.com`, "_blank"); + }} + > + 테넌트 접속 + + }>관리자 계정 + }>템플릿 재복제 +
+
+
+ )} + + {tab === "members" && ( + + 구성원 목록은 회사별 tenant DB 에서 실시간 조회로 표시 예정 (Phase 4). 현재 + 총 {users}명. + + )} + + {tab === "templates" && ( + + 이 회사에 설치된 템플릿 그룹 {r.templates || 0}개. 상세 보기는 Phase 4 에서. + + )} + + {tab === "danger" && ( +
+ {[ + { + t: "회사 비활성화", + d: "사용자 로그인 차단 · 데이터 보존 · 언제든 재활성 가능", + b: "비활성화", + c: "var(--v5-amber)", + Icon: PauseCircle, + }, + { + t: "관리자 비밀번호 재설정", + d: "무작위 비밀번호 재설정 · 1회 표시", + b: "재설정", + c: "var(--v5-primary)", + Icon: KeyRound, + }, + { + t: "회사 영구 삭제", + d: "회사 + 테넌트 DB 영구 삭제 · 복구 불가", + b: "삭제 예약", + c: "var(--v5-red)", + Icon: Trash2, + }, + ].map((row, i) => { + const IconC = row.Icon; + return ( +
+
+ +
+
+
{row.t}
+
{row.d}
+
+ +
+ ); + })} +
+ )} +
+ )} +
+ ); +} + +const labelSm: React.CSSProperties = { + fontSize: "0.58rem", + color: "var(--v5-text-sec)", + letterSpacing: "0.06em", + textTransform: "uppercase", + fontWeight: 700, + marginBottom: 2, + fontFamily: "var(--v5-font-mono)", +}; + +const sectionTitle: React.CSSProperties = { + fontSize: "0.62rem", + color: "var(--v5-text-sec)", + letterSpacing: "0.06em", + textTransform: "uppercase", + fontWeight: 700, + marginBottom: "0.55rem", + fontFamily: "var(--v5-font-mono)", +}; + +function SubdomainLine({ sub }: { sub: string }) { + return ( + + {sub} + .invyone.com + + ); +} + +function ABtn({ + children, + icon, + onClick, +}: { + children: React.ReactNode; + icon?: React.ReactNode; + onClick?: (e: React.MouseEvent) => void; +}) { + return ( + + ); +} + +function EmptyNote({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function formatDate(v: any): string { + if (!v) return "—"; + try { + const d = typeof v === "number" ? new Date(v) : new Date(String(v)); + if (isNaN(d.getTime())) return String(v); + return d.toISOString().slice(0, 10); + } catch { + return String(v); + } +} diff --git a/frontend/components/admin/provisioning/CompanyStatsStrip.tsx b/frontend/components/admin/provisioning/CompanyStatsStrip.tsx new file mode 100644 index 00000000..1f93c807 --- /dev/null +++ b/frontend/components/admin/provisioning/CompanyStatsStrip.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; + +/** + * 상단 KPI strip — 4 카드 (전체 회사 / 활성률 / 총 사용자 / DB 사용량). + * 모든 카드가 같은 grid row 구조 공유해서 시각적 일관성 유지. + */ +export default function CompanyStatsStrip({ rows }: { rows: Record[] }) { + const total = rows.length; + const active = rows.filter((r) => r.db_status === "active").length; + const inact = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length; + const provis = rows.filter((r) => r.db_status === "provisioning").length; + + const users = rows.reduce((s, r) => s + (Number(r.users) || 0), 0); + const active30 = rows.reduce((s, r) => s + (Number(r.active30) || 0), 0); + const pctActive = total > 0 ? Math.round((active / total) * 100) : 0; + const pctEngaged = users > 0 ? Math.round((active30 / users) * 100) : 0; + + const dbBytes = rows.reduce((s, r) => s + (Number(r.db_size_bytes) || 0), 0); + const dbGB = dbBytes / (1024 * 1024 * 1024); + const dbQuotaGB = 20; // 기본 quota (추후 합계로 변경 가능) + const pctDB = Math.round((dbGB / dbQuotaGB) * 100); + + const cardStyle: React.CSSProperties = { + padding: "0.75rem 0.85rem", + display: "grid", + gridTemplateRows: "16px 32px 6px 16px", + rowGap: 8, + background: "var(--v5-surface-solid)", + border: "1px solid var(--v5-border)", + borderRadius: 10, + }; + const label: React.CSSProperties = { + fontSize: "0.68rem", + color: "var(--v5-text-sec)", + fontWeight: 700, + display: "flex", + alignItems: "center", + letterSpacing: "-0.005em", + }; + const bigRow: React.CSSProperties = { display: "flex", alignItems: "baseline", gap: 6 }; + const bigNum: React.CSSProperties = { + fontSize: "1.75rem", + fontWeight: 800, + color: "var(--v5-text)", + fontFamily: "var(--v5-font-mono)", + fontVariantNumeric: "tabular-nums", + letterSpacing: "-0.03em", + lineHeight: 1, + }; + const unit: React.CSSProperties = { fontSize: "0.68rem", color: "var(--v5-text-sec)", fontWeight: 500 }; + const bar: React.CSSProperties = { + height: 4, + borderRadius: 2, + overflow: "hidden", + background: "var(--v5-border)", + alignSelf: "center", + }; + const sub: React.CSSProperties = { + fontSize: "0.64rem", + color: "var(--v5-text-sec)", + fontWeight: 500, + display: "flex", + alignItems: "center", + gap: 10, + }; + + return ( +
+ {/* 1 · 전체 회사 */} +
+
전체 테넌트 회사
+
+ {total} + 개 회사 +
+
+
+
+
+
+
+ + + 활성 {active} + + + + 비활성 {inact} + +
+
+ + {/* 2 · 활성률 */} +
+
활성률
+
+ {pctActive} + % + + + 기준 30일 + +
+
+
+
+
+ 활성 {active} / 전체 {total} +
+
+ + {/* 3 · 총 사용자 */} +
+
총 사용자
+
+ {users} + + + 30일 {active30} + +
+
+
+
+
+ 참여율 {pctEngaged}% +
+
+ + {/* 4 · DB 사용량 */} +
+
총 DB 사용량
+
+ {dbGB.toFixed(1)} + GB + + / {dbQuotaGB} GB + +
+
+
70 ? "var(--v5-amber)" : "var(--v5-primary)", + }} + /> +
+
+ 사용률 {pctDB}% · {total}개 회사 합계 +
+
+
+ ); +} diff --git a/frontend/components/admin/provisioning/Sparkline.tsx b/frontend/components/admin/provisioning/Sparkline.tsx new file mode 100644 index 00000000..fe68aabc --- /dev/null +++ b/frontend/components/admin/provisioning/Sparkline.tsx @@ -0,0 +1,52 @@ +"use client"; + +/** + * 미니 스파크라인. 데이터 없으면 "—" 표시 (목업 로직 그대로). + */ +export default function Sparkline({ + data, + color = "var(--v5-primary)", + width = 96, + height = 18, +}: { + data?: number[]; + color?: string; + width?: number; + height?: number; +}) { + if (!data || data.length === 0) { + return ( + + — + + ); + } + const max = Math.max(...data, 1); + const min = Math.min(...data, 0); + const span = Math.max(max - min, 1); + const pts = data + .map((v, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - ((v - min) / span) * (height - 3) - 1.5; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + return ( + + + + ); +} diff --git a/frontend/components/admin/provisioning/StatusDot.tsx b/frontend/components/admin/provisioning/StatusDot.tsx new file mode 100644 index 00000000..b867198e --- /dev/null +++ b/frontend/components/admin/provisioning/StatusDot.tsx @@ -0,0 +1,42 @@ +"use client"; + +/** + * 회사 상태 표시용 작은 컬러 dot + 라벨. + * provisioning 상태일 때만 pulse 애니메이션. + */ +const MAP: Record = { + active: { color: "rgb(var(--v5-green-rgb))", label: "활성" }, + provisioning: { color: "var(--v5-primary)", label: "생성중" }, + failed: { color: "var(--v5-red)", label: "실패" }, + suspended: { color: "var(--v5-text-muted)", label: "정지" }, + inactive: { color: "var(--v5-text-muted)", label: "비활성" }, +}; + +export default function StatusDot({ status }: { status?: string }) { + const m = MAP[status || ""] || { color: "var(--v5-text-muted)", label: status || "—" }; + const isProvisioning = status === "provisioning"; + return ( + + + {m.label} + + ); +} diff --git a/frontend/components/admin/provisioning/wizard/Step1Basic.tsx b/frontend/components/admin/provisioning/wizard/Step1Basic.tsx new file mode 100644 index 00000000..7f66a180 --- /dev/null +++ b/frontend/components/admin/provisioning/wizard/Step1Basic.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Link as LinkIcon, Briefcase, AlertTriangle, Database } from "lucide-react"; +import { checkAvailability } from "@/lib/api/provisioning"; +import { + Field, + TextInput, + CheckAvailBadge, + AvailStatus, + availToInputStatus, +} from "./fields"; + +/** + * Step 1 · 회사 기본 정보 (시안 v2 포팅). + * 좌측: 식별자 섹션 + 법인정보 섹션 (둘 다 기본 노출) + * 우측: LIVE PREVIEW 카드 (접속 URL / DB 이름 / 회사 코드 / 주의) + * + * 로직 (유지): + * - subdomain 입력 시 db_prefix / company_code 자동 제안 (사용자 수정 전까지) + * - debounce 350ms 로 백엔드 /check 호출 + * - valid: 3개 식별자 available + company_name 존재 + */ +export default function Step1Basic({ + state, + setState, + onValidChange, +}: { + state: Record; + setState: (patch: Record) => void; + onValidChange: (valid: boolean) => void; +}) { + const [subStatus, setSubStatus] = useState("idle"); + const [prefStatus, setPrefStatus] = useState("idle"); + const [codeStatus, setCodeStatus] = useState("idle"); + const timerRef = useRef(null); + + function onSubChange(v: string) { + const sub = v.toLowerCase().replace(/[^a-z0-9-]/g, ""); + const patch: Record = { subdomain: sub }; + if (!state.db_prefix_manual) { + patch.db_prefix = sub.replace(/-/g, "_"); + } + if (!state.company_code_manual) { + patch.company_code = sub.toUpperCase().replace(/-/g, "_"); + } + setState(patch); + } + function onDbPrefixChange(v: string) { + setState({ db_prefix: v.toLowerCase().replace(/[^a-z0-9_]/g, ""), db_prefix_manual: true }); + } + function onCompanyCodeChange(v: string) { + setState({ company_code: v.toUpperCase().replace(/[^A-Z0-9_]/g, ""), company_code_manual: true }); + } + + useEffect(() => { + if (timerRef.current) clearTimeout(timerRef.current); + setSubStatus(state.subdomain ? "checking" : "idle"); + setPrefStatus(state.db_prefix ? "checking" : "idle"); + setCodeStatus(state.company_code ? "checking" : "idle"); + + const payload = { + subdomain: state.subdomain, + dbPrefix: state.db_prefix, + companyCode: state.company_code, + }; + if (!payload.subdomain && !payload.dbPrefix && !payload.companyCode) return; + + timerRef.current = setTimeout(async () => { + try { + const r: any = await checkAvailability(payload); + if (payload.subdomain === state.subdomain) { + const sub = r?.subdomain; + setSubStatus( + !sub ? "idle" : !sub.valid_format || sub.reserved ? "invalid" : sub.available ? "available" : "taken", + ); + } + if (payload.dbPrefix === state.db_prefix) { + const pref = r?.db_prefix; + setPrefStatus(!pref ? "idle" : !pref.valid_format ? "invalid" : pref.available ? "available" : "taken"); + } + if (payload.companyCode === state.company_code) { + const cc = r?.company_code; + setCodeStatus(!cc ? "idle" : !cc.valid_format ? "invalid" : cc.available ? "available" : "taken"); + } + } catch { + setSubStatus("idle"); + setPrefStatus("idle"); + setCodeStatus("idle"); + } + }, 350); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [state.subdomain, state.db_prefix, state.company_code]); + + useEffect(() => { + const valid = + subStatus === "available" && + prefStatus === "available" && + codeStatus === "available" && + !!state.company_name; + onValidChange(valid); + }, [subStatus, prefStatus, codeStatus, state.company_name, onValidChange]); + + return ( +
+ {/* 왼쪽: 폼 */} +
+
+ 01 · BASIC +
+

+ 회사 기본 정보 +

+
+ subdomain 을 입력하면 접속 URL 과 DB 이름이 자동 결정됩니다. 생성 후에는 변경 불가합니다. +
+ + {/* ── 식별자 ── */} +
+ } + label="식별자" + hint="생성 후 변경 불가" + /> +
+ } + > + + + } + > + + + } + > + + + + setState({ company_name: v })} + placeholder="큐엔씨 주식회사" + /> + +
+
+ + {/* ── 법인 정보 ── */} +
+ } label="법인 정보" /> +
+ + setState({ business_registration_number: v })} + placeholder="123-45-67890" + mono + /> + + + setState({ representative_name: v })} + placeholder="홍길동" + /> + + + setState({ representative_phone: v })} + placeholder="02-1234-5678" + mono + /> + + + setState({ email: v })} + placeholder="admin@company.kr" + mono + /> + + + setState({ address: v })} + placeholder="서울특별시 강남구 테헤란로 123" + /> + + + setState({ website: v })} + placeholder="https://company.kr" + mono + /> + +
+
+
+ + {/* 오른쪽: LIVE PREVIEW */} +
+
+
+ LIVE PREVIEW +
+ + + https:// + + {state.subdomain || "___"} + + .invyone.com + + + + + + + + {state.db_prefix || "___"} + + _vexplor + + + + {state.company_code ? ( + {state.company_code} + ) : ( + ___ + )} + + +
+ +
+ 주의 — subdomain 과 db_prefix 는 생성 후 변경할 수 없습니다. +
+
+
+
+
+ ); +} + +function SectionHead({ + icon, + label, + hint, +}: { + icon: React.ReactNode; + label: string; + hint?: string; +}) { + return ( +
+ {icon} + {label} + {hint && ( + + — {hint} + + )} +
+ ); +} + +function PreviewField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+
+ {children} +
+
+ ); +} diff --git a/frontend/components/admin/provisioning/wizard/Step2Template.tsx b/frontend/components/admin/provisioning/wizard/Step2Template.tsx new file mode 100644 index 00000000..bceac97e --- /dev/null +++ b/frontend/components/admin/provisioning/wizard/Step2Template.tsx @@ -0,0 +1,420 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Check, + Lock, + ChevronDown, + ChevronUp, + Monitor, + Sliders, + CalendarClock, + Workflow, + Shield, + Database, +} from "lucide-react"; +import { getTableGroups } from "@/lib/api/provisioning"; + +/** + * Step 2 · 템플릿 그룹 선택 (시안 v2 포팅). + * 왼쪽: 카드 리스트 (필수 / 선택 subheader 로 분리, 각 카드에 아이콘·체크·확장) + * 오른쪽: SUMMARY 패널 (선택된 그룹 · 복제될 테이블 · 예상 레코드 · 예상 소요 시간) + * + * 로직 (유지): + * - /table-groups 로드 + * - g.required 는 서버가 강제하므로 UI 도 disabled + 항상 체크 + * - selected_groups 에는 선택 그룹(required=false) 만 저장 + * - valid = true 항상 + */ +export default function Step2Template({ + state, + setState, + onValidChange, +}: { + state: Record; + setState: (patch: Record) => void; + onValidChange: (valid: boolean) => void; +}) { + const { data: groups = [], isLoading } = useQuery({ + queryKey: ["provisioning-table-groups"], + queryFn: getTableGroups, + staleTime: 60_000, + }); + const [expanded, setExpanded] = useState(null); + const selected: string[] = state.selected_groups || []; + + useEffect(() => { + if (!state.selected_groups) setState({ selected_groups: [] }); + }, []); // eslint-disable-line + + useEffect(() => { + onValidChange(true); + }, [onValidChange]); + + function toggle(id: string, required: boolean) { + if (required) return; + const next = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]; + setState({ selected_groups: next }); + } + + const required = groups.filter((g: any) => g.required); + const optional = groups.filter((g: any) => !g.required); + const selectedGroups = groups.filter((g: any) => g.required || selected.includes(g.id)); + const totalTables = selectedGroups.reduce( + (a: number, g: any) => a + (Array.isArray(g.tables) ? g.tables.length : 0), + 0, + ); + const totalRows = selectedGroups.reduce((a: number, g: any) => a + (Number(g.count) || 0), 0); + const estimatedSec = 10 + selectedGroups.length * 3; + + return ( +
+
+
+ 02 · TEMPLATE +
+

+ 복사할 기준 데이터 +

+
+ 기본 회사(INVION_DEFAULT) 의 기준 데이터 중 새 회사에 복제할 항목을 선택합니다. + 필수 그룹은 해제할 수 없습니다. +
+ + {isLoading && ( +
로딩 중...
+ )} + +
+ {required.length > 0 && ( + <> + 필수 (해제 불가) + {required.map((g: any) => ( + {}} + expanded={expanded === g.id} + onExpand={() => setExpanded(expanded === g.id ? null : g.id)} + /> + ))} + + )} + {optional.length > 0 && ( + <> + 선택 + {optional.map((g: any) => ( + toggle(g.id, false)} + expanded={expanded === g.id} + onExpand={() => setExpanded(expanded === g.id ? null : g.id)} + /> + ))} + + )} +
+
+ + {/* 요약 패널 */} +
+
+
+ SUMMARY +
+ + + + +
+
+
+ ); +} + +/* ─── 카드 ─────────────────────────────────────────────── */ + +const ICON_MAP: Record> = { + screen: Monitor, + control: Sliders, + batch: CalendarClock, + dataflow: Workflow, + authmenu: Shield, + menu: Shield, + auth: Shield, +}; + +function TemplateCard({ + g, + checked, + onToggle, + expanded, + onExpand, +}: { + g: Record; + checked: boolean; + onToggle: () => void; + expanded: boolean; + onExpand: () => void; +}) { + const locked = !!g.required; + const Ic = ICON_MAP[g.id] || ICON_MAP[g.key] || Database; + const tables: string[] = Array.isArray(g.tables) ? g.tables : []; + const meta: string[] = []; + meta.push(`${tables.length}개 테이블`); + if (g.count != null) meta.push(`${Number(g.count).toLocaleString()}건`); + if (g.size) meta.push(String(g.size)); + + return ( +
+
!locked && onToggle()} + style={{ + padding: "0.85rem 1rem", + display: "flex", + alignItems: "center", + gap: "0.7rem", + cursor: locked ? "not-allowed" : "pointer", + }} + > + {/* checkbox */} +
+ {checked && } +
+ + {/* icon tile */} +
+ +
+ + {/* label */} +
+
+ + {g.label || g.id} + + {locked && ( + + 필수 + + )} +
+
+ {meta.join(" · ")} +
+
+ + {tables.length > 0 && ( + + )} +
+ + {expanded && tables.length > 0 && ( +
+ {tables.map((t) => ( + + {t} + + ))} +
+ )} +
+ ); +} + +function SubHead({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) { + return ( +
+ {children} +
+ ); +} + +function SummaryRow({ + label, + value, + mono, + accent, + isLast, +}: { + label: string; + value: React.ReactNode; + mono?: boolean; + accent?: "cyan"; + isLast: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/frontend/components/admin/provisioning/wizard/Step3Admin.tsx b/frontend/components/admin/provisioning/wizard/Step3Admin.tsx new file mode 100644 index 00000000..b9114b73 --- /dev/null +++ b/frontend/components/admin/provisioning/wizard/Step3Admin.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Eye, + EyeOff, + RefreshCw, + Copy, + Check, + AlertTriangle, + Lock, + Shield, + KeyRound, +} from "lucide-react"; +import { genPassword } from "./fields"; + +/** + * Step 3 · 초기 관리자 계정 (시안 v2 포팅). + * 단일 테두리 카드 안에 4개 row: USER_ID / INITIAL_PASSWORD / USER_TYPE / FORCE_PW_CHANGE + * + * 로직 (유지): + * - user_id = {db_prefix}_admin (자동) + * - initial_password 자동 생성, 재생성, 복사, 보기 토글 + * - force_password_change 기본 ON + * - valid = password 길이 8+ + */ +export default function Step3Admin({ + state, + setState, + onValidChange, +}: { + state: Record; + setState: (patch: Record) => void; + onValidChange: (valid: boolean) => void; +}) { + const [visible, setVisible] = useState(true); + const [copied, setCopied] = useState(false); + + const userId = state.db_prefix ? `${state.db_prefix}_admin` : "—"; + + useEffect(() => { + if (!state.initial_password) { + setState({ initial_password: genPassword(12) }); + } + if (state.force_password_change === undefined) { + setState({ force_password_change: true }); + } + }, []); // eslint-disable-line + + useEffect(() => { + onValidChange(!!state.initial_password && state.initial_password.length >= 8); + }, [state.initial_password, onValidChange]); + + const pw: string = state.initial_password || ""; + + function regen() { + setState({ initial_password: genPassword(12) }); + setCopied(false); + } + async function copy() { + try { + await navigator.clipboard.writeText(pw); + setCopied(true); + setTimeout(() => setCopied(false), 1400); + } catch { + /* ignore */ + } + } + + const passwordActions = ( +
+ setVisible((v) => !v)}> + {visible ? : } + + + + + +
+ ); + + return ( +
+
+ 03 · ADMIN ACCOUNT +
+

+ 초기 관리자 계정 +

+
+ 시스템이 자동으로{" "} + COMPANY_ADMIN 권한의 관리자 계정을 + 생성합니다. 생성된 비밀번호는{" "} + 이 화면을 벗어나면 다시 볼 수 없으며, DB 에는 BCrypt 해시만 저장됩니다. +
+ +
+ } + > + {userId} + + + +
+ + {visible ? pw : "•".repeat(pw.length || 12)} +
+
+ + + + + COMPANY_ADMIN + + + + + + +
+ + {/* 경고 배너 */} +
+ +
+ 이 비밀번호는 다시 볼 수 없습니다. + + {" "} + 생성 후 DB 에는 BCrypt 해시만 저장됩니다. 지금 복사하여 안전한 채널 (1Password, Keeper 등) 로 회사에 전달하세요. + +
+
+
+ ); +} + +function Row({ + label, + sub, + children, + trailing, + last, +}: { + label: string; + sub?: string; + children: React.ReactNode; + trailing?: React.ReactNode; + last?: boolean; +}) { + return ( +
+
+
+ {label} +
+ {sub &&
{sub}
} +
+
{children}
+
{trailing}
+
+ ); +} + +function IconBtn({ + onClick, + children, + title, +}: { + onClick: () => void; + children: React.ReactNode; + title: string; +}) { + return ( + + ); +} + +function ReadBox({ children, mono }: { children: React.ReactNode; mono?: boolean }) { + return ( +
+ {children} +
+ ); +} + +function Switch({ on, onChange }: { on: boolean; onChange: () => void }) { + return ( +
+
+
+ ); +} diff --git a/frontend/components/admin/provisioning/wizard/Step4Run.tsx b/frontend/components/admin/provisioning/wizard/Step4Run.tsx new file mode 100644 index 00000000..fcece142 --- /dev/null +++ b/frontend/components/admin/provisioning/wizard/Step4Run.tsx @@ -0,0 +1,666 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Check, X, AlertOctagon } from "lucide-react"; +import { createCompany, getProvisioningStatus, CreateCompanyRequest } from "@/lib/api/provisioning"; + +/** + * Step 4 · 생성 진행 (시안 v2 포팅). + * 레이아웃: 좌측 (제목 + 큰 진행바 + 단계 리스트 + 결과 배너) + 우측 (다크 로그 콘솔) + * + * 로직 (유지): + * - 마운트 즉시 POST /companies → jobId 취득 → 폴링 시작 + * - 폴링 결과 status.currentStep 변화에 맞춰 단계 state 갱신 + * - onDone 으로 최종 결과 상위에 통보 + */ + +const DISPLAY_STEPS: { key: string; label: string; sub: string; sec: number }[] = [ + { key: "CREATE_DATABASE", label: "DB 생성", sub: "CREATE DATABASE '%db%'", sec: 2 }, + { key: "COPY_SCHEMA", label: "스키마 복제", sub: "pg_dump --schema-only | psql", sec: 5 }, + { key: "COPY_DATA", label: "템플릿 데이터 복사", sub: "선택된 그룹의 공통 데이터", sec: 12 }, + { key: "CREATE_ADMIN", label: "관리자 계정 생성", sub: "BCrypt · user_info", sec: 3 }, + { key: "FINALIZE", label: "마무리", sub: "db_status=active · 캐시 무효화", sec: 2 }, +]; + +type RowStatus = "pending" | "running" | "done" | "failed"; + +export default function Step4Run({ + state, + onDone, +}: { + state: Record; + onDone: (result: { success: boolean; subdomain?: string; companyCode?: string; error?: string }) => void; +}) { + const [jobId, setJobId] = useState(null); + const [status, setStatus] = useState>({ + status: "in_progress", + currentStep: "", + progress: 0, + }); + const [createError, setCreateError] = useState(null); + const [log, setLog] = useState([]); + const [startedAt] = useState(() => Date.now()); + const pollRef = useRef(null); + const startedRef = useRef(false); + const prevStepRef = useRef(""); + + // 로그 append 헬퍼 + function appendLog(line: string) { + const hhmmss = new Date().toTimeString().slice(0, 8); + setLog((lg) => [...lg, `[${hhmmss}] ${line}`]); + } + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + appendLog(`▸ START provisioning · ${state.company_code || "—"}`); + appendLog(`· subdomain=${state.subdomain}`); + appendLog(`· db_prefix=${state.db_prefix}`); + (async () => { + try { + const payload: CreateCompanyRequest = { + company_code: state.company_code, + company_name: state.company_name, + subdomain: state.subdomain, + db_prefix: state.db_prefix, + business_registration_number: state.business_registration_number, + representative_name: state.representative_name, + representative_phone: state.representative_phone, + email: state.email, + website: state.website, + address: state.address, + selected_groups: state.selected_groups || [], + initial_password: state.initial_password, + }; + const resp = await createCompany(payload); + setJobId(resp.provisioning_id); + appendLog(`✓ accepted · job_id=${resp.provisioning_id}`); + } catch (e: any) { + const msg = e?.response?.data?.message || e?.response?.data?.error || e?.message || "요청 실패"; + setCreateError(msg); + appendLog(`✗ FAILED · ${msg}`); + onDone({ success: false, error: msg }); + } + })(); + return () => { + if (pollRef.current) clearTimeout(pollRef.current); + }; + // eslint-disable-next-line + }, []); + + useEffect(() => { + if (!jobId) return; + let cancelled = false; + + async function tick() { + try { + const s = await getProvisioningStatus(jobId!); + if (cancelled) return; + setStatus(s); + + // step 변화 감지 → 로그 + if (s.currentStep && s.currentStep !== prevStepRef.current) { + const disp = DISPLAY_STEPS.find((d) => d.key === s.currentStep); + appendLog(`▸ ${s.currentStep}${disp ? ` · ${disp.label}` : ""}`); + prevStepRef.current = s.currentStep; + } + + if (s.status === "completed") { + appendLog(`━ READY · https://${s.subdomain || state.subdomain}.invyone.com`); + onDone({ success: true, subdomain: s.subdomain || state.subdomain, companyCode: s.companyCode }); + return; + } + if (s.status === "failed") { + appendLog(`✗ FAILED · ${s.failedStep || "—"} · ${s.errorMessage || "—"}`); + onDone({ success: false, error: s.errorMessage || "프로비저닝 실패" }); + return; + } + } catch { + // 네트워크 일시 장애 → 다음 틱에서 재시도 + } + pollRef.current = setTimeout(tick, 2000); + } + tick(); + return () => { + cancelled = true; + if (pollRef.current) clearTimeout(pollRef.current); + }; + // eslint-disable-next-line + }, [jobId]); + + const current = status?.currentStep as string | undefined; + const failed = status?.status === "failed"; + const done = status?.status === "completed"; + + function rowStatus(key: string): RowStatus { + if (!current && !done && !failed) return "pending"; + if (done) return "done"; + const idx = DISPLAY_STEPS.findIndex((s) => s.key === key); + const curIdx = DISPLAY_STEPS.findIndex((s) => s.key === current); + if (failed && status.failedStep === key) return "failed"; + if (idx < curIdx) return "done"; + if (idx === curIdx) return failed ? "failed" : "running"; + return "pending"; + } + + const progress: number = useMemo(() => { + if (done) return 100; + if (typeof status?.progress === "number") return Math.max(0, Math.min(100, Math.round(status.progress))); + if (!current) return 0; + const curIdx = DISPLAY_STEPS.findIndex((s) => s.key === current); + if (curIdx < 0) return 0; + return Math.round(((curIdx + 0.5) / DISPLAY_STEPS.length) * 100); + }, [done, current, status?.progress]); + + const elapsedSec = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); + + const variant: "running" | "success" | "failed" = failed ? "failed" : done ? "success" : "running"; + const barLabel = variant === "success" ? "COMPLETED" : variant === "failed" ? "HALTED" : "IN PROGRESS"; + const accent = + variant === "success" ? "rgb(var(--v5-green-rgb))" : variant === "failed" ? "var(--v5-red)" : "var(--v5-cyan)"; + + return ( +
+
+
+ 04 · PROVISIONING +
+

+ {variant === "success" ? "회사 생성 완료" : variant === "failed" ? "생성 실패" : "회사 생성 중"} +

+
+ {variant === "success" && ( + <> + + {state.subdomain}.invyone.com + {" "} + 에서 접속 가능합니다. 회사에 URL 과 초기 비밀번호를 전달하세요. + + )} + {variant === "failed" && <>오류가 발생했습니다. 로그를 확인 후 롤백/재시도를 진행하세요.} + {variant === "running" && <>브라우저를 닫아도 서버에서 계속 진행됩니다.} +
+ {createError && ( +
+ {createError} +
+ )} + + {/* 큰 진행 바 */} +
+
+
+ {barLabel} +
+
+ {progress} + % +
+
+
+
+ {variant === "running" && ( +
+ )} +
+
+ PROV_ID · {jobId || "—"} + + 경과 {elapsedSec}s + {variant === "running" && " · 진행 중"} + {variant === "success" && " · 완료"} + {variant === "failed" && " · 실패"} + +
+
+ + {/* 단계 리스트 */} +
+ {DISPLAY_STEPS.map((s, i) => ( + + ))} +
+ + {/* 실패 에러 메시지 */} + {failed && ( +
+
+ ERROR · {status?.failedStep || "—"} +
+
+ {status?.errorMessage || createError || "—"} +
+
+ )} + + {/* 성공 접속 카드 */} + {done && ( +
+
+ +
+
+
+ READY +
+
+ + {state.subdomain}.invyone.com + + 접속 가능 +
+
+ 로그인: {state.db_prefix}_admin / [Step 3 에서 복사한 비밀번호] +
+
+
+ )} +
+ + {/* 우측: 로그 콘솔 */} +
+
+
+ + PROVISIONING LOG +
+
+ {log.length === 0 ? (로그 없음) : log.join("\n")} +
+
+
+
+ ); +} + +function RunRow({ + step, + idx, + status, + isLast, + dbName, +}: { + step: { key: string; label: string; sub: string; sec: number }; + idx: number; + status: RowStatus; + isLast: boolean; + dbName: string; +}) { + const isDone = status === "done"; + const isActive = status === "running"; + const isFailed = status === "failed"; + const isPending = status === "pending"; + const barColor = isFailed + ? "var(--v5-red)" + : isDone + ? "rgb(var(--v5-green-rgb))" + : isActive + ? "var(--v5-cyan)" + : "var(--v5-border)"; + + return ( +
+ {/* circle + connecting line */} +
+ {!isLast && ( +
+ )} +
+ {isDone && } + {isFailed && } + {isActive && ( + + )} + {isPending && ( + + {idx + 1} + + )} +
+
+ +
+
+ {step.label} +
+
+ {step.sub.replace("%db%", dbName)} +
+
+ +
+ {isDone && "완료"} + {isActive && "진행 중…"} + {isPending && "대기"} + {isFailed && "실패"} +
+
+ ); +} diff --git a/frontend/components/admin/provisioning/wizard/StepIndicator.tsx b/frontend/components/admin/provisioning/wizard/StepIndicator.tsx new file mode 100644 index 00000000..1ab6fa82 --- /dev/null +++ b/frontend/components/admin/provisioning/wizard/StepIndicator.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Building2, Layers, UserCog, Rocket, Check } from "lucide-react"; + +export const WIZARD_STEPS = [ + { n: 1, key: "basic", label: "기본 정보", icon: Building2, desc: "회사 · 접속 주소 · DB" }, + { n: 2, key: "template", label: "템플릿 선택", icon: Layers, desc: "복사할 기준 데이터" }, + { n: 3, key: "admin", label: "관리자 계정", icon: UserCog, desc: "초기 관리자 생성" }, + { n: 4, key: "run", label: "생성 진행", icon: Rocket, desc: "DB · 스키마 · 복사" }, +] as const; + +export default function StepIndicator({ current }: { current: number }) { + return ( +
+ {WIZARD_STEPS.map((s, i) => { + const done = current > s.n; + const active = current === s.n; + return ( +
+
+ {done ? : s.n} +
+
+
+ STEP {s.n} / {WIZARD_STEPS.length} +
+
+ {s.label} +
+
+ {active && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/components/admin/provisioning/wizard/Wizard.tsx b/frontend/components/admin/provisioning/wizard/Wizard.tsx new file mode 100644 index 00000000..423d7da8 --- /dev/null +++ b/frontend/components/admin/provisioning/wizard/Wizard.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + X, + ArrowLeft, + ArrowRight, + Rocket, + Building2, + AlertTriangle, + FileText, + ArrowUpRight, + RefreshCw, + Trash2, + Info, + XOctagon, +} from "lucide-react"; +import StepIndicator from "./StepIndicator"; +import Step1Basic from "./Step1Basic"; +import Step2Template from "./Step2Template"; +import Step3Admin from "./Step3Admin"; +import Step4Run from "./Step4Run"; + +type RunDone = { + success: boolean; + subdomain?: string; + companyCode?: string; + error?: string; +} | null; + +/** + * 회사 생성 마법사 메인 (시안 v2 포팅). + * 모달로 열림. ESC · 배경 클릭으로 닫기 (진행 중 생성은 경고). + * Step 4 진입하면 Step4Run 이 POST /companies 를 즉시 호출 → 폴링. + * 성공/실패 후 메인 목록 invalidate. + */ +export default function Wizard({ onClose }: { onClose: () => void }) { + const qc = useQueryClient(); + const [step, setStep] = useState(1); + const [state, setStateRaw] = useState>({ selected_groups: [] }); + const [validByStep, setValidByStep] = useState>({ + 1: false, + 2: true, + 3: false, + 4: false, + }); + const [runDone, setRunDone] = useState(null); + + function setState(patch: Record) { + setStateRaw((s) => ({ ...s, ...patch })); + } + function mark(n: number, v: boolean) { + setValidByStep((x) => (x[n] === v ? x : { ...x, [n]: v })); + } + + const canNext = step < 4 && validByStep[step]; + + function next() { + if (step < 4 && canNext) setStep((s) => s + 1); + } + function prev() { + if (step > 1 && step < 4) setStep((s) => s - 1); + } + + function tryClose() { + if (step === 4 && !runDone) { + if ( + !window.confirm( + "생성 작업이 진행 중입니다. 창을 닫아도 서버에서는 계속 진행되지만, 이 화면은 다시 볼 수 없습니다. 닫을까요?", + ) + ) + return; + } + if (runDone?.success) qc.invalidateQueries({ queryKey: ["companies-stats"] }); + onClose(); + } + + return ( +
{ + if (e.target === e.currentTarget) tryClose(); + }} + style={{ + position: "fixed", + inset: 0, + zIndex: 100, + background: "rgba(6, 5, 14, 0.5)", + backdropFilter: "blur(2px)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 24, + }} + > + + +
+ {/* 브랜디드 헤더 */} +
+
+ +
+
+
+ INVION · PROVISIONING +
+
+ 회사 생성 + {(state.company_name || state.subdomain) && ( + + {" · "} + {state.company_name || state.subdomain} + + )} +
+
+
+ SUPER_ADMIN +
+ + {/* glow line */} +
+
+ + + + {/* 본문 (스크롤) */} +
+ {step === 1 && mark(1, v)} />} + {step === 2 && mark(2, v)} />} + {step === 3 && mark(3, v)} />} + {step === 4 && ( + { + setRunDone(r); + if (r.success) qc.invalidateQueries({ queryKey: ["companies-stats"] }); + }} + /> + )} +
+ + {/* Footer (상황별) */} +
+ {step < 4 && ( + <> + + 취소 + +
+ {validByStep[step] ? "다음 단계로 이동 가능" : "필수 항목을 확인하세요"} +
+ } onClick={prev} disabled={step <= 1}> + 이전 + + {step < 3 ? ( + } + iconRight + > + 다음 + + ) : ( + }> + 생성 시작 + + )} + + )} + + {step === 4 && runDone?.success && ( + <> +
+ 생성 완료 · AUDIT event 기록됨 +
+ }>감사 로그 + } + onClick={() => { + if (runDone.subdomain) { + window.open(`http://${runDone.subdomain}.invyone.com`, "_blank"); + } + tryClose(); + }} + > + 바로 이동 + + + )} + + {step === 4 && runDone && !runDone.success && ( + <> +
+ + 실패 · DB 가 미완성 상태일 수 있습니다 +
+ }> + 롤백 (DB 삭제) + + } onClick={tryClose}> + 닫기 + + + )} + + {step === 4 && !runDone && ( + <> +
+ + 생성 중에는 닫아도 서버에서 계속 진행됩니다. +
+ } onClick={tryClose}> + 창 닫기 + + + 진행 중… + + + )} +
+
+
+ ); +} + +function Btn({ + children, + onClick, + disabled, + variant = "secondary", + icon, + iconRight, +}: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: "primary" | "secondary" | "ghost" | "cyan" | "danger"; + icon?: React.ReactNode; + iconRight?: boolean; +}) { + const styles: Record = { + primary: { + background: "var(--v5-primary)", + color: "#fff", + borderColor: "var(--v5-primary)", + }, + cyan: { + background: "var(--v5-cyan)", + color: "#fff", + borderColor: "var(--v5-cyan)", + boxShadow: disabled ? "none" : "0 0 18px rgba(var(--v5-cyan-rgb), 0.25)", + }, + secondary: { + background: "var(--v5-surface-solid)", + color: "var(--v5-text)", + borderColor: "var(--v5-border)", + }, + ghost: { + background: "transparent", + color: "var(--v5-text-sec)", + borderColor: "transparent", + }, + danger: { + background: "var(--v5-red)", + color: "#fff", + borderColor: "var(--v5-red)", + }, + }; + return ( + + ); +} diff --git a/frontend/components/admin/provisioning/wizard/fields.tsx b/frontend/components/admin/provisioning/wizard/fields.tsx new file mode 100644 index 00000000..e2f6e745 --- /dev/null +++ b/frontend/components/admin/provisioning/wizard/fields.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { Loader2, CheckCircle2, XCircle } from "lucide-react"; + +/** + * 마법사 전용 공용 입력 컴포넌트 (시안 v2 포팅). + * - Field: label + hint + child input wrapper + * - TextInput: 텍스트 입력 (mono, prefix/suffix, status 테두리 글로우) + * - CheckAvailBadge: subdomain/db_prefix 실시간 검증 인디케이터 + */ + +export type AvailStatus = "idle" | "checking" | "available" | "taken" | "invalid"; + +export function Field({ + label, + hint, + required, + full, + error, + children, +}: { + label: string; + hint?: React.ReactNode; + required?: boolean; + full?: boolean; + error?: string; + children: React.ReactNode; +}) { + return ( +
+
+ {label} + {required && ( + + )} + {hint && ( + + {hint} + + )} +
+ {children} + {error && ( +
{error}
+ )} +
+ ); +} + +type TextInputStatus = "ok" | "err" | "warn"; + +export function TextInput({ + value, + onChange, + placeholder, + mono, + prefix, + suffix, + readOnly, + type = "text", + status, + size = "md", +}: { + value: string; + onChange?: (v: string) => void; + placeholder?: string; + mono?: boolean; + prefix?: React.ReactNode; + suffix?: React.ReactNode; + readOnly?: boolean; + type?: string; + status?: TextInputStatus; + size?: "sm" | "md"; +}) { + const border = + status === "ok" + ? "rgb(var(--v5-green-rgb))" + : status === "err" + ? "var(--v5-red)" + : status === "warn" + ? "var(--v5-amber)" + : "var(--v5-border)"; + const glow = + status === "ok" + ? "0 0 0 3px rgba(var(--v5-green-rgb), 0.12)" + : status === "err" + ? "0 0 0 3px rgba(var(--v5-red-rgb), 0.15)" + : status === "warn" + ? "0 0 0 3px rgba(var(--v5-amber-rgb), 0.12)" + : "none"; + return ( +
+ {prefix && ( +
+ {prefix} +
+ )} + onChange?.(e.target.value)} + placeholder={placeholder} + readOnly={readOnly} + style={{ + flex: 1, + padding: size === "sm" ? "0.3rem 0.45rem" : "0.42rem 0.55rem", + background: "transparent", + border: 0, + outline: "none", + fontSize: size === "sm" ? "0.65rem" : "0.7rem", + color: "var(--v5-text)", + fontFamily: mono ? "var(--v5-font-mono)" : "inherit", + }} + /> + {suffix && ( +
+ {suffix} +
+ )} +
+ ); +} + +export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?: string }) { + if (!value || status === "idle") + return 입력 후 자동 확인; + if (status === "checking") + return ( + + 중복 확인 중… + + ); + if (status === "available") + return ( + + 사용 가능 + + ); + if (status === "taken") + return ( + + 이미 사용 중 + + ); + return ( + + 형식 오류 + + ); +} + +/** TextInput 의 status prop 과 매핑 */ +export function availToInputStatus(a: AvailStatus): TextInputStatus | undefined { + if (a === "available") return "ok"; + if (a === "taken" || a === "invalid") return "err"; + return undefined; +} + +export function genPassword(length = 12): string { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#$"; + const arr = new Uint32Array(length); + if (typeof window !== "undefined" && window.crypto) { + window.crypto.getRandomValues(arr); + } + let out = ""; + for (let i = 0; i < length; i++) { + out += chars[arr[i] % chars.length]; + } + return out; +} diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 2cc546f0..a3978d3d 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -11,28 +11,31 @@ const authLog = (event: string, detail: string) => { } }; -// API URL 동적 설정 - 환경변수 우선 사용 +// API URL 동적 설정 +// 우선순위: 1) 테넌트 서브도메인 → 직접 백엔드 2) 프로덕션 도메인 3) NEXT_PUBLIC_API_URL 4) default const getApiBaseUrl = (): string => { + if (typeof window !== "undefined") { + const currentHost = window.location.hostname; + + // 1. 테넌트 서브도메인 (*.invyone.com) 은 Next.js rewrite 우회 필수. + // NEXT_PUBLIC_API_URL=/api 쓰면 rewrite 가 Host 헤더를 invyone-backend-spring:8081 로 + // 변조해서 서브도메인 파싱이 실패함. 직접 8083 으로 보내서 Host 헤더 보존. + if (currentHost.endsWith(".invyone.com")) { + return `http://${currentHost}:8083/api`; + } + + // 2. 프로덕션 메인 도메인 + if (currentHost === "v1.invion.com") { + return "https://api.invion.com/api"; + } + } + + // 3. 환경변수 (docker-compose 에서 /api 로 주입되면 Next rewrite 사용) if (process.env.NEXT_PUBLIC_API_URL) { return process.env.NEXT_PUBLIC_API_URL; } - if (typeof window !== "undefined") { - const currentHost = window.location.hostname; - const currentPort = window.location.port; - - if (currentHost === "v1.invion.com") { - return "https://api.invion.com/api"; - } - - if ( - (currentHost === "localhost" || currentHost === "127.0.0.1") && - (currentPort === "9771" || currentPort === "3000") - ) { - return "http://localhost:8081/api"; - } - } - + // 4. 로컬 기본 return "http://localhost:8081/api"; }; diff --git a/frontend/lib/api/provisioning.ts b/frontend/lib/api/provisioning.ts new file mode 100644 index 00000000..49878bb6 --- /dev/null +++ b/frontend/lib/api/provisioning.ts @@ -0,0 +1,72 @@ +/** + * Phase 3-A 프로비저닝 API 클라이언트. + * 백엔드: /api/admin/provisioning/* + * - GET /table-groups - 마법사 Step 2 체크박스 렌더 + * - GET /check - subdomain/db_prefix/company_code 실시간 검증 + * - POST /companies - 회사 생성 (202 accepted) + * - GET /status/{id} - 진행 상태 폴링 + * - GET /companies-stats - 메인 화면 accordion 렌더용 (derived 필드 포함) + */ +import { apiClient } from "./client"; + +export type CompanyStats = Record; + +export async function getCompaniesStats(): Promise { + const { data } = await apiClient.get("/admin/provisioning/companies-stats"); + return Array.isArray(data) ? data : []; +} + +export async function getTableGroups(): Promise[]> { + const { data } = await apiClient.get("/admin/provisioning/table-groups"); + return Array.isArray(data) ? data : []; +} + +export interface CheckParams { + subdomain?: string; + dbPrefix?: string; + companyCode?: string; +} + +export async function checkAvailability(params: CheckParams): Promise> { + const q = new URLSearchParams(); + if (params.subdomain) q.set("subdomain", params.subdomain); + if (params.dbPrefix) q.set("dbPrefix", params.dbPrefix); + if (params.companyCode) q.set("companyCode", params.companyCode); + const { data } = await apiClient.get(`/admin/provisioning/check?${q.toString()}`); + return data || {}; +} + +export interface CreateCompanyRequest { + company_code: string; + company_name: string; + subdomain: string; + db_prefix: string; + business_registration_number?: string; + representative_name?: string; + representative_phone?: string; + email?: string; + website?: string; + address?: string; + selected_groups?: string[]; + initial_password?: string; +} + +export interface CreateCompanyResponse { + provisioning_id: string; + company_code: string; + db_name: string; + subdomain: string; + admin_user_id: string; + initial_password: string; + status_url: string; +} + +export async function createCompany(req: CreateCompanyRequest): Promise { + const { data } = await apiClient.post("/admin/provisioning/companies", req); + return data; +} + +export async function getProvisioningStatus(jobId: string): Promise> { + const { data } = await apiClient.get(`/admin/provisioning/status/${jobId}`); + return data || {}; +} diff --git a/notes/gbpark/2026-04-24-company-mgmt-ui-schema.md b/notes/gbpark/2026-04-24-company-mgmt-ui-schema.md new file mode 100644 index 00000000..ea87a961 --- /dev/null +++ b/notes/gbpark/2026-04-24-company-mgmt-ui-schema.md @@ -0,0 +1,176 @@ +# 회사관리 UI 스키마 & 데이터 소스 레퍼런스 + +작성일: 2026-04-24 +작성자: gbpark +대상 UI: `admin-companyList-v9-systemized.jsx` (v9 accordion 메인 화면) +관련 마이그레이션: `db/migrations/RUN_081_MIGRATION.md` +관련 실행계획: `notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md` + +--- + +## 요약 + +목업 v9 accordion row 가 요구하는 **16개 필드** 중: +- **8개**는 `COMPANY_MNG` 정적 컬럼 (기존 + 081 에서 추가) +- **4개**는 런타임 집계 (`CompanyStatsService` 가 계산) +- **2개**는 MVP placeholder (추후 데이터 파이프라인 생기면 채움) +- **2개**는 화면에서 derive (표시 로직) + +신규 API: `GET /api/admin/provisioning/companies-stats` + +--- + +## 1. 목업 ↔ 데이터 소스 매핑 + +### 1.1 정적 필드 (COMPANY_MNG 컬럼) + +| 목업 필드 | COMPANY_MNG 컬럼 | 비고 | +|---|---|---| +| `code` | `COMPANY_CODE` | 기존 | +| `name` | `COMPANY_NAME` | 기존 | +| `sub` | `SUBDOMAIN` | 079 추가 | +| `brn` | `BUSINESS_REGISTRATION_NUMBER` | 기존 | +| `owner` | `REPRESENTATIVE_NAME` | 기존. 목업이 `owner` 이지만 그대로 매핑 | +| `created` | `CREATED_DATE` | 기존 | +| `writer` | `WRITER` | 기존 | +| **`plan`** | `PLAN` | ★ 081 추가. 기본값 `'Starter'` | +| **`industry`** | `INDUSTRY` | ★ 081 추가. 기본 NULL | +| **`templates`** | `TEMPLATES_COUNT` | ★ 081 추가. 프로비저닝 시 `CompanyProvisioningService.initiate()` 가 "필수(3) + 선택 n" 합산해서 저장 | + +추가로 DB 라우팅 관련 (079): +| 목업에 없지만 UI 에서 파생 | 소스 | +|---|---| +| `{prefix}.invyone.com` 프리뷰 | `SUBDOMAIN` + 하드코딩 도메인 | +| `{prefix}_vexplor` DB 명 | `DB_NAME` | +| 상태 dot | `DB_STATUS` | + +### 1.2 런타임 집계 (CompanyStatsService) + +| 목업 필드 | 계산 방법 | 구현 파일:함수 | +|---|---|---| +| `users` | tenant DB `SELECT count(*) FROM user_info` | `CompanyStatsService.enrichOne()` | +| `db_size_bytes` (숨김) | 메타 서버 `SELECT pg_database_size(?)` | 같음 | +| `db_size` ("4.2 GB" 포맷) | `formatBytes(bytes)` | 같음 | +| `db_pct` (0~100) | `bytes / (DB_QUOTA_GB * 1GB) * 100` | 같음 | + +`DB_QUOTA_GB` 는 081 에서 추가된 컬럼. 회사별 할당량. 기본 20 GB. 추후 플랜별 자동 결정 로직 추가 가능. + +### 1.3 MVP Placeholder (나중에 구현) + +| 목업 필드 | 현재 값 | 언제 채움 | +|---|---|---| +| `active30` | `0` | `user_info` 또는 별도 `login_history` 테이블에 `last_login_date` 컬럼 생기면 ` WHERE last_login_date > NOW() - INTERVAL '30 days'` 로 구현 | +| `spark` | `[]` (빈 배열) | 일자별 사용자 집계 테이블 (`daily_active_users` 같은) 생기면 최근 12일 데이터 반환 | + +### 1.4 화면에서 derive (백엔드 추가 작업 없음) + +| 목업 필드 | 계산 | +|---|---| +| `url_preview` (`qnc.invyone.com`) | `${subdomain}.invyone.com` | +| `db_name_display` (`qnc_vexplor`) | `db_name` (이미 있음) | + +--- + +## 2. 신규 API + +### `GET /api/admin/provisioning/companies-stats` + +회사관리 v9 accordion 메인 화면 전용. + +**권한**: SUPER_ADMIN (개발 모드 익명 허용) + +**응답 예시**: +```json +[ + { + "company_code": "COMPANY_10", + "company_name": "큐엔씨", + "subdomain": "qnc", + "db_name": "qnc_vexplor", + "db_host": "183.99.177.40", + "db_status": "active", + "status": "active", + "plan": "Starter", + "industry": null, + "owner": "정대표", + "brn": "456-78-90123", + "email": "admin@qnc.kr", + "templates": 3, + "db_quota_gb": 20, + "created": "2026-04-24T...", + "writer": "SUPER_ADMIN", + + "users": 31, + "active30": 0, + "db_size": "820 MB", + "db_size_bytes": 859963392, + "db_pct": 4, + "spark": [] + }, + ... +] +``` + +정렬: `CREATED_DATE DESC`. + +**N+1 주의**: 회사 N 개 → tenant DB N 번 + 메타 서버 N 번 접속. 현재 MVP 직렬 실행. 회사 50 개 넘어가면 병렬(`CompletableFuture`) 또는 5분 캐시 도입. + +--- + +## 3. 081 마이그레이션 요약 + +```sql +ALTER TABLE COMPANY_MNG + ADD COLUMN IF NOT EXISTS PLAN VARCHAR(20) DEFAULT 'Starter', + ADD COLUMN IF NOT EXISTS INDUSTRY VARCHAR(50), + ADD COLUMN IF NOT EXISTS TEMPLATES_COUNT INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS DB_QUOTA_GB INT DEFAULT 20; +``` + +- `PLAN` 기본 'Starter' → 기존 회사들도 자동 'Starter'. +- `TEMPLATES_COUNT` 는 신규 프로비저닝부터만 채워짐. 기존 회사들은 0. +- `INDUSTRY` NULL → UI 에서 "—" 로 표시. + +--- + +## 4. 관련 코드 변경 + +### 신규 +- `com/erp/provisioning/CompanyStatsService.java` — derived 집계 +- `mapper/provisioning.xml` → `listCompaniesForUi` select 추가 + +### 수정 +- `com/erp/provisioning/CompanyProvisioningService.java` → `initiate()` 에 templates_count 계산 +- `com/erp/provisioning/ProvisioningController.java` → `/companies-stats` 엔드포인트 + `CompanyStatsService` 주입 +- `mapper/provisioning.xml` → `insertCompanyWithTenant` 에 `PLAN`, `INDUSTRY`, `TEMPLATES_COUNT` 컬럼 추가 + +--- + +## 5. Phase 3-B 프론트가 할 일 정리 + +1. `GET /api/admin/provisioning/companies-stats` 호출해 회사 목록 획득 +2. 응답 스키마 그대로 `R9` mock 자리에 주입 +3. 필요 시 AccRow 가 `sub` 변수 대신 `subdomain` 사용하도록 네이밍 일관화 + +### 미구현 필드 표시 정책 + +- `active30 = 0` 이면 표시하되 "—" 로 회색 처리 추천 +- `spark.length === 0` 이면 기존 Spark 컴포넌트가 "—" 반환 (이미 목업에 로직 있음) +- `industry === null` 이면 "—" 표시 + +--- + +## 6. 추후 작업 항목 + +| 항목 | 우선순위 | 트리거 | +|---|---|---| +| `active30` 구현 | 중 | `user_info.last_login_date` 컬럼 또는 `login_history` 테이블 생성 | +| `spark` 일자별 집계 | 낮 | 일 단위 배치로 `daily_active_users` 테이블 생성 | +| `companies-stats` 병렬 집계 | 중 | 회사 수 50 개 넘으면 | +| `industry` 선택 드롭다운 | 낮 | 마법사 Step1 에 "업종" 필드 추가 시 | +| `plan` 선택 UI | 낮 | 과금 시스템 도입 시점 | +| `DB_QUOTA_GB` 플랜 연동 | 낮 | 플랜별 자동 할당 (Starter=5, Standard=20, Enterprise=100) | + +--- + +이 문서는 나중에 위 필드들을 실제 데이터로 채울 때 **체크리스트**로 쓰면 됨. 컬럼은 다 깔려있으니 추가 마이그레이션 없이 값만 채우면 화면이 바로 살아남.