855 lines
45 KiB
Python
855 lines
45 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
킨텍스 자동전시시스템 — DA(데이터 아키텍처) 산출물 생성기
|
||
1) 데이터베이스설계서.pptx (행안부 DB설계서 표준 톤 / 16:9)
|
||
2) 테이블정의서.xlsx (실제 Flyway DDL 역추출)
|
||
|
||
내용 소스(권위) = src/backend/.../db/migration/V1~V9 실제 DDL.
|
||
→ 테이블·컬럼·타입·NULL·PK/FK·기본값·인덱스·공간타입을 프로그램으로 파싱(수기 발명 금지).
|
||
→ 컬럼 설명은 DDL 인라인 주석·COMMENT ON 을 그대로 사용. 없으면 generic 표준 컬럼만 보강.
|
||
|
||
디자인 = KINTEX Blue #0066B3 / AI Purple #6D4AFF / 맑은 고딕. ERD·도식은 python-pptx 도형 직접 작도.
|
||
보안 불변 = 비밀번호/API키/서버IP/SSH 미기재. 암호화 컬럼은 정책만 기술(AES-256-GCM).
|
||
|
||
재실행: python gen_da_deliverables.py
|
||
"""
|
||
import os, re, glob
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
OUT_DIR = os.path.dirname(HERE) # docs/deliverables/DA산출물
|
||
MIG_DIR = os.path.abspath(os.path.join(
|
||
HERE, "..", "..", "..", "..", "src", "backend", "src", "main", "resources", "db", "migration"))
|
||
assert os.path.isdir(MIG_DIR), f"migration dir not found: {MIG_DIR}"
|
||
|
||
# ======================================================================
|
||
# 1. DDL 파서 (Flyway V1~V9 → 테이블 스키마 모델)
|
||
# ======================================================================
|
||
CONSTRAINT_STARTS = ("PRIMARY KEY", "UNIQUE", "FOREIGN KEY", "CHECK", "CONSTRAINT", "REFERENCES ")
|
||
GENERIC_DESC = {
|
||
"id": "식별자(PK)", "created_at": "생성일시", "updated_at": "수정일시",
|
||
"event_id": "행사 참조(FK event)", "status": "상태 코드", "sort_order": "정렬 순서",
|
||
"use_yn": "사용 여부(Y/N)", "dept_id": "부서 식별자", "writer_id": "작성자",
|
||
"writer_name": "작성자명", "author_id": "작성자", "author_name": "작성자명",
|
||
"owner_id": "소유자", "owner_name": "소유자명", "title": "제목", "content": "내용",
|
||
"read_at": "읽은 일시", "recipient_id": "수신자",
|
||
}
|
||
|
||
def _split_type(rest):
|
||
"""rest 문자열의 선두에서 데이터타입 토큰을 추출. geometry(Point,0)·numeric(8,2) 등 괄호 내부 콤마 허용."""
|
||
m = re.match(r"\s*([A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?)", rest)
|
||
if not m:
|
||
return "", rest
|
||
return m.group(1), rest[m.end():]
|
||
|
||
def _parse_default(rest):
|
||
m = re.search(r"\bDEFAULT\s+(now\(\)|gen_random_uuid\(\)|'[^']*'|true|false|[0-9]+\.?[0-9]*|[A-Za-z_][\w]*)",
|
||
rest, re.IGNORECASE)
|
||
return m.group(1) if m else ""
|
||
|
||
def parse_ddl():
|
||
tables = {} # name -> {"cols":[...], "pk":set, "uniques":[...], "indexes":[...], "version":Vx, "order":int}
|
||
order = 0
|
||
files = sorted(glob.glob(os.path.join(MIG_DIR, "V*.sql")),
|
||
key=lambda p: int(re.match(r"V(\d+)", os.path.basename(p)).group(1)))
|
||
comments = {} # (table,col) -> desc (COMMENT ON)
|
||
for path in files:
|
||
ver = re.match(r"(V\d+)", os.path.basename(path)).group(1)
|
||
raw = open(path, encoding="utf-8").read()
|
||
|
||
# COMMENT ON COLUMN t.c IS '...'
|
||
for cm in re.finditer(r"COMMENT\s+ON\s+COLUMN\s+(\w+)\.(\w+)\s+IS\s+'((?:[^']|'')*)'", raw, re.IGNORECASE):
|
||
comments[(cm.group(1), cm.group(2))] = cm.group(3).replace("''", "'")
|
||
|
||
# CREATE TABLE IF NOT EXISTS name ( ... );
|
||
for tm in re.finditer(r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+(\w+)\s*\((.*?)\n\)\s*;",
|
||
raw, re.IGNORECASE | re.DOTALL):
|
||
tname, body = tm.group(1), tm.group(2)
|
||
if tname not in tables:
|
||
order += 1
|
||
tables[tname] = {"cols": [], "pk": set(), "uniques": [], "indexes": [],
|
||
"version": ver, "order": order}
|
||
t = tables[tname]
|
||
for line in body.split("\n"):
|
||
code = line.split("--", 1)[0].strip()
|
||
cmt = line.split("--", 1)[1].strip() if "--" in line else ""
|
||
if not code:
|
||
continue
|
||
code = code.rstrip(",").strip()
|
||
up = code.upper()
|
||
# 테이블 레벨 제약
|
||
if up.startswith("PRIMARY KEY"):
|
||
for c in re.findall(r"\((.*?)\)", code)[0].split(","):
|
||
t["pk"].add(c.strip())
|
||
continue
|
||
if up.startswith("UNIQUE"):
|
||
t["uniques"].append(re.findall(r"\((.*?)\)", code)[0].strip())
|
||
continue
|
||
if up.startswith(CONSTRAINT_STARTS):
|
||
continue
|
||
# 컬럼 정의
|
||
cm2 = re.match(r"(\w+)\s+(.*)", code)
|
||
if not cm2:
|
||
continue
|
||
cname = cm2.group(1)
|
||
dtype, rest = _split_type(cm2.group(2))
|
||
ru = rest.upper()
|
||
is_pk = "PRIMARY KEY" in ru
|
||
if is_pk:
|
||
t["pk"].add(cname)
|
||
fk = re.search(r"REFERENCES\s+(\w+)\s*\((\w+)\)", rest, re.IGNORECASE)
|
||
nn = ("NOT NULL" in ru) or is_pk
|
||
uniq = ("UNIQUE" in ru) and "PRIMARY KEY" not in ru
|
||
dflt = _parse_default(rest)
|
||
t["cols"].append({
|
||
"name": cname, "type": dtype,
|
||
"null": "N" if nn else "Y",
|
||
"pk": is_pk, "fk": (fk.group(1) if fk else None),
|
||
"fkcol": (fk.group(2) if fk else None),
|
||
"unique": uniq, "default": dflt, "desc": cmt,
|
||
})
|
||
|
||
# ALTER TABLE t ADD COLUMN IF NOT EXISTS c type ...
|
||
for am in re.finditer(r"ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS\s+(.+?);",
|
||
raw, re.IGNORECASE):
|
||
tname = am.group(1)
|
||
if tname not in tables:
|
||
continue
|
||
t = tables[tname]
|
||
code = am.group(2).strip()
|
||
cm2 = re.match(r"(\w+)\s+(.*)", code)
|
||
if not cm2:
|
||
continue
|
||
cname = cm2.group(1)
|
||
if any(c["name"] == cname for c in t["cols"]):
|
||
continue
|
||
dtype, rest = _split_type(cm2.group(2))
|
||
ru = rest.upper()
|
||
t["cols"].append({
|
||
"name": cname, "type": dtype,
|
||
"null": "N" if "NOT NULL" in ru else "Y",
|
||
"pk": False, "fk": None, "fkcol": None,
|
||
"unique": "UNIQUE" in ru, "default": _parse_default(rest),
|
||
"desc": f"({ver} 순증 컬럼)",
|
||
})
|
||
|
||
# CREATE INDEX ... ON t ...
|
||
for im in re.finditer(r"CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+(\w+)\s+ON\s+(\w+)\s*(.*?);",
|
||
raw, re.IGNORECASE | re.DOTALL):
|
||
idxname, tname, spec = im.group(1), im.group(2), im.group(3).strip()
|
||
if tname in tables:
|
||
gist = "GIST" in spec.upper()
|
||
cols = re.findall(r"\((.*?)\)", spec)
|
||
tables[tname]["indexes"].append({
|
||
"name": idxname, "spec": (cols[0] if cols else spec),
|
||
"gist": gist,
|
||
})
|
||
|
||
# 설명 보강: COMMENT ON → generic
|
||
for tname, t in tables.items():
|
||
for c in t["cols"]:
|
||
if not c["desc"]:
|
||
c["desc"] = comments.get((tname, c["name"]), "")
|
||
if not c["desc"] and c["name"] in GENERIC_DESC:
|
||
c["desc"] = GENERIC_DESC[c["name"]]
|
||
return tables
|
||
|
||
TABLES = parse_ddl()
|
||
|
||
# ---- 테이블 한글명 · 주제영역 매핑 (라벨링) ----------------------------------
|
||
KO = {
|
||
"app_user": "사용자", "company": "등록업체", "event": "행사",
|
||
"hall": "전시홀 마스터", "hall_assignment": "행사-홀 배정", "event_member": "행사 참여자(RBAC)",
|
||
"booth_standard": "부스 표준사양", "master_data": "마스터데이터(요율·규정)",
|
||
"trench": "트렌치(급전 포인트)", "hall_exit": "비상구", "layout": "배치안(버전)",
|
||
"booth": "부스(폴리곤)", "design_plan": "부스 설계안", "utility_order": "유틸리티 신청",
|
||
"render_job": "AI 렌더잡", "common_code_group": "공통코드 그룹", "common_code": "공통코드",
|
||
"sys_menu": "시스템 메뉴", "sys_role": "역할", "sys_permission": "권한",
|
||
"sys_role_permission": "역할-권한 매핑", "sys_setting": "시스템 설정", "audit_log": "감사 로그",
|
||
"worklog": "업무일지", "schedule": "일정", "message": "쪽지",
|
||
"message_recipient": "쪽지 수신자", "notice": "공지", "opinion": "의견",
|
||
"opinion_comment": "의견 댓글", "meeting": "회의", "meeting_action": "회의 액션아이템",
|
||
"report": "보고서", "notification": "알림", "password_reset": "비밀번호 재설정 코드",
|
||
}
|
||
# 주제영역(Subject Area): (코드, 한글, 테이블목록, 색상키)
|
||
AREAS = [
|
||
("MASTER", "행사·조직·권한·마스터",
|
||
["event", "hall", "hall_assignment", "event_member", "app_user", "company",
|
||
"booth_standard", "master_data"]),
|
||
("SPATIAL", "공간·설계·시각화(부스 코어)",
|
||
["trench", "hall_exit", "layout", "booth", "design_plan", "utility_order", "render_job"]),
|
||
("SYSTEM", "시스템관리·인증",
|
||
["common_code_group", "common_code", "sys_menu", "sys_role", "sys_permission",
|
||
"sys_role_permission", "sys_setting", "audit_log", "password_reset"]),
|
||
("COMMON", "공통 업무 모듈",
|
||
["worklog", "schedule", "message", "message_recipient", "notice", "opinion",
|
||
"opinion_comment", "meeting", "meeting_action", "report", "notification"]),
|
||
]
|
||
AREA_OF = {}
|
||
for code, ko, tlist in AREAS:
|
||
for tn in tlist:
|
||
AREA_OF[tn] = ko
|
||
|
||
TOTAL_TABLES = len(TABLES)
|
||
TOTAL_COLS = sum(len(t["cols"]) for t in TABLES.values())
|
||
TOTAL_IDX = sum(len(t["indexes"]) for t in TABLES.values())
|
||
SPATIAL_COLS = [(tn, c["name"], c["type"]) for tn, t in TABLES.items()
|
||
for c in t["cols"] if c["type"].lower().startswith("geometry")]
|
||
FK_EDGES = [(tn, c["name"], c["fk"]) for tn, t in TABLES.items()
|
||
for c in t["cols"] if c["fk"]]
|
||
|
||
# ======================================================================
|
||
# 2. PPTX — 데이터베이스설계서
|
||
# ======================================================================
|
||
from pptx import Presentation
|
||
from pptx.util import Pt
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
|
||
from pptx.oxml.ns import qn
|
||
|
||
BLUE = RGBColor(0x00, 0x66, 0xB3)
|
||
BLUE_DK = RGBColor(0x00, 0x44, 0x7A)
|
||
BLUE_LT = RGBColor(0xE1, 0xEF, 0xF9)
|
||
PURPLE = RGBColor(0x6D, 0x4A, 0xFF)
|
||
PURPLE_LT = RGBColor(0xEC, 0xE8, 0xFF)
|
||
INK = RGBColor(0x10, 0x18, 0x28)
|
||
MUTED = RGBColor(0x66, 0x70, 0x85)
|
||
LINE = RGBColor(0xE4, 0xE7, 0xEC)
|
||
BG = RGBColor(0xF9, 0xFA, 0xFB)
|
||
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
||
GREEN = RGBColor(0x12, 0x9E, 0x63)
|
||
AMBER = RGBColor(0xE0, 0x8A, 0x00)
|
||
TEAL = RGBColor(0x0E, 0x7C, 0x86)
|
||
CARD = RGBColor(0xFF, 0xFF, 0xFF)
|
||
FONT = "맑은 고딕"
|
||
IN = 914400
|
||
def inch(v): return int(v * IN)
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = 12192000
|
||
prs.slide_height = 6858000
|
||
BLANK = prs.slide_layouts[6]
|
||
def slide(): return prs.slides.add_slide(BLANK)
|
||
|
||
def _fill(sp, color):
|
||
if color is None: sp.fill.background()
|
||
else:
|
||
sp.fill.solid(); sp.fill.fore_color.rgb = color
|
||
|
||
def rect(s, x, y, w, h, fill=None, line=None, line_w=1.0, shape=MSO_SHAPE.RECTANGLE, shadow=False):
|
||
sp = s.shapes.add_shape(shape, inch(x), inch(y), inch(w), inch(h))
|
||
_fill(sp, fill)
|
||
if line is None: sp.line.fill.background()
|
||
else:
|
||
sp.line.color.rgb = line; sp.line.width = Pt(line_w)
|
||
sp.shadow.inherit = False # creates an empty <a:effectLst/>
|
||
if shadow:
|
||
el = sp._element.spPr
|
||
ef = el.find(qn('a:effectLst'))
|
||
if ef is None:
|
||
ef = el.makeelement(qn('a:effectLst'), {}); el.append(ef)
|
||
sh = ef.makeelement(qn('a:outerShdw'),
|
||
{'blurRad':'80000','dist':'30000','dir':'5400000','rotWithShape':'0'})
|
||
clr = sh.makeelement(qn('a:srgbClr'), {'val':'101828'})
|
||
alp = clr.makeelement(qn('a:alpha'), {'val':'16000'})
|
||
clr.append(alp); sh.append(clr); ef.append(sh)
|
||
return sp
|
||
|
||
def text(s, x, y, w, h, content, size=13, color=INK, bold=False, align=PP_ALIGN.LEFT,
|
||
anchor=MSO_ANCHOR.TOP, line_spacing=1.0, space_after=2, wrap=True):
|
||
tb = s.shapes.add_textbox(inch(x), inch(y), inch(w), inch(h))
|
||
tf = tb.text_frame; tf.word_wrap = wrap; tf.vertical_anchor = anchor
|
||
tf.margin_left = 0; tf.margin_right = 0; tf.margin_top = 0; tf.margin_bottom = 0
|
||
if isinstance(content, str): content = [content]
|
||
for i, para in enumerate(content):
|
||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||
p.alignment = align; p.line_spacing = line_spacing
|
||
p.space_after = Pt(space_after); p.space_before = Pt(0)
|
||
segs = [{"t": para}] if isinstance(para, str) else ([para] if isinstance(para, dict) else para)
|
||
for seg in segs:
|
||
r = p.add_run(); r.text = seg.get("t", "")
|
||
r.font.name = FONT; r.font.size = Pt(seg.get("size", size))
|
||
r.font.bold = seg.get("bold", bold); r.font.color.rgb = seg.get("color", color)
|
||
rPr = r._r.get_or_add_rPr()
|
||
rPr.append(rPr.makeelement(qn('a:ea'), {'typeface': FONT}))
|
||
return tb
|
||
|
||
def page_bg(s): rect(s, -0.1, -0.1, 13.53, 7.7, fill=BG)
|
||
def chip(s, x, y, w, h, label, fill, txtcolor=WHITE, size=9.5, bold=True):
|
||
rect(s, x, y, w, h, fill=fill, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, x, y, w, h, label, size=size, color=txtcolor, bold=bold, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||
def card(s, x, y, w, h, fill=CARD, line=LINE):
|
||
return rect(s, x, y, w, h, fill=fill, line=line, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE, shadow=True)
|
||
|
||
def header(s, kicker, title, idx):
|
||
page_bg(s)
|
||
rect(s, 0, 0, 13.333, 0.12, fill=BLUE)
|
||
rect(s, 0.55, 0.5, 0.62, 0.62, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, 0.55, 0.5, 0.62, 0.62, f"{idx:02d}", size=20, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||
text(s, 1.32, 0.5, 10.5, 0.28, kicker, size=11, color=PURPLE, bold=True)
|
||
text(s, 1.32, 0.74, 11.4, 0.44, title, size=22, color=INK, bold=True)
|
||
rect(s, 0.55, 1.28, 12.23, 0.02, fill=LINE)
|
||
text(s, 0.55, 7.06, 9, 0.3, "킨텍스 자동전시시스템 구축 · 데이터베이스 설계서", size=8.5, color=MUTED)
|
||
text(s, 11.3, 7.06, 1.48, 0.3, "2026-07-11", size=8.5, color=MUTED, align=PP_ALIGN.RIGHT)
|
||
|
||
def bullets(s, x, y, w, items, size=12.5, gap=0.44, head=None):
|
||
if head:
|
||
text(s, x, y, w, 0.32, head, size=12, color=PURPLE, bold=True); y += 0.42
|
||
for it in items:
|
||
rect(s, x, y+0.07, 0.12, 0.12, fill=BLUE, shape=MSO_SHAPE.OVAL)
|
||
if isinstance(it, tuple):
|
||
text(s, x+0.28, y, w-0.28, gap, [[{"t": it[0]+" ", "bold":True, "color":INK, "size":size},
|
||
{"t": it[1], "color":MUTED, "size":size-1}]], line_spacing=1.05)
|
||
else:
|
||
text(s, x+0.28, y, w-0.28, gap, it, size=size, color=INK, line_spacing=1.05)
|
||
y += gap
|
||
return y
|
||
|
||
# ---- ERD 엔티티 박스 (파싱 데이터 구동) ----
|
||
def erd_entity(s, x, y, w, tname, maxrows=6, accent=BLUE):
|
||
t = TABLES[tname]
|
||
# 표시 컬럼: PK → FK → 대표 컬럼
|
||
pk = [c for c in t["cols"] if c["pk"]]
|
||
fk = [c for c in t["cols"] if c["fk"] and not c["pk"]]
|
||
rest = [c for c in t["cols"] if not c["pk"] and not c["fk"]]
|
||
show = (pk + fk + rest)[:maxrows]
|
||
rh = 0.26
|
||
h = 0.5 + len(show)*rh
|
||
rect(s, x, y, w, h, fill=CARD, line=LINE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE, shadow=True)
|
||
rect(s, x, y, w, 0.44, fill=accent, shape=MSO_SHAPE.ROUND_2_SAME_RECTANGLE)
|
||
text(s, x+0.12, y, w-0.2, 0.44, [[{"t": tname, "size":10.5, "color":WHITE, "bold":True},
|
||
{"t": " "+KO.get(tname, ""), "size":8, "color":RGBColor(0xD6,0xE6,0xF6)}]],
|
||
anchor=MSO_ANCHOR.MIDDLE)
|
||
cy = y + 0.5
|
||
for c in show:
|
||
key = "PK" if c["pk"] else ("FK" if c["fk"] else "")
|
||
kc = PURPLE if c["pk"] else (TEAL if c["fk"] else MUTED)
|
||
geo = c["type"].lower().startswith("geometry")
|
||
text(s, x+0.12, cy-0.03, 0.42, rh, key, size=7.5, color=kc, bold=True, anchor=MSO_ANCHOR.MIDDLE)
|
||
text(s, x+0.54, cy-0.03, w-0.62, rh,
|
||
[[{"t": c["name"], "size":8.6, "color":(INK if key else RGBColor(0x33,0x3B,0x4A)), "bold":bool(key)},
|
||
{"t": " : "+c["type"], "size":7.6, "color":(GREEN if geo else MUTED)}]],
|
||
anchor=MSO_ANCHOR.MIDDLE)
|
||
cy += rh
|
||
return {"x": x, "y": y, "w": w, "h": h, "cx": x+w/2, "cy": y+h/2}
|
||
|
||
def connect(s, a, b, color=RGBColor(0x9A,0xA6,0xB8)):
|
||
cn = s.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, inch(a["cx"]), inch(a["cy"]),
|
||
inch(b["cx"]), inch(b["cy"]))
|
||
cn.line.color.rgb = color; cn.line.width = Pt(1.4)
|
||
cn.shadow.inherit = False
|
||
return cn
|
||
|
||
# ================= 슬라이드 1 : 표지 =================
|
||
s = slide()
|
||
rect(s, -0.1, -0.1, 13.53, 7.7, fill=BLUE_DK)
|
||
rect(s, 6.4, -1.5, 9, 11, fill=BLUE, shape=MSO_SHAPE.PARALLELOGRAM)
|
||
rect(s, 9.2, -2.0, 7, 12, fill=PURPLE, shape=MSO_SHAPE.PARALLELOGRAM)
|
||
rect(s, 0.85, 0.8, 0.42, 0.42, fill=WHITE, shape=MSO_SHAPE.OVAL)
|
||
rect(s, 0.97, 0.92, 0.18, 0.18, fill=PURPLE, shape=MSO_SHAPE.OVAL)
|
||
text(s, 1.42, 0.78, 6, 0.46, "KINTEX · WISE AI", size=15, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
|
||
chip(s, 0.85, 2.35, 1.95, 0.42, "데이터베이스 설계서", PURPLE, WHITE, size=12)
|
||
chip(s, 2.92, 2.35, 2.2, 0.42, "Database Design", RGBColor(0x2B,0x4C,0x7E), WHITE, size=10.5)
|
||
text(s, 0.85, 2.95, 11, 1.9, [
|
||
[{"t":"킨텍스 자동전시시스템", "size":44, "color":WHITE, "bold":True}],
|
||
[{"t":"데이터베이스 설계서", "size":44, "color":WHITE, "bold":True}]], line_spacing=1.02)
|
||
text(s, 0.88, 4.7, 11, 0.5, "PostgreSQL 15 + PostGIS · Flyway 형상관리 · kintex 전용 DB",
|
||
size=15.5, color=RGBColor(0xC7,0xDD,0xF2))
|
||
text(s, 0.88, 5.24, 11.4, 0.4,
|
||
"공간데이터(부스 POLYGON · 트렌치 POINT · 배선 LINESTRING) 일원화 · 행사 단위 격리 · 감사추적",
|
||
size=12, color=RGBColor(0xB9,0xC7,0xEA))
|
||
rect(s, 0.85, 6.05, 11.63, 0.02, fill=RGBColor(0x3A,0x5A,0x8C))
|
||
text(s, 0.85, 6.25, 8.6, 0.4, [[{"t":"산출물 ","size":11,"color":RGBColor(0x90,0xA6,0xC9)},
|
||
{"t":f"개념·논리·물리 데이터 모델 / 테이블 {TOTAL_TABLES} · 컬럼 {TOTAL_COLS} · 인덱스 {TOTAL_IDX}","size":11,"color":WHITE,"bold":True}]])
|
||
text(s, 0.85, 6.62, 9.6, 0.4, [[{"t":"근거 ","size":11,"color":RGBColor(0x90,0xA6,0xC9)},
|
||
{"t":"Flyway V1~V10 실제 DDL 역추출 (스키마 권위)","size":10.5,"color":WHITE}]])
|
||
text(s, 9.7, 6.25, 2.78, 0.4, "작성일 2026-07-11", size=11, color=WHITE, bold=True, align=PP_ALIGN.RIGHT)
|
||
text(s, 9.7, 6.62, 2.78, 0.4, "DA · kintex-da", size=9.5, color=RGBColor(0x90,0xA6,0xC9), align=PP_ALIGN.RIGHT)
|
||
|
||
# ================= 슬라이드 2 : 목차 =================
|
||
s = slide()
|
||
header(s, "CONTENTS", "목차", 0)
|
||
rect(s, 0.55, 0.5, 0.62, 0.62, fill=PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, 0.55, 0.5, 0.62, 0.62, "C", size=22, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||
toc = [
|
||
("01","개요","PostgreSQL+PostGIS · Flyway · kintex 전용 DB"),
|
||
("02","데이터 아키텍처 원칙","행사 단위 격리 · 공간타입 선정 · 감사로그"),
|
||
("03","개념 데이터 모델","핵심 주제영역 관계 개요도"),
|
||
("04","논리 모델(ERD)","주제영역별 엔터티·관계 ERD"),
|
||
("05","물리 모델 요약","테이블 그룹 · 공간컬럼 · 인덱스 전략"),
|
||
("06","데이터 표준","명명 규칙 · 공통코드 · 날짜/통화"),
|
||
("07","데이터 보안","암호화·PII·응답제외·감사추적"),
|
||
("08","백업·이관 전략","Flyway 마이그레이션 운영 원칙"),
|
||
]
|
||
cw = 5.85
|
||
for i,(no,t,d) in enumerate(toc):
|
||
col = i // 4; row = i % 4
|
||
x = 0.7 + col*(cw+0.35); y = 1.62 + row*1.28
|
||
rect(s, x, y, cw, 1.1, fill=CARD, line=LINE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
rect(s, x, y, 0.09, 1.1, fill=BLUE if col==0 else PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, x+0.28, y, 1.0, 1.1, no, size=26, color=(BLUE if col==0 else PURPLE), bold=True, anchor=MSO_ANCHOR.MIDDLE)
|
||
text(s, x+1.3, y+0.24, cw-1.5, 0.4, t, size=15, color=INK, bold=True)
|
||
text(s, x+1.3, y+0.64, cw-1.5, 0.34, d, size=9.5, color=MUTED)
|
||
|
||
# ================= 슬라이드 3 : 01 개요 =================
|
||
s = slide()
|
||
header(s, "OVERVIEW", "01. 개요 — DBMS · 형상관리 · DB 구성", 1)
|
||
text(s, 0.55, 1.45, 12.2, 0.5,
|
||
"킨텍스 자동전시시스템은 전시 생애주기(판매→AI 설계·시각화→공사 옥션→운영→경영분석) 전 과정을 단일 DB에 담는다. "
|
||
"공간데이터를 애플리케이션과 분리하지 않고 PostGIS로 DB에 일원화하여 배치·배선·규정검증을 ST_* 연산으로 수행한다.",
|
||
size=12, color=INK, line_spacing=1.12)
|
||
# 좌: 스택 카드
|
||
cards = [
|
||
("DBMS", "PostgreSQL 15", "관계형 + 트랜잭션 정합", BLUE),
|
||
("공간확장", "PostGIS", "POLYGON·POINT·LINESTRING 지오메트리", GREEN),
|
||
("암호화", "pgcrypto", "gen_random_uuid() · 해시", PURPLE),
|
||
("형상관리", "Flyway", "V1~V10 버전 마이그레이션(불변·순증)", AMBER),
|
||
]
|
||
for i,(k,v,d,c) in enumerate(cards):
|
||
x = 0.55 + i*3.06
|
||
card(s, x, 2.2, 2.86, 1.35)
|
||
rect(s, x, 2.2, 2.86, 0.1, fill=c, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, x+0.22, 2.4, 2.5, 0.3, k, size=10, color=c, bold=True)
|
||
text(s, x+0.22, 2.72, 2.5, 0.4, v, size=15, color=INK, bold=True)
|
||
text(s, x+0.22, 3.16, 2.5, 0.34, d, size=9, color=MUTED, line_spacing=1.05)
|
||
# 통계 스트립
|
||
stats = [(f"{TOTAL_TABLES}", "테이블"), (f"{TOTAL_COLS}", "컬럼"), (f"{len(AREAS)}", "주제영역"),
|
||
(f"{len(SPATIAL_COLS)}", "공간 컬럼"), (f"{TOTAL_IDX}", "인덱스"), ("V1–V10", "Flyway")]
|
||
for i,(v,l) in enumerate(stats):
|
||
x = 0.55 + i*2.03
|
||
rect(s, x, 3.85, 1.9, 1.0, fill=BLUE_LT, line=LINE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, x, 4.0, 1.9, 0.44, v, size=23, color=BLUE_DK, bold=True, align=PP_ALIGN.CENTER)
|
||
text(s, x, 4.5, 1.9, 0.3, l, size=10, color=MUTED, align=PP_ALIGN.CENTER)
|
||
# DB 구성 원칙
|
||
bullets(s, 0.55, 5.15, 12.2, [
|
||
("kintex 전용 DB", "GUARDiA 타 솔루션과 물리 DB 분리(kintex 전용 스키마). 접속·권한은 서비스 계정 최소권한."),
|
||
("공간·업무 단일 저장", "부스 폴리곤·배선 라인·요율·관람 데이터를 한 트랜잭션 경계에서 일관 관리."),
|
||
("멱등·순증 마이그레이션", "V7 이후는 CREATE IF NOT EXISTS·ADD COLUMN IF NOT EXISTS로 재적용 안전(운영 무중단)."),
|
||
], size=11.5, gap=0.5)
|
||
|
||
# ================= 슬라이드 4 : 02 아키텍처 원칙 =================
|
||
s = slide()
|
||
header(s, "PRINCIPLES", "02. 데이터 아키텍처 원칙", 2)
|
||
princ = [
|
||
("행사 단위 격리(멀티테넌시)", BLUE,
|
||
["물리 tenant_id 컬럼 대신 event_id + event_member(RBAC)로 논리 격리",
|
||
"핵심 트랜잭션 테이블은 event_id FK·ON DELETE CASCADE로 행사 경계 정합",
|
||
"hall·company·master_data 등 마스터는 전 행사 공유(참조 무결성)"]),
|
||
("공간데이터 PostGIS 일원화", GREEN,
|
||
["부스=POLYGON, 트렌치·비상구=POINT, 배선=MultiLineString (SRID 0 홀 로컬 m)",
|
||
"GiST 공간 인덱스로 ST_Intersects/Area/Distance 고속 연산",
|
||
"배치·배선 재저장 대비 booth_id 소프트 참조(감사·쿼터 보존)"]),
|
||
("감사 추적·이력 보존", PURPLE,
|
||
["audit_log에 actor·action·target·ruleset_version·result 기록",
|
||
"render_job은 이력·쿼터 정본(부스 교체와 무관하게 감사 보존)",
|
||
"layout·design_plan·booth_standard 버전(version) 관리"]),
|
||
("보안 내재화", AMBER,
|
||
["password_hash·otp_secret·code_hash 컬럼은 API 응답/로그 완전 제외",
|
||
"민감 시크릿 설정은 sys_setting.secret_yn='Y'로 마스킹",
|
||
"error_message는 사용자 요약만(스택트레이스 저장 금지)"]),
|
||
]
|
||
for i,(title,c,items) in enumerate(princ):
|
||
x = 0.55 + (i%2)*6.15; y = 1.5 + (i//2)*2.6
|
||
card(s, x, y, 5.95, 2.4)
|
||
rect(s, x, y, 0.11, 2.4, fill=c, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, x+0.35, y+0.2, 5.4, 0.4, title, size=14, color=INK, bold=True)
|
||
yy = y+0.72
|
||
for it in items:
|
||
rect(s, x+0.4, yy+0.06, 0.1, 0.1, fill=c, shape=MSO_SHAPE.OVAL)
|
||
text(s, x+0.62, yy, 5.15, 0.5, it, size=10.3, color=MUTED, line_spacing=1.05)
|
||
yy += 0.52
|
||
|
||
# ================= 슬라이드 5 : 03 개념 데이터 모델 =================
|
||
s = slide()
|
||
header(s, "CONCEPTUAL", "03. 개념 데이터 모델 — 주제영역 관계 개요", 3)
|
||
text(s, 0.55, 1.42, 12.2, 0.4,
|
||
"구현된 4개 주제영역과 소유 데이터의 관계. 행사(EVENT)를 축으로 공간·설계 코어와 공통·시스템 레이어가 결합한다.",
|
||
size=11.5, color=MUTED)
|
||
# 중앙 EVENT 허브
|
||
hub = {"cx":6.6, "cy":3.9}
|
||
rect(s, 5.75, 3.3, 1.7, 1.2, fill=BLUE_DK, shape=MSO_SHAPE.ROUNDED_RECTANGLE, shadow=True)
|
||
text(s, 5.75, 3.3, 1.7, 1.2, [[{"t":"EVENT","size":15,"color":WHITE,"bold":True}],
|
||
[{"t":"행사(테넌트 축)","size":9,"color":RGBColor(0xC7,0xDD,0xF2)}]], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||
sa = [
|
||
("MASTER","행사·조직·권한·마스터","event · hall · company · event_member",8,BLUE, 0.7, 1.95),
|
||
("SPATIAL","공간·설계·시각화","layout · booth · utility_order · render_job",7,GREEN, 9.35, 1.95),
|
||
("COMMON","공통 업무 모듈","worklog · schedule · notice · meeting …",11,PURPLE, 0.7, 4.75),
|
||
("SYSTEM","시스템관리·인증","sys_* · common_code · audit_log · password_reset",9,AMBER, 9.35, 4.75),
|
||
]
|
||
for code,ko,ex,n,c,x,y in sa:
|
||
# 연결선 (먼저)
|
||
box_cx = x+1.6; box_cy = y+0.75
|
||
cn = s.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, inch(box_cx), inch(box_cy), inch(hub["cx"]), inch(hub["cy"]))
|
||
cn.line.color.rgb = RGBColor(0xC2,0xCC,0xDA); cn.line.width = Pt(1.6); cn.shadow.inherit=False
|
||
for code,ko,ex,n,c,x,y in sa:
|
||
card(s, x, y, 3.2, 1.5)
|
||
rect(s, x, y, 3.2, 0.12, fill=c, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, x+0.24, y+0.24, 2.7, 0.34, ko, size=13, color=INK, bold=True)
|
||
chip(s, x+2.45, y+0.24, 0.62, 0.3, f"{n}T", c, WHITE, size=9)
|
||
text(s, x+0.24, y+0.68, 2.9, 0.7, ex, size=9.3, color=MUTED, line_spacing=1.08)
|
||
text(s, 0.55, 6.5, 12.2, 0.4,
|
||
"관계 요지: EVENT 1─N HallAssignment/EventMember/Layout/Notice/Worklog(event_id). Layout 1─N Booth(POLYGON) 1─N DesignPlan·UtilityOrder(배선)·RenderJob. Company·User ─ EventMember로 행사 RBAC 성립.",
|
||
size=9.8, color=INK, line_spacing=1.1)
|
||
|
||
# ================= 슬라이드 6~9 : 04 논리 모델 ERD (주제영역별) =================
|
||
def erd_slide(area_ko, idx, placements, extra_note, cross_notes=None):
|
||
s = slide()
|
||
header(s, "LOGICAL ERD", f"04. 논리 모델 — {area_ko}", 4)
|
||
# 연결선 먼저(엔티티 아래로 깔림)
|
||
boxes = {}
|
||
# 1차: 좌표만 계산해 중심 확보
|
||
for tn, x, y, w in placements:
|
||
t = TABLES[tn]
|
||
pk=[c for c in t["cols"] if c["pk"]]; fk=[c for c in t["cols"] if c["fk"] and not c["pk"]]
|
||
rest=[c for c in t["cols"] if not c["pk"] and not c["fk"]]
|
||
show=(pk+fk+rest); show=show[:6]
|
||
h = 0.5+len(show)*0.26
|
||
boxes[tn]={"x":x,"y":y,"w":w,"h":h,"cx":x+w/2,"cy":y+h/2}
|
||
for tn,_,_,_ in placements:
|
||
for c in TABLES[tn]["cols"]:
|
||
if c["fk"] and c["fk"] in boxes and c["fk"]!=tn:
|
||
connect(s, boxes[tn], boxes[c["fk"]])
|
||
accents={"MASTER":BLUE,"SPATIAL":GREEN,"SYSTEM":AMBER,"COMMON":PURPLE}
|
||
acc = accents.get(idx, BLUE)
|
||
for tn,x,y,w in placements:
|
||
erd_entity(s, x, y, w, tn, accent=acc)
|
||
if extra_note:
|
||
text(s, 0.55, 6.78, 12.2, 0.3, extra_note, size=9, color=MUTED, line_spacing=1.05)
|
||
return s
|
||
|
||
# ERD-1 MASTER
|
||
erd_slide("행사·조직·권한·마스터", "MASTER", [
|
||
("event", 5.6, 1.5, 2.5),
|
||
("hall", 9.9, 1.5, 2.8),
|
||
("hall_assignment", 9.9, 4.05, 2.8),
|
||
("event_member", 5.6, 3.7, 2.85),
|
||
("app_user", 0.6, 1.5, 2.75),
|
||
("company", 0.6, 4.55, 2.75),
|
||
], "관계: event 1─N hall_assignment·event_member. app_user·company ─ event_member(행사 RBAC). 독립 마스터 booth_standard·master_data 는 물리 모델 목록(슬라이드 10) 참조.")
|
||
|
||
# ERD-2 SPATIAL
|
||
erd_slide("공간·설계·시각화(부스 코어)", "SPATIAL", [
|
||
("hall", 0.6, 1.5, 2.5),
|
||
("trench", 0.6, 4.2, 2.5),
|
||
("hall_exit", 3.45, 4.2, 2.4),
|
||
("layout", 3.6, 1.5, 2.5),
|
||
("booth", 6.55, 1.5, 2.6),
|
||
("design_plan", 9.65, 1.5, 2.7),
|
||
("utility_order", 6.55, 4.35,2.9),
|
||
("render_job", 9.85, 4.35,2.75),
|
||
], "관계: hall 1─N trench·hall_exit·layout. layout 1─N booth(POLYGON) 1─N design_plan. booth ─ utility_order(배선 LineString)·render_job(AI 샷). booth_id 소프트 참조.")
|
||
|
||
# ERD-3 SYSTEM
|
||
erd_slide("시스템관리·인증", "SYSTEM", [
|
||
("common_code_group", 0.6, 1.5, 2.7),
|
||
("common_code", 0.6, 4.0, 2.7),
|
||
("sys_role", 3.6, 1.5, 2.6),
|
||
("sys_role_permission",3.6,4.0, 2.75),
|
||
("sys_permission", 6.55, 1.5, 2.6),
|
||
("sys_menu", 9.6, 1.5, 2.8),
|
||
("sys_setting", 6.55, 4.05, 2.6),
|
||
("audit_log", 9.6, 4.05, 2.8),
|
||
], "관계: common_code_group 1─N common_code. sys_role ─ sys_role_permission ─ sys_permission(M:N). sys_menu 자기참조(parent_id). password_reset(공개 인증)은 별도. audit_log 전 모듈 공유.")
|
||
|
||
# ERD-4 COMMON
|
||
s = slide()
|
||
header(s, "LOGICAL ERD", "04. 논리 모델 — 공통 업무 모듈", 4)
|
||
# 부모-자식 3쌍 + 독립 목록
|
||
pairs = [("message","message_recipient"),("opinion","opinion_comment"),("meeting","meeting_action")]
|
||
px = 0.6
|
||
for i,(p,ch) in enumerate(pairs):
|
||
x = 0.6 + i*4.15
|
||
bp = erd_entity(s, x, 1.5, 3.0, p, accent=PURPLE)
|
||
bc = erd_entity(s, x+0.3, 3.9, 3.0, ch, accent=RGBColor(0x8B,0x6B,0xE0))
|
||
# 연결(부모→자식) 먼저 그리지 못했으니 라벨로 대체
|
||
text(s, 0.6, 6.05, 11.9, 0.3,
|
||
"부모 1─N 자식(FK·ON DELETE CASCADE): message→message_recipient · opinion→opinion_comment · meeting→meeting_action",
|
||
size=9.5, color=TEAL, bold=True)
|
||
# 독립 테이블 스트립
|
||
indep = ["worklog","schedule","notice","report","notification"]
|
||
text(s, 0.6, 6.42, 3, 0.3, "행사 스코프 독립 테이블", size=10, color=MUTED, bold=True)
|
||
for i,tn in enumerate(indep):
|
||
x = 3.0 + i*1.85
|
||
rect(s, x, 6.4, 1.75, 0.4, fill=BLUE_LT, line=LINE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, x, 6.4, 1.75, 0.4, tn, size=9.5, color=BLUE_DK, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||
|
||
# ================= 슬라이드 10 : 05 물리 모델 요약 (테이블 그룹) =================
|
||
s = slide()
|
||
header(s, "PHYSICAL", "05. 물리 모델 요약 — 테이블 그룹", 5)
|
||
col_x = [0.55, 6.7]
|
||
for gi,(code,ko,tlist) in enumerate(AREAS):
|
||
x = col_x[gi%2]; y = 1.5 + (gi//2)*2.65
|
||
card(s, x, y, 5.9, 2.45)
|
||
acc = {"MASTER":BLUE,"SPATIAL":GREEN,"SYSTEM":AMBER,"COMMON":PURPLE}[code]
|
||
rect(s, x, y, 5.9, 0.5, fill=acc, shape=MSO_SHAPE.ROUND_2_SAME_RECTANGLE)
|
||
text(s, x+0.25, y, 4.8, 0.5, ko, size=13, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
|
||
text(s, x+5.0, y, 0.7, 0.5, f"{len(tlist)}T", size=13, color=WHITE, bold=True, align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE)
|
||
yy = y+0.62
|
||
for j,tn in enumerate(tlist):
|
||
cx = x+0.25 + (j%2)*2.9
|
||
cyy = yy + (j//2)*0.315
|
||
text(s, cx, cyy, 2.85, 0.3, [[{"t":"· ","color":acc,"bold":True,"size":10},
|
||
{"t":tn,"size":9.3,"color":INK,"bold":True},
|
||
{"t":" "+KO.get(tn,""),"size":7.8,"color":MUTED}]], anchor=MSO_ANCHOR.MIDDLE)
|
||
|
||
# ================= 슬라이드 11 : 05b 공간 컬럼·인덱스 전략 =================
|
||
s = slide()
|
||
header(s, "PHYSICAL — SPATIAL & INDEX", "05. 공간 컬럼 · 인덱스 전략", 5)
|
||
# 공간 컬럼 표
|
||
text(s, 0.55, 1.45, 6, 0.32, "공간(PostGIS) 컬럼", size=13, color=GREEN, bold=True)
|
||
sp_rows = [("테이블","컬럼","지오메트리","용도")]
|
||
sp_meta = {
|
||
("trench","geom"):"급전/급수/네트워크 공급 포인트",
|
||
("hall_exit","geom"):"비상구 위치(이격 버퍼 검증)",
|
||
("booth","geom"):"부스 경계 폴리곤(면적·간섭)",
|
||
("utility_order","wiring"):"전기/네트워크/급배수 배선 경로",
|
||
}
|
||
for tn,cn,ty in SPATIAL_COLS:
|
||
sp_rows.append((tn, cn, ty, sp_meta.get((tn,cn),"")))
|
||
ry = 1.85
|
||
for i,row in enumerate(sp_rows):
|
||
fill = BLUE if i==0 else (WHITE if i%2 else BLUE_LT)
|
||
tc = WHITE if i==0 else INK
|
||
rect(s, 0.55, ry, 6.1, 0.4, fill=fill, line=LINE)
|
||
xs=[0.65,2.05,3.35,4.75]; ws=[1.35,1.25,1.4,1.85]
|
||
for k,val in enumerate(row):
|
||
text(s, xs[k], ry, ws[k], 0.4, val, size=8.6, color=(tc if k<3 else (tc if i==0 else MUTED)),
|
||
bold=(i==0 or k==0), anchor=MSO_ANCHOR.MIDDLE)
|
||
ry += 0.4
|
||
text(s, 0.55, ry+0.1, 6.1, 0.6,
|
||
"SRID 0 = 홀 로컬 평면(미터). ST_Area/Distance/Length 결과 단위 = m·㎡.",
|
||
size=9, color=MUTED, line_spacing=1.1)
|
||
# 인덱스 전략
|
||
text(s, 6.95, 1.45, 5.8, 0.32, "인덱스 전략", size=13, color=BLUE, bold=True)
|
||
idx_items = [
|
||
("GiST 공간 인덱스", f"geometry 컬럼 4종(booth·trench·hall_exit·utility_order.wiring) 전부 GiST"),
|
||
("B-Tree 조회 인덱스", "FK·정렬 축: writer_id+work_date, recipient_id, event_id 등"),
|
||
("복합 인덱스", "render_job(booth_id,shot_preset) · (event_id,status) 쿼터·상태 조회"),
|
||
("정렬 최적화", "audit_log(created_at DESC) · notice(event_id,pinned DESC,created_at DESC)"),
|
||
(f"총 인덱스 {TOTAL_IDX}개", "PK 자동 인덱스 별도 · 공간 4 · B-Tree 다수"),
|
||
]
|
||
yy = 1.9
|
||
for k,v in idx_items:
|
||
card(s, 6.95, yy, 5.75, 0.78)
|
||
text(s, 7.15, yy+0.12, 5.4, 0.3, k, size=11, color=INK, bold=True)
|
||
text(s, 7.15, yy+0.44, 5.4, 0.3, v, size=8.8, color=MUTED, line_spacing=1.05)
|
||
yy += 0.9
|
||
|
||
# ================= 슬라이드 12 : 06 데이터 표준 =================
|
||
s = slide()
|
||
header(s, "STANDARDS", "06. 데이터 표준 — 명명 · 코드 · 날짜/통화", 6)
|
||
std = [
|
||
("명명 규칙(실제 DDL 관찰)", BLUE, [
|
||
"테이블·컬럼 = snake_case 단수형 (app_user, booth, render_job)",
|
||
"PK = id (varchar 애플리케이션 채번) / 대량 로그 = bigserial (audit_log·password_reset)",
|
||
"FK = <참조테이블>_id (event_id, booth_id, hall_id, layout_id)",
|
||
"불리언 = is_/has_/*_yn 혼용 · 금액/치수 = numeric(정밀도,스케일)",
|
||
"타임스탬프 = *_at (timestamptz) / 날짜 = *_date (date)",
|
||
]),
|
||
("공통코드 체계(common_code)", PURPLE, [
|
||
"grp_code(그룹) 1─N code — 14개 그룹 시드(USE_YN·USER_ROLE·WORK_STATUS …)",
|
||
"화면 드롭다운·상태값을 코드로 표준화 (하드코딩 배제)",
|
||
"정렬 sort_order · 사용여부 use_yn(Y/N) · 확장 attr1",
|
||
]),
|
||
("날짜·통화·단위 표준", GREEN, [
|
||
"일시 = timestamptz(UTC 저장, 표시 KST) · 날짜 = date",
|
||
"통화 = KRW 정수(원) 원칙, 요율/정산은 numeric — 부동소수 금지",
|
||
"치수 = 미터(m)·면적 ㎡ · 하중 t/㎡ (numeric 고정 스케일)",
|
||
]),
|
||
]
|
||
yy = 1.5
|
||
for title,c,items in std:
|
||
h = 0.5 + len(items)*0.34 + 0.15
|
||
card(s, 0.55, yy, 12.2, h)
|
||
rect(s, 0.55, yy, 0.11, h, fill=c, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, 0.85, yy+0.16, 11.6, 0.34, title, size=13.5, color=INK, bold=True)
|
||
iy = yy+0.6
|
||
for it in items:
|
||
rect(s, 0.95, iy+0.06, 0.09, 0.09, fill=c, shape=MSO_SHAPE.OVAL)
|
||
text(s, 1.14, iy, 11.4, 0.32, it, size=10.3, color=MUTED)
|
||
iy += 0.34
|
||
yy += h + 0.16
|
||
|
||
# ================= 슬라이드 13 : 07 데이터 보안 =================
|
||
s = slide()
|
||
header(s, "SECURITY", "07. 데이터 보안 — 암호화 · PII · 응답제외 · 감사", 7)
|
||
# 응답 제외/암호화 컬럼 표
|
||
text(s, 0.55, 1.45, 7, 0.32, "민감 컬럼 처리 정책", size=13, color=BLUE, bold=True)
|
||
sec_rows = [("테이블.컬럼","정책")]
|
||
sec_rows += [
|
||
("app_user.password_hash","BCrypt 해시 · API 응답/로그 완전 제외"),
|
||
("app_user.otp_secret","TOTP 시크릿 · AES-256-GCM 암호화 저장 · 응답 제외"),
|
||
("password_reset.code_hash","BCrypt(6자리) · 만료 10분·1회성 · 응답/로그 제외"),
|
||
("sys_setting.secret_yn='Y'","시크릿 설정값 마스킹 반환"),
|
||
("render_job.error_message","사용자 요약만 · 스택트레이스 저장 금지"),
|
||
("booth.assigned_company_name","표시용(내부 식별자·민감정보 미포함)"),
|
||
]
|
||
ry = 1.85
|
||
for i,(a,b) in enumerate(sec_rows):
|
||
fill = BLUE if i==0 else (WHITE if i%2 else BLUE_LT)
|
||
tc = WHITE if i==0 else INK
|
||
rect(s, 0.55, ry, 6.5, 0.52, fill=fill, line=LINE)
|
||
text(s, 0.68, ry, 2.75, 0.52, a, size=8.4, color=tc, bold=(i==0), anchor=MSO_ANCHOR.MIDDLE)
|
||
text(s, 3.5, ry, 3.45, 0.52, b, size=8.3, color=(tc if i==0 else MUTED), bold=(i==0),
|
||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.0)
|
||
ry += 0.52
|
||
# 우측 원칙 카드
|
||
text(s, 7.3, 1.45, 5.4, 0.32, "보안 불변 원칙", size=13, color=PURPLE, bold=True)
|
||
sec_cards = [
|
||
("PII 최소화·격리", "관람/리드 PII 집중영역 접근 제한 · BI 마트는 집계/익명키만 반입(개인식별자 0)"),
|
||
("암호화 저장", "비밀번호·OTP·재설정코드는 해시/AES-256-GCM · 평문 저장 금지"),
|
||
("API 응답 스키마 통제", "ServerOut류 민감 컬럼 응답 완전 제외 · 스택트레이스 미노출"),
|
||
("감사 추적", "audit_log(actor·action·target·ruleset_version·result·ip_hint) 전 변경 기록"),
|
||
("행사 격리 접근제어", "event_member 역할(ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER) 스코프"),
|
||
]
|
||
yy = 1.85
|
||
for k,v in sec_cards:
|
||
card(s, 7.3, yy, 5.45, 0.92)
|
||
rect(s, 7.3, yy, 0.09, 0.92, fill=PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, 7.55, yy+0.13, 5.1, 0.3, k, size=11, color=INK, bold=True)
|
||
text(s, 7.55, yy+0.45, 5.1, 0.42, v, size=8.7, color=MUTED, line_spacing=1.05)
|
||
yy += 1.02
|
||
|
||
# ================= 슬라이드 14 : 08 백업·이관 전략 =================
|
||
s = slide()
|
||
header(s, "BACKUP & MIGRATION", "08. 백업 · 이관 전략 (Flyway 형상관리)", 8)
|
||
mig = [
|
||
("Flyway 버전 마이그레이션", BLUE, [
|
||
"V1 확장 → V2 신원·마스터 → V3 공간코어 → V4~V6 시드 → V7 시스템관리 → V8 공통업무 → V9 공개인증 → V10 10년 시드",
|
||
"적용 이력은 flyway_schema_history 테이블에 체크섬으로 기록·검증",
|
||
"V1~V6 불변 원칙 · V7 이후 순증(additive)·멱등만 수행(운영 무중단)",
|
||
]),
|
||
("백업 전략", GREEN, [
|
||
"일 1회 논리 백업(pg_dump) + WAL 아카이브 기반 PITR(시점복구)",
|
||
"PostGIS 지오메트리·공간 인덱스 포함 전체 스키마 백업",
|
||
"오브젝트 스토리지(렌더 이미지·도면)는 서명·만료 URL로 별도 보존",
|
||
]),
|
||
("이관·복구 절차", PURPLE, [
|
||
"복구 = 최신 백업 복원 → Flyway migrate로 최신 버전까지 재적용(정본 순서 보장)",
|
||
"환경 승격(dev→운영): 동일 마이그레이션 스크립트 적용으로 스키마 동형 보장",
|
||
"시드(V4~V6·V10)와 스키마(V1~V3·V7~V9) 분리로 데이터 오염 없이 재구축",
|
||
]),
|
||
]
|
||
yy = 1.5
|
||
for title,c,items in mig:
|
||
h = 0.5 + len(items)*0.4 + 0.1
|
||
card(s, 0.55, yy, 12.2, h)
|
||
rect(s, 0.55, yy, 0.11, h, fill=c, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
|
||
text(s, 0.85, yy+0.16, 11.6, 0.34, title, size=13.5, color=INK, bold=True)
|
||
iy = yy+0.62
|
||
for it in items:
|
||
rect(s, 0.95, iy+0.07, 0.09, 0.09, fill=c, shape=MSO_SHAPE.OVAL)
|
||
text(s, 1.14, iy, 11.4, 0.38, it, size=10.2, color=MUTED, line_spacing=1.08)
|
||
iy += 0.4
|
||
yy += h + 0.16
|
||
text(s, 0.55, 6.75, 12.2, 0.3,
|
||
"보안 불변: 본 설계서·백업 매뉴얼에 비밀번호·API키·서버 IP·SSH 접속정보를 기재하지 않는다.",
|
||
size=9, color=MUTED, bold=True)
|
||
|
||
# ---- 저장 ----
|
||
PPTX_PATH = os.path.join(OUT_DIR, "데이터베이스설계서.pptx")
|
||
prs.save(PPTX_PATH)
|
||
N_SLIDES = len(prs.slides._sldIdLst)
|
||
|
||
# ======================================================================
|
||
# 3. XLSX — 테이블정의서
|
||
# ======================================================================
|
||
import openpyxl
|
||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||
from openpyxl.utils import get_column_letter
|
||
|
||
BLUE_HEX = "0066B3"; BLUEDK_HEX = "00447A"; BAND_HEX = "E1EFF9"; ROW_HEX = "F4F8FC"
|
||
thin = Side(style="thin", color="D0D7E2")
|
||
BORDER = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||
HFONT = Font(name="맑은 고딕", size=10, bold=True, color="FFFFFF")
|
||
BFONT = Font(name="맑은 고딕", size=10)
|
||
BOLD = Font(name="맑은 고딕", size=10, bold=True, color="101828")
|
||
TITLE_FONT = Font(name="맑은 고딕", size=11, bold=True, color="FFFFFF")
|
||
HEAD_FILL = PatternFill("solid", fgColor=BLUE_HEX)
|
||
BAND_FILL = PatternFill("solid", fgColor=BLUEDK_HEX)
|
||
ALT_FILL = PatternFill("solid", fgColor=ROW_HEX)
|
||
CEN = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||
LEFT = Alignment(horizontal="left", vertical="center", wrap_text=True)
|
||
|
||
wb = openpyxl.Workbook()
|
||
|
||
# ---- 시트1: 테이블목록 ----
|
||
ws = wb.active; ws.title = "테이블목록"
|
||
ws.sheet_view.showGridLines = False
|
||
cols1 = [("No",6),("테이블명",22),("한글명",24),("주제영역",24),("Flyway",10),("컬럼수",8),("비고",40)]
|
||
for j,(h,w) in enumerate(cols1, start=1):
|
||
c = ws.cell(1, j, h); c.font=HFONT; c.fill=HEAD_FILL; c.alignment=CEN; c.border=BORDER
|
||
ws.column_dimensions[get_column_letter(j)].width = w
|
||
ordered = sorted(TABLES.items(), key=lambda kv: kv[1]["order"])
|
||
for i,(tn,t) in enumerate(ordered, start=1):
|
||
note = []
|
||
if any(c["type"].lower().startswith("geometry") for c in t["cols"]): note.append("PostGIS 공간")
|
||
if t["pk"] and len(t["pk"])>1: note.append("복합 PK("+",".join(sorted(t["pk"]))+")")
|
||
if t["uniques"]: note.append("UNIQUE("+"; ".join(t["uniques"])+")")
|
||
if any(c["name"] in ("password_hash","otp_secret","code_hash") for c in t["cols"]): note.append("민감:응답제외")
|
||
row = [i, tn, KO.get(tn,""), AREA_OF.get(tn,""), t["version"], len(t["cols"]), " · ".join(note)]
|
||
for j,val in enumerate(row, start=1):
|
||
c = ws.cell(i+1, j, val)
|
||
c.font = BOLD if j==2 else BFONT
|
||
c.alignment = CEN if j in (1,5,6) else LEFT
|
||
c.border = BORDER
|
||
if i%2==0: c.fill = ALT_FILL
|
||
ws.freeze_panes = "A2"
|
||
ws.auto_filter.ref = f"A1:G{len(ordered)+1}"
|
||
|
||
# ---- 주제영역별 시트 ----
|
||
DATA_COLS = [("No",5),("컬럼명",26),("데이터타입",20),("NULL",7),("키",13),("기본값",18),("설명",56)]
|
||
def key_str(c):
|
||
parts=[]
|
||
if c["pk"]: parts.append("PK")
|
||
if c["fk"]: parts.append(f"FK→{c['fk']}")
|
||
if c["unique"]: parts.append("U")
|
||
return " ".join(parts)
|
||
|
||
for code, ko, tlist in AREAS:
|
||
title = ko.split("(")[0].strip()
|
||
sname = {"MASTER":"마스터·조직·권한","SPATIAL":"공간·설계·시각화",
|
||
"SYSTEM":"시스템관리·인증","COMMON":"공통업무"}[code]
|
||
ws = wb.create_sheet(sname)
|
||
ws.sheet_view.showGridLines = False
|
||
for j,(h,w) in enumerate(DATA_COLS, start=1):
|
||
c = ws.cell(1, j, h); c.font=HFONT; c.fill=HEAD_FILL; c.alignment=CEN; c.border=BORDER
|
||
ws.column_dimensions[get_column_letter(j)].width = w
|
||
r = 2
|
||
ncol = len(DATA_COLS)
|
||
for tn in tlist:
|
||
t = TABLES[tn]
|
||
# 밴드 행(테이블 헤더)
|
||
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=ncol)
|
||
bc = ws.cell(r, 1, f"◆ {tn} — {KO.get(tn,'')} [{t['version']}]"
|
||
+ (" · 인덱스 "+", ".join(ix["name"] for ix in t["indexes"]) if t["indexes"] else ""))
|
||
bc.font = TITLE_FONT; bc.fill = BAND_FILL; bc.alignment = LEFT
|
||
for j in range(1, ncol+1):
|
||
ws.cell(r, j).border = BORDER
|
||
ws.cell(r, j).fill = BAND_FILL
|
||
r += 1
|
||
for k,c in enumerate(t["cols"], start=1):
|
||
vals = [k, c["name"], c["type"], c["null"], key_str(c), c["default"], c["desc"]]
|
||
for j,val in enumerate(vals, start=1):
|
||
cc = ws.cell(r, j, val)
|
||
cc.font = BOLD if (j==2 and c["pk"]) else BFONT
|
||
cc.alignment = CEN if j in (1,4) else LEFT
|
||
cc.border = BORDER
|
||
if k%2==0: cc.fill = ALT_FILL
|
||
if j==5 and c["fk"]:
|
||
cc.font = Font(name="맑은 고딕", size=9, color="0E7C86", bold=True)
|
||
r += 1
|
||
r += 1 # 테이블 간 공백
|
||
ws.freeze_panes = "A2"
|
||
|
||
XLSX_PATH = os.path.join(OUT_DIR, "테이블정의서.xlsx")
|
||
wb.save(XLSX_PATH)
|
||
|
||
print("=" * 60)
|
||
print(f"[PPTX] {PPTX_PATH}")
|
||
print(f" 슬라이드 {N_SLIDES}장")
|
||
print(f"[XLSX] {XLSX_PATH}")
|
||
print(f" 시트 {len(wb.sheetnames)} : {wb.sheetnames}")
|
||
print(f"[SCHEMA] 테이블 {TOTAL_TABLES} · 컬럼 {TOTAL_COLS} · 인덱스 {TOTAL_IDX} · 공간컬럼 {len(SPATIAL_COLS)} · FK {len(FK_EDGES)}")
|
||
print("=" * 60)
|