[Developer] #424 add backoffice schema initialization action

This commit is contained in:
devmrko
2026-06-23 15:00:04 +09:00
parent be4f337802
commit d9a676d706
4 changed files with 275 additions and 2 deletions

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.domain.schema;
public record SchemaActionResult(
String objectName,
String action,
String status,
String message
) {
}

View File

@@ -0,0 +1,207 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.schema.SchemaActionResult;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BackofficeSchemaService {
private final JdbcTemplate jdbcTemplate;
private final BackofficeProperties properties;
public BackofficeSchemaService(JdbcTemplate jdbcTemplate, BackofficeProperties properties) {
this.jdbcTemplate = jdbcTemplate;
this.properties = properties;
}
@Transactional
public List<SchemaActionResult> initializeSchema() {
List<SchemaActionResult> results = new ArrayList<>();
createSequence(results, "cb_agent_bearer_key_seq", 1000);
createSequence(results, "cb_permission_seq", 1000);
createSequence(results, "cb_permission_rule_seq", 10000);
createSequence(results, "cb_ords_probe_audit_seq", 1);
createTable(results, "cb_app_user", """
CREATE TABLE cb_app_user (
user_id NUMBER PRIMARY KEY,
user_name VARCHAR2(100) NOT NULL UNIQUE,
employee_no VARCHAR2(50),
dept_code VARCHAR2(50),
can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL,
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL
)
""");
createTable(results, "cb_app_role", """
CREATE TABLE cb_app_role (
role_id NUMBER PRIMARY KEY,
role_name VARCHAR2(100) NOT NULL UNIQUE
)
""");
createTable(results, "cb_user_role", """
CREATE TABLE cb_user_role (
user_id NUMBER NOT NULL,
role_id NUMBER NOT NULL,
CONSTRAINT cb_user_role_pk PRIMARY KEY (user_id, role_id)
)
""");
createTable(results, "cb_permission", """
CREATE TABLE cb_permission (
perm_id NUMBER PRIMARY KEY,
role_id NUMBER NOT NULL,
target_name VARCHAR2(128) NOT NULL,
action_name VARCHAR2(30) DEFAULT 'SELECT' NOT NULL
)
""");
createTable(results, "cb_permission_rule", """
CREATE TABLE cb_permission_rule (
rule_id NUMBER PRIMARY KEY,
perm_id NUMBER NOT NULL,
rule_column VARCHAR2(128),
rule_type VARCHAR2(30) NOT NULL,
rule_value VARCHAR2(4000)
)
""");
addColumn(results, "cb_permission_rule", "rule_column", "ALTER TABLE cb_permission_rule ADD (rule_column VARCHAR2(128))");
createTable(results, "cb_permission_column", """
CREATE TABLE cb_permission_column (
permission_id NUMBER NOT NULL,
column_name VARCHAR2(128) NOT NULL,
CONSTRAINT cb_permission_column_pk PRIMARY KEY (permission_id, column_name)
)
""");
createTable(results, "cb_agent_bearer_key", """
CREATE TABLE cb_agent_bearer_key (
key_id NUMBER PRIMARY KEY,
user_id NUMBER NOT NULL,
key_prefix VARCHAR2(30) NOT NULL,
key_hash VARCHAR2(128) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP,
description VARCHAR2(200)
)
""");
addColumn(results, "cb_agent_bearer_key", "description",
"ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))");
createTable(results, "cb_protected_object", """
CREATE TABLE cb_protected_object (
object_id NUMBER PRIMARY KEY,
owner VARCHAR2(128) NOT NULL,
object_name VARCHAR2(128) NOT NULL UNIQUE,
ords_path VARCHAR2(300) NOT NULL,
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL
)
""");
createTable(results, "cb_protected_column", """
CREATE TABLE cb_protected_column (
column_id NUMBER PRIMARY KEY,
object_id NUMBER NOT NULL,
column_name VARCHAR2(128) NOT NULL,
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL,
visible_role_id NUMBER,
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name)
)
""");
createTable(results, "cb_ords_probe_audit", """
CREATE TABLE cb_ords_probe_audit (
audit_id NUMBER PRIMARY KEY,
event_type VARCHAR2(50) NOT NULL,
key_id NUMBER,
object_id NUMBER,
status VARCHAR2(50),
row_count NUMBER,
error_code VARCHAR2(100),
message VARCHAR2(500),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
""");
createTable(results, "cb_backoffice_setting", """
CREATE TABLE cb_backoffice_setting (
setting_key VARCHAR2(100) PRIMARY KEY,
setting_value VARCHAR2(1000),
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
""");
seedDefaultSettings(results);
return results;
}
private void createSequence(List<SchemaActionResult> results, String name, int startWith) {
runIgnoringAlreadyExists(results, name, "SEQUENCE",
"CREATE SEQUENCE " + name + " START WITH " + startWith + " INCREMENT BY 1 NOCACHE");
}
private void createTable(List<SchemaActionResult> results, String name, String ddl) {
runIgnoringAlreadyExists(results, name, "TABLE", ddl);
}
private void addColumn(List<SchemaActionResult> results, String table, String column, String ddl) {
try {
jdbcTemplate.execute(ddl);
results.add(new SchemaActionResult(table + "." + column, "COLUMN", "CREATED", "컬럼을 추가했습니다."));
} catch (RuntimeException exception) {
if (oracleErrorCode(exception) == 1430) {
results.add(new SchemaActionResult(table + "." + column, "COLUMN", "EXISTS", "이미 존재합니다."));
return;
}
results.add(new SchemaActionResult(table + "." + column, "COLUMN", "FAILED", safeMessage(exception)));
throw exception;
}
}
private void runIgnoringAlreadyExists(List<SchemaActionResult> results, String name, String action, String ddl) {
try {
jdbcTemplate.execute(ddl);
results.add(new SchemaActionResult(name, action, "CREATED", "생성했습니다."));
} catch (RuntimeException exception) {
if (oracleErrorCode(exception) == 955) {
results.add(new SchemaActionResult(name, action, "EXISTS", "이미 존재합니다."));
return;
}
results.add(new SchemaActionResult(name, action, "FAILED", safeMessage(exception)));
throw exception;
}
}
private void seedDefaultSettings(List<SchemaActionResult> results) {
jdbcTemplate.update("""
MERGE INTO cb_backoffice_setting dst
USING (
SELECT 'ORDS_BASE_URL' setting_key, ? 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
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)
""", properties.ords().baseUrl());
results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "MERGED", "기본 ORDS URL 설정을 보강했습니다."));
}
private int oracleErrorCode(RuntimeException exception) {
Throwable current = exception;
while (current != null) {
if (current instanceof SQLException sqlException) {
return Math.abs(sqlException.getErrorCode());
}
current = current.getCause();
}
return 0;
}
private String safeMessage(Throwable throwable) {
String message = throwable.getMessage();
if (message == null || message.isBlank()) {
return throwable.getClass().getSimpleName();
}
String normalized = message.replaceAll("\\s+", " ").trim();
return normalized.length() <= 300 ? normalized : normalized.substring(0, 300) + "...";
}
}

View File

@@ -1,6 +1,8 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.BackofficeSchemaService;
import com.cloudhandson.vpdbackoffice.service.SettingService;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -12,14 +14,23 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
public class SettingController {
private final SettingService settingService;
private final BackofficeSchemaService backofficeSchemaService;
public SettingController(SettingService settingService) {
public SettingController(SettingService settingService, BackofficeSchemaService backofficeSchemaService) {
this.settingService = settingService;
this.backofficeSchemaService = backofficeSchemaService;
}
@GetMapping("/settings")
public String settings(Model model) {
model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl());
try {
model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl());
} catch (DataAccessException exception) {
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("ordsBaseUrl", "");
model.addAttribute("runtimeErrorTitle", error.title());
model.addAttribute("runtimeErrorMessage", error.message());
}
return "settings";
}
@@ -32,4 +43,11 @@ public class SettingController {
redirectAttributes.addFlashAttribute("message", "ORDS 설정을 저장했습니다.");
return "redirect:/settings";
}
@PostMapping("/settings/schema/initialize")
public String initializeSchema(RedirectAttributes redirectAttributes) {
redirectAttributes.addFlashAttribute("schemaResults", backofficeSchemaService.initializeSchema());
redirectAttributes.addFlashAttribute("message", "백오피스 지원 테이블 DDL을 실행했습니다.");
return "redirect:/settings";
}
}

View File

@@ -10,6 +10,10 @@
</div>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<div class="alert alert-warning" th:if="${runtimeErrorMessage}">
<strong th:text="${runtimeErrorTitle}">DB 준비 필요</strong>
<div th:text="${runtimeErrorMessage}">지원 테이블을 생성하세요.</div>
</div>
<section class="content-band">
<h2>ORDS</h2>
@@ -22,6 +26,41 @@
<button class="btn rw-btn-primary" type="submit">저장</button>
</form>
</section>
<section class="content-band mt-3">
<h2>DB 스키마 초기화</h2>
<p class="text-muted mb-3">
현재 연결된 DB에 백오피스가 사용하는 지원 테이블과 시퀀스를 생성하거나 보강합니다.
</p>
<form method="post" action="/settings/schema/initialize" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn btn-outline-primary" type="submit">지원 테이블 생성/확인</button>
</form>
<div class="table-responsive mt-3" th:if="${schemaResults}">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Object</th>
<th>Action</th>
<th>Status</th>
<th>Message</th>
</tr>
</thead>
<tbody>
<tr th:each="result : ${schemaResults}">
<td><code th:text="${result.objectName()}">object</code></td>
<td th:text="${result.action()}">TABLE</td>
<td>
<span class="badge"
th:classappend="${result.status() == 'CREATED' || result.status() == 'MERGED'} ? ' text-bg-success' : (${result.status() == 'EXISTS'} ? ' text-bg-secondary' : ' text-bg-danger')"
th:text="${result.status()}">CREATED</span>
</td>
<td th:text="${result.message()}">생성했습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>