[Developer] #424 add editable ORDS settings

Refs #424
This commit is contained in:
devmrko
2026-06-23 11:59:58 +09:00
parent 7d3bc02123
commit 923daa2a56
12 changed files with 201 additions and 10 deletions

View File

@@ -21,7 +21,7 @@ export ADB_PASSWORD="" # 비워두면
# export ADB_CONN="${ADB_USER}/${ADB_PASSWORD}@${ADB_TNS}" # export ADB_CONN="${ADB_USER}/${ADB_PASSWORD}@${ADB_TNS}"
# --- (2b) Spring Boot 백오피스 --- # --- (2b) Spring Boot 백오피스 ---
export BACKOFFICE_ORDS_BASE_URL="" # 예: https://<actual-ords-domain>/ords export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords"
export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10" export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) --- # --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) ---

View File

@@ -77,6 +77,34 @@ CREATE TABLE cb_ords_probe_audit (
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
); );
PROMPT === Creating backoffice settings ===
BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE cb_backoffice_setting (
setting_key VARCHAR2(100) PRIMARY KEY,
setting_value VARCHAR2(1000),
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
/
MERGE INTO cb_backoffice_setting dst
USING (
SELECT 'ORDS_BASE_URL' setting_key,
'https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords' setting_value
FROM dual
) src
ON (dst.setting_key = src.setting_key)
WHEN MATCHED THEN UPDATE SET dst.setting_value = src.setting_value
WHERE dst.setting_value IS NULL OR TRIM(dst.setting_value) IS NULL
WHEN NOT MATCHED THEN INSERT (setting_key, setting_value, updated_at)
VALUES (src.setting_key, src.setting_value, SYSTIMESTAMP);
PROMPT === Seeding default VPD ORDS protected object === PROMPT === Seeding default VPD ORDS protected object ===
MERGE INTO cb_protected_object dst MERGE INTO cb_protected_object dst
USING ( USING (

View File

@@ -0,0 +1,7 @@
package com.cloudhandson.vpdbackoffice.domain.setting;
public record BackofficeSetting(
String settingKey,
String settingValue
) {
}

View File

@@ -0,0 +1,13 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface SettingMapper {
BackofficeSetting findByKey(@Param("settingKey") String settingKey);
void upsert(@Param("settingKey") String settingKey, @Param("settingValue") String settingValue);
}

View File

@@ -1,6 +1,5 @@
package com.cloudhandson.vpdbackoffice.service; package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent; import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
@@ -43,7 +42,7 @@ public class OrdsProbeService {
private final ProbeErrorClassifier errorClassifier; private final ProbeErrorClassifier errorClassifier;
private final RestTemplate ordsRestTemplate; private final RestTemplate ordsRestTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final BackofficeProperties properties; private final SettingService settingService;
private final Clock clock; private final Clock clock;
public OrdsProbeService( public OrdsProbeService(
@@ -53,7 +52,7 @@ public class OrdsProbeService {
ProbeErrorClassifier errorClassifier, ProbeErrorClassifier errorClassifier,
RestTemplate ordsRestTemplate, RestTemplate ordsRestTemplate,
ObjectMapper objectMapper, ObjectMapper objectMapper,
BackofficeProperties properties, SettingService settingService,
Clock clock Clock clock
) { ) {
this.tokenService = tokenService; this.tokenService = tokenService;
@@ -62,12 +61,13 @@ public class OrdsProbeService {
this.errorClassifier = errorClassifier; this.errorClassifier = errorClassifier;
this.ordsRestTemplate = ordsRestTemplate; this.ordsRestTemplate = ordsRestTemplate;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.properties = properties; this.settingService = settingService;
this.clock = clock; this.clock = clock;
} }
public ProbeResult runProbe(ProbeCommand command) { public ProbeResult runProbe(ProbeCommand command) {
if (properties.ords().baseUrl() == null || properties.ords().baseUrl().isBlank()) { String ordsBaseUrl = settingService.ordsBaseUrl();
if (ordsBaseUrl == null || ordsBaseUrl.isBlank()) {
return auditAndReturn(command, ProbeResult.blocked( return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.ORDS_NOT_CONFIGURED, ProbeStatus.ORDS_NOT_CONFIGURED,
ProbeStatus.ORDS_NOT_CONFIGURED.name(), ProbeStatus.ORDS_NOT_CONFIGURED.name(),
@@ -100,7 +100,7 @@ public class OrdsProbeService {
String requestHeaders = null; String requestHeaders = null;
String requestPayload = null; String requestPayload = null;
try { try {
URI uri = buildUri(object.ordsPath(), command.limit()); URI uri = buildUri(ordsBaseUrl, object.ordsPath(), command.limit());
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(command.bearerToken()); headers.setBearerAuth(command.bearerToken());
requestHeaders = prettyHeaders(maskedRequestHeaders(headers)); requestHeaders = prettyHeaders(maskedRequestHeaders(headers));
@@ -144,8 +144,7 @@ public class OrdsProbeService {
} }
} }
private URI buildUri(String ordsPath, int limit) { private URI buildUri(String baseUrl, String ordsPath, int limit) {
String baseUrl = properties.ords().baseUrl();
String path = ordsPath.startsWith("/") ? ordsPath.substring(1) : ordsPath; String path = ordsPath.startsWith("/") ? ordsPath.substring(1) : ordsPath;
return UriComponentsBuilder.fromUriString(baseUrl) return UriComponentsBuilder.fromUriString(baseUrl)
.path("/") .path("/")

View File

@@ -0,0 +1,52 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting;
import com.cloudhandson.vpdbackoffice.mapper.SettingMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SettingService {
public static final String ORDS_BASE_URL = "ORDS_BASE_URL";
private final SettingMapper settingMapper;
private final BackofficeProperties properties;
public SettingService(SettingMapper settingMapper, BackofficeProperties properties) {
this.settingMapper = settingMapper;
this.properties = properties;
}
public String ordsBaseUrl() {
BackofficeSetting setting = settingMapper.findByKey(ORDS_BASE_URL);
if (setting != null && setting.settingValue() != null && !setting.settingValue().isBlank()) {
return normalizeUrl(setting.settingValue());
}
return normalizeUrl(properties.ords().baseUrl());
}
@Transactional
public void updateOrdsBaseUrl(String value) {
if (value == null || value.isBlank()) {
throw new AppException("ORDS Base URL은 필수입니다.");
}
String normalized = normalizeUrl(value);
if (!normalized.startsWith("https://") && !normalized.startsWith("http://")) {
throw new AppException("ORDS Base URL은 http:// 또는 https://로 시작해야 합니다.");
}
settingMapper.upsert(ORDS_BASE_URL, normalized);
}
private String normalizeUrl(String value) {
if (value == null) {
return "";
}
String trimmed = value.trim();
while (trimmed.endsWith("/") && trimmed.length() > "https://".length()) {
trimmed = trimmed.substring(0, trimmed.length() - 1);
}
return trimmed;
}
}

View File

@@ -0,0 +1,35 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.SettingService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class SettingController {
private final SettingService settingService;
public SettingController(SettingService settingService) {
this.settingService = settingService;
}
@GetMapping("/settings")
public String settings(Model model) {
model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl());
return "settings";
}
@PostMapping("/settings/ords")
public String updateOrds(
@RequestParam String ordsBaseUrl,
RedirectAttributes redirectAttributes
) {
settingService.updateOrdsBaseUrl(ordsBaseUrl);
redirectAttributes.addFlashAttribute("message", "ORDS 설정을 저장했습니다.");
return "redirect:/settings";
}
}

View File

@@ -29,5 +29,5 @@ backoffice:
token: token:
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365} max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
ords: ords:
base-url: ${BACKOFFICE_ORDS_BASE_URL:} base-url: ${BACKOFFICE_ORDS_BASE_URL:https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords}
timeout-seconds: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10} timeout-seconds: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.SettingMapper">
<select id="findByKey" resultType="com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting">
SELECT setting_key, setting_value
FROM cb_backoffice_setting
WHERE setting_key = #{settingKey,jdbcType=VARCHAR}
</select>
<update id="upsert">
MERGE INTO cb_backoffice_setting dst
USING (
SELECT #{settingKey,jdbcType=VARCHAR} setting_key,
#{settingValue,jdbcType=VARCHAR} setting_value
FROM dual
) src
ON (dst.setting_key = src.setting_key)
WHEN MATCHED THEN UPDATE SET
dst.setting_value = src.setting_value,
dst.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT (setting_key, setting_value, updated_at)
VALUES (src.setting_key, src.setting_value, SYSTIMESTAMP)
</update>
</mapper>

View File

@@ -82,6 +82,10 @@ body {
font-weight: 600; font-weight: 600;
} }
.form-grid .span-2 {
grid-column: span 2;
}
.inline-form { .inline-form {
display: inline; display: inline;
} }

View File

@@ -20,6 +20,7 @@
<a class="nav-link" href="/objects">테이블/뷰</a> <a class="nav-link" href="/objects">테이블/뷰</a>
<a class="nav-link" href="/tokens">토큰</a> <a class="nav-link" href="/tokens">토큰</a>
<a class="nav-link" href="/probe">ORDS 검증</a> <a class="nav-link" href="/probe">ORDS 검증</a>
<a class="nav-link" href="/settings">설정</a>
</div> </div>
<form method="post" action="/logout" class="ms-auto"> <form method="post" action="/logout" class="ms-auto">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('설정')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>설정</h1>
<p>백오피스에서 사용하는 외부 연동 값을 관리합니다.</p>
</div>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<section class="content-band">
<h2>ORDS</h2>
<form method="post" action="/settings/ords" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label class="span-2">
Base URL
<input class="form-control" name="ordsBaseUrl" th:value="${ordsBaseUrl}" required>
</label>
<button class="btn btn-primary" type="submit">저장</button>
</form>
</section>
</main>
</body>
</html>