fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈 #21
@@ -33,6 +33,11 @@ dependencies {
|
|||||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
|
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
|
||||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
|
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
|
||||||
implementation 'org.postgresql:postgresql'
|
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'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
annotationProcessor 'org.projectlombok:lombok'
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
implementation 'org.flywaydb:flyway-core'
|
implementation 'org.flywaydb:flyway-core'
|
||||||
|
|||||||
@@ -100,13 +100,22 @@ public class DataCopier {
|
|||||||
try (Statement us = dst.createStatement()) {
|
try (Statement us = dst.createStatement()) {
|
||||||
for (String[] r : rows) {
|
for (String[] r : rows) {
|
||||||
String seq = r[0], tbl = r[1], col = r[2], coltype = r[3];
|
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++;
|
skippedType++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String sql = String.format(
|
|
||||||
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
|
|
||||||
seq.replace("'", "''"), col, tbl);
|
|
||||||
try {
|
try {
|
||||||
us.execute(sql);
|
us.execute(sql);
|
||||||
updated++;
|
updated++;
|
||||||
@@ -117,14 +126,8 @@ public class DataCopier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때
|
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}",
|
||||||
// nextval 을 사용하지 않으므로 setval 은 no-op. skipped_non_integer 값이 높아도 정상.
|
updated, skippedType, skippedErr, rows.size());
|
||||||
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +138,12 @@ public class DataCopier {
|
|||||||
|| t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2");
|
|| 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<String> listColumns(Connection conn, String table) throws SQLException {
|
private List<String> listColumns(Connection conn, String table) throws SQLException {
|
||||||
List<String> cols = new ArrayList<>();
|
List<String> cols = new ArrayList<>();
|
||||||
try (PreparedStatement ps = conn.prepareStatement(
|
try (PreparedStatement ps = conn.prepareStatement(
|
||||||
|
|||||||
@@ -297,29 +297,61 @@ public class ExternalDbConnectionService extends BaseService {
|
|||||||
private Map<String, Object> executeConnectionTest(
|
private Map<String, Object> executeConnectionTest(
|
||||||
String dbType, Map<String, Object> conn, String password) {
|
String dbType, Map<String, Object> conn, String password) {
|
||||||
|
|
||||||
|
String type = dbType == null ? "" : dbType.toLowerCase();
|
||||||
String host = str(conn, "host");
|
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 database = str(conn, "database_name");
|
||||||
String username = str(conn, "username");
|
String username = str(conn, "username");
|
||||||
String sslEnabled = str(conn, "ssl_enabled");
|
String sslEnabled = str(conn, "ssl_enabled");
|
||||||
int connTimeout = toInt(conn, "connection_timeout", 30);
|
int connTimeout = toInt(conn, "connection_timeout", 30);
|
||||||
|
boolean ssl = "Y".equalsIgnoreCase(sslEnabled);
|
||||||
|
|
||||||
if (!"postgresql".equalsIgnoreCase(dbType)) {
|
String url;
|
||||||
Map<String, Object> 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);
|
|
||||||
Properties props = new Properties();
|
Properties props = new Properties();
|
||||||
props.setProperty("user", username);
|
if (username != null) props.setProperty("user", username);
|
||||||
props.setProperty("password", password);
|
if (password != null) props.setProperty("password", password);
|
||||||
props.setProperty("connect_timeout", String.valueOf(connTimeout));
|
|
||||||
props.setProperty("socket_timeout", "30");
|
switch (type) {
|
||||||
if ("Y".equalsIgnoreCase(sslEnabled)) {
|
case "postgresql" -> {
|
||||||
props.setProperty("ssl", "true");
|
url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
|
||||||
props.setProperty("sslmode", "require");
|
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<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "지원하지 않는 DB 타입입니다: " + dbType);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try (Connection c = DriverManager.getConnection(url, props);
|
try (Connection c = DriverManager.getConnection(url, props);
|
||||||
@@ -328,7 +360,11 @@ public class ExternalDbConnectionService extends BaseService {
|
|||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("success", true);
|
result.put("success", true);
|
||||||
result.put("message", "연결 성공");
|
result.put("message", "연결 성공");
|
||||||
result.put("details", Map.of("host", host, "database", database, "port", port));
|
Map<String, Object> 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;
|
return result;
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
log.warn("DB 연결 테스트 실패 ({}): {}", url, e.getMessage());
|
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) ────────────────────────────────────────────
|
// ── SQL 쿼리 실행 (SELECT only) ────────────────────────────────────────────
|
||||||
|
|
||||||
public Map<String, Object> executeQuery(long id, String sql) {
|
public Map<String, Object> executeQuery(long id, String sql) {
|
||||||
|
|||||||
@@ -166,13 +166,13 @@
|
|||||||
, #{description}
|
, #{description}
|
||||||
, #{db_type}
|
, #{db_type}
|
||||||
, #{host}
|
, #{host}
|
||||||
, #{port}
|
, #{port}::varchar
|
||||||
, #{database_name}
|
, #{database_name}
|
||||||
, #{username}
|
, #{username}
|
||||||
, #{password}
|
, #{password}
|
||||||
, #{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}::JSONB
|
, #{connection_options}::JSONB
|
||||||
@@ -193,13 +193,13 @@
|
|||||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||||
<if test="db_type != null">DB_TYPE = #{db_type},</if>
|
<if test="db_type != null">DB_TYPE = #{db_type},</if>
|
||||||
<if test="host != null">HOST = #{host},</if>
|
<if test="host != null">HOST = #{host},</if>
|
||||||
<if test="port != null">PORT = #{port},</if>
|
<if test="port != null">PORT = #{port}::varchar,</if>
|
||||||
<if test="database_name != null">DATABASE_NAME = #{database_name},</if>
|
<if test="database_name != null">DATABASE_NAME = #{database_name},</if>
|
||||||
<if test="username != null">USERNAME = #{username},</if>
|
<if test="username != null">USERNAME = #{username},</if>
|
||||||
<if test="password != null">PASSWORD = #{password},</if>
|
<if test="password != null">PASSWORD = #{password},</if>
|
||||||
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout},</if>
|
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout}::varchar,</if>
|
||||||
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout},</if>
|
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout}::varchar,</if>
|
||||||
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections},</if>
|
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections}::varchar,</if>
|
||||||
<if test="ssl_enabled != null">SSL_ENABLED = #{ssl_enabled},</if>
|
<if test="ssl_enabled != null">SSL_ENABLED = #{ssl_enabled},</if>
|
||||||
<if test="ssl_cert_path != null">SSL_CERT_PATH = #{ssl_cert_path},</if>
|
<if test="ssl_cert_path != null">SSL_CERT_PATH = #{ssl_cert_path},</if>
|
||||||
<if test="connection_options != null">CONNECTION_OPTIONS = #{connection_options}::JSONB,</if>
|
<if test="connection_options != null">CONNECTION_OPTIONS = #{connection_options}::JSONB,</if>
|
||||||
|
|||||||
@@ -312,14 +312,14 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-2xl">
|
<DialogContent className="flex max-h-[90vh] max-w-[95vw] flex-col overflow-hidden sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle className="text-base sm:text-lg">
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
|
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="-mr-1 min-h-0 flex-1 space-y-3 overflow-y-auto pr-1 sm:space-y-4">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<h3 className="text-sm font-semibold sm:text-base">기본 정보</h3>
|
<h3 className="text-sm font-semibold sm:text-base">기본 정보</h3>
|
||||||
@@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="shrink-0 gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
Reference in New Issue
Block a user