diff --git a/backend-spring/build.gradle b/backend-spring/build.gradle index 457ea85b..65b211d0 100644 --- a/backend-spring/build.gradle +++ b/backend-spring/build.gradle @@ -33,6 +33,11 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' implementation 'org.postgresql:postgresql' + // 외부 커넥션 테스트용 JDBC 드라이버 (runtimeOnly — 내부 비즈니스 DB 는 PostgreSQL 만 사용) + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:3.4.1' + runtimeOnly 'com.mysql:mysql-connector-j:8.4.0' + runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11' + runtimeOnly 'org.xerial:sqlite-jdbc:3.46.1.0' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' implementation 'org.flywaydb:flyway-core' diff --git a/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java b/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java index 165b56be..1d772e20 100644 --- a/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java +++ b/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java @@ -100,13 +100,22 @@ public class DataCopier { 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)) { + String sql; + if (isIntegerLike(coltype)) { + sql = String.format( + "SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))", + seq.replace("'", "''"), col, tbl); + } else if (isVarcharLike(coltype)) { + // V001 마이그레이션으로 INT → VARCHAR 로 바뀐 PK 컬럼도 시퀀스가 연결되어 있고, + // INSERT 시 DEFAULT nextval 이 호출되므로 max 재설정 필요. 비숫자 PK(UUID 등) 가 + // 섞여 있어도 정규식으로 거르고 숫자 PK 만 max 계산. + sql = String.format( + "SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\"::bigint) FROM \"%s\" WHERE \"%s\" ~ '^[0-9]+$'), 0), 1))", + seq.replace("'", "''"), col, tbl, col); + } else { 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++; @@ -117,14 +126,8 @@ public class DataCopier { } } } - // 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()); - } + log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}", + updated, skippedType, skippedErr, rows.size()); return updated; } @@ -135,6 +138,12 @@ public class DataCopier { || t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2"); } + private static boolean isVarcharLike(String coltype) { + if (coltype == null) return false; + String t = coltype.toLowerCase(); + return t.startsWith("character varying") || t.startsWith("varchar") || t.startsWith("text"); + } + private List listColumns(Connection conn, String table) throws SQLException { List cols = new ArrayList<>(); try (PreparedStatement ps = conn.prepareStatement( diff --git a/backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java b/backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java index ef319b94..f81fe993 100644 --- a/backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java +++ b/backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java @@ -297,29 +297,61 @@ public class ExternalDbConnectionService extends BaseService { private Map executeConnectionTest( String dbType, Map conn, String password) { + String type = dbType == null ? "" : dbType.toLowerCase(); String host = str(conn, "host"); - int port = toInt(conn, "port", 5432); + int port = toInt(conn, "port", defaultPort(type)); String database = str(conn, "database_name"); String username = str(conn, "username"); String sslEnabled = str(conn, "ssl_enabled"); int connTimeout = toInt(conn, "connection_timeout", 30); + boolean ssl = "Y".equalsIgnoreCase(sslEnabled); - if (!"postgresql".equalsIgnoreCase(dbType)) { - Map result = new LinkedHashMap<>(); - result.put("success", false); - result.put("message", "이 버전에서는 PostgreSQL 연결만 테스트가 지원됩니다."); - return result; - } - - String url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database); + String url; Properties props = new Properties(); - props.setProperty("user", username); - props.setProperty("password", password); - props.setProperty("connect_timeout", String.valueOf(connTimeout)); - props.setProperty("socket_timeout", "30"); - if ("Y".equalsIgnoreCase(sslEnabled)) { - props.setProperty("ssl", "true"); - props.setProperty("sslmode", "require"); + if (username != null) props.setProperty("user", username); + if (password != null) props.setProperty("password", password); + + switch (type) { + case "postgresql" -> { + url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database); + props.setProperty("connect_timeout", String.valueOf(connTimeout)); + props.setProperty("socket_timeout", "30"); + if (ssl) { + props.setProperty("ssl", "true"); + props.setProperty("sslmode", "require"); + } + } + case "mysql" -> { + url = String.format("jdbc:mysql://%s:%d/%s", host, port, database); + props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000)); + props.setProperty("socketTimeout", "30000"); + props.setProperty("useSSL", String.valueOf(ssl)); + props.setProperty("allowPublicKeyRetrieval", "true"); + } + case "mariadb" -> { + url = String.format("jdbc:mariadb://%s:%d/%s", host, port, database); + props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000)); + props.setProperty("socketTimeout", "30000"); + if (ssl) props.setProperty("useSsl", "true"); + } + case "mssql", "sqlserver" -> { + StringBuilder sb = new StringBuilder() + .append("jdbc:sqlserver://").append(host).append(':').append(port) + .append(";databaseName=").append(database) + .append(";loginTimeout=").append(connTimeout) + .append(";encrypt=").append(ssl ? "true;trustServerCertificate=true" : "false"); + url = sb.toString(); + } + case "sqlite" -> { + // SQLite: host/port 무의미. database_name 을 파일 경로로 사용 (비면 in-memory) + url = "jdbc:sqlite:" + (database != null && !database.isBlank() ? database : ":memory:"); + } + default -> { + Map result = new LinkedHashMap<>(); + result.put("success", false); + result.put("message", "지원하지 않는 DB 타입입니다: " + dbType); + return result; + } } try (Connection c = DriverManager.getConnection(url, props); @@ -328,7 +360,11 @@ public class ExternalDbConnectionService extends BaseService { Map result = new LinkedHashMap<>(); result.put("success", true); result.put("message", "연결 성공"); - result.put("details", Map.of("host", host, "database", database, "port", port)); + Map details = new LinkedHashMap<>(); + details.put("host", host == null ? "" : host); + details.put("database", database == null ? "" : database); + details.put("port", port); + result.put("details", details); return result; } catch (SQLException e) { log.warn("DB 연결 테스트 실패 ({}): {}", url, e.getMessage()); @@ -342,6 +378,16 @@ public class ExternalDbConnectionService extends BaseService { } } + private int defaultPort(String dbType) { + if (dbType == null) return 5432; + return switch (dbType.toLowerCase()) { + case "mysql", "mariadb" -> 3306; + case "mssql", "sqlserver" -> 1433; + case "sqlite" -> 0; + default -> 5432; + }; + } + // ── SQL 쿼리 실행 (SELECT only) ──────────────────────────────────────────── public Map executeQuery(long id, String sql) { diff --git a/backend-spring/src/main/resources/mapper/externalDbConnection.xml b/backend-spring/src/main/resources/mapper/externalDbConnection.xml index e0da15c8..ad9fda03 100644 --- a/backend-spring/src/main/resources/mapper/externalDbConnection.xml +++ b/backend-spring/src/main/resources/mapper/externalDbConnection.xml @@ -166,13 +166,13 @@ , #{description} , #{db_type} , #{host} - , #{port} + , #{port}::varchar , #{database_name} , #{username} , #{password} - , #{connection_timeout} - , #{query_timeout} - , #{max_connections} + , #{connection_timeout}::varchar + , #{query_timeout}::varchar + , #{max_connections}::varchar , #{ssl_enabled} , #{ssl_cert_path} , #{connection_options}::JSONB @@ -193,13 +193,13 @@ DESCRIPTION = #{description}, DB_TYPE = #{db_type}, HOST = #{host}, - PORT = #{port}, + PORT = #{port}::varchar, DATABASE_NAME = #{database_name}, USERNAME = #{username}, PASSWORD = #{password}, - CONNECTION_TIMEOUT = #{connection_timeout}, - QUERY_TIMEOUT = #{query_timeout}, - MAX_CONNECTIONS = #{max_connections}, + CONNECTION_TIMEOUT = #{connection_timeout}::varchar, + QUERY_TIMEOUT = #{query_timeout}::varchar, + MAX_CONNECTIONS = #{max_connections}::varchar, SSL_ENABLED = #{ssl_enabled}, SSL_CERT_PATH = #{ssl_cert_path}, CONNECTION_OPTIONS = #{connection_options}::JSONB, diff --git a/frontend/components/admin/ExternalDbConnectionModal.tsx b/frontend/components/admin/ExternalDbConnectionModal.tsx index 28b2ad46..dbe2f146 100644 --- a/frontend/components/admin/ExternalDbConnectionModal.tsx +++ b/frontend/components/admin/ExternalDbConnectionModal.tsx @@ -312,14 +312,14 @@ export const ExternalDbConnectionModal: React.FC return ( - - + + {isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"} -
+
{/* 기본 정보 */}

기본 정보

@@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC
- +