kintex/docs/deliverables/DA산출물/_gen/gen_da_deliverables.py
zio 6c9891c7e2 feat: mobile harness + design v2.1 (84 screens) + PLANNING v3.1 + deliverables + Stitch screens
- Harness: kintex-mobile-dev agent + kintex-mobile-orchestrator skill (WISE mobile ref, Stitch-first design rule, dual app targets B2B/B2C)
- design.md v2.1: full 84-screen inventory (web 51 / admin 10 / public 8 / mobile 15) with Stitch prompts incl. ticketing (SCR-P7/P8, M14/M15)
- PLANNING v3.1: unified account + split signup tracks (2FA required for staff, light signup/guest for visitors), one codebase / two app targets
- Deliverables: dev plan (21s), user/operator/developer guides (17/14/15s), program spec (44s, 65 programs, 8 flowcharts), DA (DB design 14s + table spec xlsx 35 tables/299 cols)
- Benchmark: ticketing-app-benchmark.md (7 apps) -> IMPLEMENTATION_BACKLOG Phase F (14 items)
- Stitch: 23 generated screens saved (mobile 10, admin 6, web core 5, ticket 2)
- mobile/: Expo scaffold (SDK 51, expo-router, secure store JWT)
- frontend: SCR-13~17 QA fixes, icons.tsx, kintexEvents, V10 seed migration
- ci/: KINTEX CI logo assets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:31:45 +09:00

855 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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}", "인덱스"), ("V1V10", "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)