70개 테이블 설계 (DBML · BASE 54 + SYSTEM VERSIONED 16)
// =====================================================================
// nestpay 데이터베이스 설계서 (schema.dbml)
// DB: MariaDB (버전 확정 대기 - LTS 기준) / 시간대: KST 단일 / 문자셋: utf8mb4
// ---------------------------------------------------------------------
// 설계 원칙 (요구사항 #29 원장 대원칙)
// 1. 원장(ledger_entries)·거래(transactions)는 append-only.
// 앱 DB 계정에 UPDATE/DELETE 권한 미부여 + 방어 트리거(routines.sql).
// 2. 모든 거래는 복식: 거래별 Σ차변 = Σ대변. 검증은 Java + 일 대사 배치.
// 3. 비즈니스 로직은 전부 Java. DB에는 선언적 제약(PK/FK/UNIQUE/CHECK),
// 파티셔닝, 시스템 버전 테이블, "로직 없는 방어 트리거"만 둔다.
// (유지보수 최우선 - 로직의 DB/앱 분산 금지)
// 4. 가변 마스터 테이블(users, merchants, cards, 정책류)은
// SYSTEM VERSIONING 적용(변경 이력 자동 보존) - 마이그레이션에서 지정.
// 5. 금액은 DECIMAL(15,0) KRW 정수. 시각은 DATETIME(KST).
// 6. 개인정보: *_enc = AES-256 암호문, *_hash = 검색/UNIQUE용 HMAC-SHA256.
// ---------------------------------------------------------------------
// 데이터 분류 정책 (필드 단위 기준 - 본 DBML이 개발 기준)
// [암호화 저장 *_enc (AES-256-GCM, 키는 KMS 상당 관리)]
// - 법적 암호화 의무: 계좌번호(account_enc), 카드번호(number_enc·CVC cvc_enc 준용)
// - 자체 상향: 전화번호(phone_enc), OTP 시크릿(otp_secret_enc), 패스키 공개키 외 비밀값 없음
// [단방향 해시 *_hash (HMAC-SHA256, char(64) 단일 규격)]
// - 검색·UNIQUE 필요하지만 원문 불요: ci/di, phone_hash(선물 대상 검색),
// account_hash(중복 검사), number_hash(재사용 차단), token_hash(선물 클레임)
// - 비밀번호·PIN·API시크릿: Argon2id (varchar(255), 파라미터 포함 문자열)
// [평문 저장 (암호화 의무 비대상 + 조회·표시 빈번)]
// - 이름/예금주명(+정규화본: 매칭 연산 필요), 생년월일, 주소, 사업자번호(공개정보),
// 은행코드, 금액·상태·시각 전부. 근거: 고유식별정보·계좌·카드·비밀번호만 암호화 의무.
// [JSON(text) 허용 기준 - 아래 3조건 전부 충족 시에만]
// ① SQL WHERE/JOIN으로 내용을 조회하지 않는다 ② write-once(증거·설정 스냅샷)
// ③ 행마다 스키마가 달라 컬럼화가 부자연스럽다
// → 해당: fds_rules.params(룰별 파라미터 상이), fds_alerts.detail·reject_logs.context·
// audit_logs.detail·integrity_findings.detail(증거 스냅샷), outbox_jobs.payload(작업 인자)
// → 금지: 위 조건을 하나라도 깨면 반드시 컬럼 또는 연결 테이블
// (예: 푸시 대상 회원 목록은 JSON 금지 → push_campaign_targets 연결 테이블)
// [연결 테이블 기준 - N:M 또는 1:N 반복값은 무조건 별도 테이블]
// → lot_allocations(차감:로트 N:M), policy_agreements, inquiry_attachments,
// integrity_findings, push_campaign_targets, admin_allowed_ips
// ---------------------------------------------------------------------
// DBML 표현 한계로 마이그레이션에서만 정의하는 것:
// - 월별 RANGE 파티셔닝: ledger_entries, notification_inbox, external_api_logs,
// reject_logs, app_error_logs, login_histories, pii_access_logs (복합 PK)
// ※ transactions·deposit_notices는 비파티션 확정: MariaDB는 파티션 테이블의
// 모든 UNIQUE 키에 파티션 컬럼 포함을 강제하는데, 멱등성 UNIQUE(멱등키·수신참조)에
// created_at을 넣으면 멱등 보장이 깨짐 → 무결성 > 파티셔닝 (실DB 검증으로 확정)
// - SYSTEM VERSIONING, CHECK 제약 일부, 방어 트리거/이벤트(routines.sql)
// ---------------------------------------------------------------------
// 보존 매트릭스 (100만건+·5년 보관 요구 반영 - 전수 감사 2026-07-21 확정)
// [영구/법정 5년+] transactions, ledger_entries, lot_allocations, point_lots,
// audit_logs, policy_agreements, users·cards·merchants(+버전 이력), unmatched_deposits
// [5년 후 파티션 DROP] external_api_logs, reject_logs, app_error_logs,
// login_histories, pii_access_logs, deposit_notices(아카이브), daily_wallet_snapshots
// [단기] notification_inbox 30일(파티션 DROP), outbox_jobs DONE/EXHAUSTED 90일(DELETE),
// webhook_deliveries DELIVERED 180일(DELETE)
// 삭제 수단: 파티션 테이블=DROP PARTITION(O(1)), 비파티션 소형=배치 DELETE.
// transactions·deposit_notices(비파티션 대형)=연 단위 아카이브 런북(routines.sql 6절).
// ---------------------------------------------------------------------
// 복식 분개 규약 (거래 유형별 leg 표 - queries.sql 0장에 전체 표)
// 시스템 지갑(SYSTEM)은 핫로우 방지를 위해 ①FOR UPDATE 잠금 제외 ②balance 동기 갱신 제외
// ③원장 balance_after = NULL (일 마감 배치가 SUM으로 balance 파생·검증).
// 사용자·매장 지갑만 잠금·balance_after 체인 유지. SYSTEM 지갑 잔액은 음수 허용
// (SETTLEMENT_CLEARING = 은행 실계좌 대사 계정).
// ---------------------------------------------------------------------
// 운영 규칙 (감사 확정)
// - 커서 페이징 표준: 정렬키+id 복합 키셋 (created_at < :ts OR (= AND id < :id)). OFFSET 금지.
// - audit_logs.detail·reject_logs.context에 개인정보 원문 금지(마스킹/PK 참조만).
// - *_enc 암호문은 [키버전 1B][nonce][ciphertext][tag] 직렬화 - 키 로테이션 대비(별도 컬럼 없음).
// - 진행중 상태 집합 상수 = ('PENDING','HOLD','UNKNOWN') - 한도·탈퇴·계좌변경 검증 공통.
// - 재발행 상호참조 = 입금→출금 단방향 저장 + ix_txn_related 역조회로 충족(트리거 불변 유지).
// ---------------------------------------------------------------------
// 거래상세 구조 (발주 답변 2026-07-22)
// - 원장 변동은 단일 transactions 테이블에 type/subtype으로 분기(거래 단위로 쪼개지 않음).
// - 업무 상세만 거래타입별 테이블로 분리: pg_orders(PG결제)·deposit_requests(충전신청)·
// deposit_notices(입금통지)·merchant_qrs(QR결제) 등.
// ※ 연결 방향: 상세 테이블이 transactions.id 를 참조합니다(pg_orders.txn_id 등).
// 역방향 참조용으로 두었던 transactions.detail_id 는 한 번도 쓰이지 않아 제거(V58).
// - ledger_entries(복식 원장)는 그대로 단일 append-only.
// - 종류×방식×상태 조합은 DB CHECK 3종이 강제(V59·V60) — 코드 실수로 이상한 거래가 남지 않도록.
// - 서비스 범위: 한국 내 전용(글로벌 미지원 확정) → 전 시스템 KST 단일.
// - 매장 정산: 대기(+N일) 구조·로직은 구현 유지. 매장 기본 delay=0(즉시 정산),
// 향후 값만 바꾸면 +1일/+2일 대기 적용 가능(발주 2026-07-22).
// ---------------------------------------------------------------------
// 확정 정책 (발주 회신 반영 - 2026-07-22 이후)
// - 결제·충전 채널: 발주사 자체 PG 사용 - 가상계좌 입금통지(웹훅) + 직접계좌 펌뱅킹 출금이체.
// (금결원 오픈뱅킹 직접 이용기관 등록 대신 발주사 PG 규격 연동. 규격서 확정 후 상세 매핑)
// - 본인인증: 쿠콘 또는 드림시큐리티. 1원인증·계좌실명조회: 쿠콘 또는 발주사 기존 계약분.
// - 상품권 충전 UX·상품권사(컬처랜드·해피머니 등) 연동: 범위 제외(발주 확정, 미구현).
// - 포인트 전환(외부 제휴 포인트 ↔ nestpay): 범위 제외. 미리 만들어 둔 스키마
// (exchange_providers 표·EXCHANGE 계열 값)는 한 번도 쓰이지 않아 전부 제거(V59·V60).
// 실제 연동 요청이 오면 그때 요구사항에 맞춰 새로 설계합니다 —
// 빈 스키마를 남겨 두면 다음 사람이 "채워야 하나" 헤매고, 새로 만들 때 옛것과 겹칩니다.
// - 유상(DEPOSIT)/무상(REWARD) 2구분: REWARD는 결제만(환급·출금 불가), DEPOSIT는 결제·선물·출금·환급.
// - 환급: 전체 충전(DEPOSIT) 잔액 기준(전자금융거래법 잔액 환급 규정 - 최종 비율·약관은 법무 확정).
// - 회원간 송금: 선불수단 내부 원장 이체(두 지갑 원자적 차감/증액). 양수도 방식 기준 -
// 전자자금이체업 채택 여부는 법률자문 확정 대상(원장 골격 동일, 한도·약관·실명확인만 상이).
// - 용어: DB 명칭 DEPOSIT = 충전(관리 편의). 사용자 대면 UI는 '충전'으로 표기. 은행 입금통지 이벤트는 deposit_notices(별도 테이블).
// =====================================================================
// ============================== 1. 계정 ==============================
// 요구 #35: 사용자/매장/관리자 계정 완전 분리 (단일 회원 테이블 금지)
Table users {
id bigint [pk, increment]
login_id varchar(20) [not null, unique, note: '영숫자 4~20, 소문자 정규화 저장(#45)']
password_hash varchar(255) [not null, note: 'Argon2id']
name varchar(100) [not null, note: '본인인증 실명']
name_norm varchar(100) [not null, note: '이름 비교용 정규화(공백·특수문자 제거, 로마자화)']
ci_hash char(64) [not null, note: '본인인증 CI HMAC']
active_ci char(64) [note: 'status=ACTIVE일 때만 ci_hash 복사, 그 외 NULL - UNIQUE로 1인 1활성계정 강제(#7)']
di_hash char(64)
phone_enc varbinary(64) [not null]
phone_hash char(64) [not null, note: '선물 대상 검색용(#5·#6)']
birth_date date [note: '본인인증 결과. 성인 전용 검증']
gender varchar(1) [note: '성별 M|F (본인인증 결과, V12)']
avatar_file_id bigint [ref: > files.id, note: '프로필 사진(중앙 파일 대장 참조, V13). NULL=기본 아이콘']
marketing_agree boolean [not null, default: false, note: '광고성 푸시 수신동의(#20)']
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|SUSPENDED|WITHDRAWN']
kyc_status varchar(20) [not null, default: 'VERIFIED', note: 'VERIFIED|PENDING - 관리자 사전생성 회원은 PENDING(앱서 본인인증 완료 시 VERIFIED 승격), V30']
withdrawn_at datetime [note: '탈퇴 시각 - 재가입 대기(admin 설정) 판정 기준']
destroy_due_at date [note: '개인정보 파기 예정일 = 탈퇴+법정보존. 파기 배치 대상 선별']
anonymized_at datetime [note: '파기(익명화) 완료 시각 - phone_enc·name·birth_date 무효화']
created_at datetime [not null]
Indexes {
active_ci [unique, name: 'ux_users_active_ci']
ci_hash [name: 'ix_users_ci', note: '재가입 대기 판정(탈퇴 계정 포함 CI 조회)']
phone_hash [name: 'ix_users_phone_hash']
(status, created_at) [name: 'ix_users_status']
}
Note: 'SYSTEM VERSIONING. 탈퇴해도 행 유지(#39, 법정 보존)'
}
Table merchants {
id bigint [pk, increment]
biz_no varchar(10) [not null, unique, note: '사업자등록번호(국세청 진위확인 #28)']
biz_name varchar(200) [not null]
ceo_name varchar(100) [not null]
ceo_name_norm varchar(100) [not null]
ceo_ci_hash char(64) [not null, note: '대표자 본인인증(CI 해시)']
ceo_di_hash char(64) [note: '대표자 본인인증(DI 해시) - V40. 값 존재=본인인증 완료']
ceo_birth_date char(10) [note: '대표자 생년월일(본인인증 결과) - V40']
ceo_gender varchar(1) [note: '대표자 성별 M|F(본인인증 결과) - V40']
ceo_phone_enc varbinary(256) [note: '대표자 휴대폰(암호화 저장) - V40']
ceo_phone_hash char(64) [note: '대표자 휴대폰(해시, 조회·중복확인용) - V40']
source varchar(10) [note: '가입 경로 WWW(홈페이지)|APP(매장앱)|ADMIN(관리자 직접) - V40']
biz_type varchar(10) [not null, note: 'CORP(법인)|SOLE(개인사업자) - 이 둘만(chk_merchant_biz_type, V66)']
category varchar(50)
address varchar(300)
// (V33) biz_cert_path 제거 — 사업자등록증은 merchant_documents(file_id → files) 로 관리(중앙 파일 대장 원칙)
vat_mode varchar(10) [not null, default: 'TAXED', note: 'TAXED|EXEMPT - 매장이 설정(#24)']
vat_rate decimal(5,2) [not null, default: 10.00, note: '결제 시점 스냅샷의 원천']
status varchar(20) [not null, default: 'PENDING', note: 'PENDING(심사중)|SUPPLEMENT(보완대기)|ACTIVE(정상)|REJECTED(반려)|SUSPENDED(정지)|CLOSED(해지) — 보완 제출 시 PENDING 복귀(왕복 가능). ★ 상태별로 할 수 있는 일이 다르며 문지기(StoreAuthFilter)가 강제합니다: ACTIVE=전부 · PENDING/SUPPLEMENT=상태확인+서류·PG준비 · REJECTED/SUSPENDED/CLOSED=상태확인만(앱은 안내 화면 하나). CLOSED 는 되돌릴 수 없으며, 남은 정산금·진행 중 정산이 있으면 처리되지 않습니다(꺼낼 수 없는 돈이 갇히지 않도록)']
reject_reason varchar(500)
supplement_note varchar(500) [note: '자료 보충 요청 사유(V7) - SUPPLEMENT 상태일 때 매장에게 표시']
approved_by bigint [ref: > admin_accounts.id, note: '승인 관리자(#28)']
approved_at datetime
created_at datetime [not null]
Indexes { status [name: 'ix_merchants_status', note: 'admin 심사 큐(PENDING)·상태별 목록'] }
Note: 'SYSTEM VERSIONING. 대표자 변경=재심사(2차 항목 3)'
}
Table merchant_accounts {
id bigint [pk, increment]
merchant_id bigint [not null, unique, ref: > merchants.id, note: '매장 1계정(하위계정 미도입)']
login_id varchar(20) [not null, unique]
password_hash varchar(255) [not null]
status varchar(20) [not null, default: 'ACTIVE']
created_at datetime [not null]
}
Table admin_accounts {
id bigint [pk, increment]
login_id varchar(20) [not null, unique]
password_hash varchar(255) [not null]
name varchar(100) [not null]
is_root boolean [not null, default: false, note: '계정 생성·삭제·OTP 초기화는 root만(#9 정책)']
otp_secret_enc varbinary(128) [note: 'TOTP 시크릿. 전 계정 의무화 - 미설정 시 기능 제한']
otp_enabled boolean [not null, default: false]
otp_fail_count int [not null, default: 0]
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|LOCKED|DISABLED']
created_at datetime [not null]
}
Table admin_allowed_ips {
id bigint [pk, increment]
admin_account_id bigint [ref: > admin_accounts.id, note: '적용 대상 관리자. NULL=모든 관리자 공통 규칙, 값=그 관리자 전용 규칙(개인 규칙 보유 시 그 규칙에 맞아야만 접근 가능) - V3 추가(관리자별 접근 제한 요구)']
cidr varchar(50) [not null, note: '단건 IP 또는 CIDR(#33)']
memo varchar(200)
created_by bigint [not null, ref: > admin_accounts.id]
created_at datetime [not null]
Indexes { admin_account_id [name: 'ix_allowed_ips_admin'] }
Note: '마지막 공통규칙 삭제 불가·본인 IP 삭제 경고는 앱 로직. 규칙 관리(등록·삭제)는 root만'
}
// --- 인증 부속 (계정 3종 공용: principal_type = USER|MERCHANT|ADMIN) ---
Table auth_pins {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
pin_hash varchar(255) [not null, note: 'Argon2id. 간편로그인+거래인증 겸용(#16)']
fail_count int [not null, default: 0, note: '5회 초과 시 전체 로그인 강등']
locked_until datetime
updated_at datetime [not null]
Indexes { (principal_type, principal_id) [unique, name: 'ux_pin_principal'] }
}
Table auth_passkeys {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
credential_id varchar(255) [not null, unique, note: 'FIDO2/WebAuthn']
public_key varbinary(512) [not null]
device_label varchar(100)
created_at datetime [not null]
Indexes { (principal_type, principal_id) [name: 'ix_passkey_principal'] }
}
// 패스키 챌린지 1회용 소진(V23) — 등록·로그인·거래 서명 replay 방지.
Table used_challenges {
challenge varchar(64) [pk, note: '이미 사용한 패스키 챌린지(1회용)']
expires_at datetime [not null, note: '만료 정리 기준']
created_at datetime [not null]
Indexes { expires_at [name: 'ix_used_challenges_expires'] }
}
Table auth_devices {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
device_uid varchar(128) [not null, note: '앱 설치 식별자 - PIN 기기 바인딩']
platform varchar(10) [not null, note: 'IOS|ANDROID']
model varchar(100)
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|REVOKED - 동시 1기기: 새 기기 등록 시 기존 REVOKED(2차 항목 4)']
last_login_at datetime
created_at datetime [not null]
Indexes {
(principal_type, principal_id, status) [name: 'ix_device_principal']
(principal_type, principal_id, device_uid) [unique, name: 'ux_device_uid']
device_uid [name: 'ix_device_uid', note: 'FDS 다계정기기 역조회(동일 기기→복수 계정)']
}
}
Table login_histories {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
method varchar(20) [not null, note: 'PASSWORD|PIN|PASSKEY|OTP']
ip varchar(45)
device_uid varchar(128)
result varchar(20) [not null, note: 'SUCCESS|FAIL_PW|FAIL_OTP|BLOCKED_IP 등']
created_at datetime [not null]
Indexes { (principal_type, principal_id, created_at) [name: 'ix_login_hist'] }
Note: '사용자 로그인 이력 화면(2차 항목 9) + 보안 감사. 월 파티션(실PK id,created_at)·5년 DROP·방어 트리거'
}
// --- 이용제한(Rate limit) — 동작별 호출 빈도 제한(무차별 차단, V14) ---
Table rate_limit_rules {
action_key varchar(50) [pk, note: '동작 키(login, signup.submit, withdraw.create 등)']
description varchar(200) [not null]
window_sec int [not null, note: '집계 창(초)']
max_count int [not null, note: '창 안 최대 허용 횟수']
basis varchar(10) [not null, note: 'IP | USER']
enabled boolean [not null, default: true]
updated_at datetime [not null]
Note: '관리자 조정(바로 적용). NestPay 실제 동작만 시드(상품권 전환·텔레그램 제외)'
}
Table rate_limit_counters {
action_key varchar(50) [not null]
subject varchar(100) [not null, note: 'IP 또는 회원 식별자']
window_start bigint [not null, note: '창 시작 epoch초']
cnt int [not null, default: 0]
Indexes {
(action_key, subject, window_start) [pk]
window_start [name: 'ix_ratelimit_window']
}
Note: '고정창 카운터. 초과 시 429. 오래된 창은 배치 정리(purgeBefore)'
}
// --- 은행 계좌 (사용자·매장 공용, 소유자 구분) ---
Table bank_accounts {
id bigint [pk, increment]
owner_type varchar(10) [not null, note: 'USER|MERCHANT']
owner_id bigint [not null]
bank_code varchar(10) [not null]
account_enc varbinary(128) [not null]
account_hash char(64) [not null, note: '중복 등록 검사용']
holder_name varchar(100) [not null, note: '실명인증 예금주']
holder_name_norm varchar(100) [not null, note: '정규화 prefix 비교(#15 확장 규칙)']
verified_at datetime [note: '실명+1원인증 완료 시각. 순서: 실명일치→1원인증(#15)']
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|PENDING(매장 자가등록 승인대기)|REMOVED - 변경 시 REMOVED 처리, 이력 보존(#10 정책)']
registrant_type varchar(10) [note: '정산계좌 등록 주체 MERCHANT(매장 자가등록,실명+1원→승인대기)|ADMIN(관리자 직접등록,바로 활성) - V40']
cooldown_until datetime [note: '계좌 변경 후 출금 냉각 24h']
created_at datetime [not null]
Indexes { (owner_type, owner_id, status) [name: 'ix_bank_owner'] }
Note: '출금은 ACTIVE 계좌 1건으로만(#36). ACTIVE 1건 보장은 앱 트랜잭션. 매장 정산계좌는 2경로: 매장 자가등록(PENDING→관리자 승인) 또는 관리자 직접등록(ACTIVE) - V40'
}
// ============================ 2. 카드·지갑 ============================
Table cards {
id bigint [pk, increment]
user_id bigint [not null, ref: > users.id]
number_hash char(64) [not null, unique, note: '재사용 영구 차단의 단일 진실(#2). 삭제 금지']
number_enc varbinary(64) [not null, note: 'BIN 972963 + 랜덤9 + Luhn, 16자리']
cvc_enc varbinary(32) [not null, note: '고정 CVC 확정. 표시용, 서버 암호화 보관']
issued_at datetime [not null]
expires_on date [not null, note: '발급+5년의 그 달 말일(LAST_DAY, 카드 월말 기준). 만료 시 재발행 절차 재사용']
design_code varchar(20) [not null, note: 'OCEAN|SUNSET|FOREST|MIDNIGHT|CORAL|EMBER|VIOLET|ROYAL(#10). 옛 카드 PEARL|AURORA|LAVENDER 호환 표시']
color_code varchar(20) [note: '사용자 선택 색상(#7)']
is_primary boolean [not null, default: false, note: '대표 카드 = 기본 수취(#5 정책)']
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|SUSPENDED|REISSUED|EXPIRED']
reissued_to bigint [ref: > cards.id, note: '재발행 체인 참조(#2)']
created_at datetime [not null]
Indexes { (user_id, status) [name: 'ix_cards_user'] }
Note: 'SYSTEM VERSIONING. 발급 수량 카운트 = ACTIVE만(#43). 카드번호 = 지갑 식별자(#41)'
}
Table wallets {
id bigint [pk, increment]
owner_type varchar(12) [not null, note: 'USER_CARD|MERCHANT|SYSTEM']
card_id bigint [unique, ref: > cards.id, note: 'USER_CARD일 때만, 카드 1:1']
merchant_id bigint [unique, ref: > merchants.id, note: 'MERCHANT일 때만. 정산 출금·취소 반환 전용(#8 정책)']
system_code varchar(30) [unique, note: 'SYSTEM일 때만: FEE_REVENUE(수수료수익)|EXPIRED(낙전)|UNMATCHED(미매칭입금)|SETTLEMENT_CLEARING(대외청산)|FORFEITED(탈퇴 소액포기)|SEIZED(압류 보관)|SEIZE_BURNED(압류 소각) - 7개']
balance decimal(15,0) [not null, default: 0, note: 'CHECK: SYSTEM 제외 balance>=0. 사용자·매장=FOR UPDATE 잠금 지점, SYSTEM=일 배치 파생(핫로우 방지 규약)']
created_at datetime [not null]
Indexes { owner_type [name: 'ix_wallets_owner_type', note: '시스템 지갑 현황·유형별 집계'] }
Note: 'CHECK: owner_type별 해당 참조 컬럼만 NOT NULL(마이그레이션 정의)'
}
Table point_lots {
id bigint [pk, increment]
txn_id bigint [not null, ref: > transactions.id, note: '이 포인트 덩어리를 만든 거래 - 출처 추적·소명 근거(V61). 거래 없이 생긴 포인트는 만들 수 없습니다(NOT NULL). 반대 방향(어디에 썼나)은 lot_allocations 가 담당']
wallet_id bigint [not null, ref: > wallets.id]
lot_type varchar(10) [not null, note: 'DEPOSIT(유상 충전·포인트전환 유입: 결제·선물·출금·환급 가능)|REWARD(무상 적립: 결제만, 환급·출금 불가) - 유상/무상 2구분. 환급은 소스 무관 전체 DEPOSIT 잔액 기준']
source varchar(30) [not null, note: 'DEPOSIT(충전)|GIFT(선물수취)|CANCEL_RESTORE(결제취소 복원)|WITHDRAW_RESTORE(출금실패 복원) - 이 4개가 전부(넣는 코드 기준). 이력 메타이며 환급 판정에는 lot_type=DEPOSIT 을 씁니다']
amount_init decimal(15,0) [not null]
amount_remaining decimal(15,0) [not null, note: 'CHECK(0 <= remaining <= init). 감소만 허용(방어 트리거)']
expires_at datetime [not null, note: '모든 포인트 유효기간 필수(#42). 재발행 이관은 승계, 선물 수취는 리셋(승인 정책)']
origin_lot_id bigint [ref: > point_lots.id, note: '재발행 승계 시 원 로트 참조']
created_at datetime [not null]
Indexes {
(wallet_id, expires_at) [name: 'ix_lots_fifo', note: 'FIFO 소진(만료 임박 우선) 조회']
(expires_at, amount_remaining) [name: 'ix_lots_expiry', note: '만료 배치 스캔(low-watermark 하한 병용)']
(lot_type, amount_remaining) [name: 'ix_lots_type_remaining', note: '별도관리 대사(DEPOSIT 잔여 합산)']
}
}
// ============================== 3. 원장 ==============================
Table transactions {
id bigint [pk, increment]
txn_uid char(26) [not null, unique, note: '대외 노출용 ULID']
type varchar(12) [not null, note: 'DEPOSIT(충전)|WITHDRAW(출금·정산)|GIFT(선물)|PAYMENT(결제)|CANCEL(취소)|ADJUST(관리자 조정) - CHECK 로 강제(V59·V60)']
subtype varchar(20) [not null, note: 'DEPOSIT:VACCT | WITHDRAW:FIRMBANK·SETTLEMENT·UNMATCHED_RETURN·WITHDRAW_RESTORE | GIFT:GIFT_DIRECT | PAYMENT:QR_ORDER·QR_STORE | CANCEL:QR_CANCEL·PG_REFUND | ADJUST:FORFEIT — 조합을 chk_txn_type_subtype 이 강제']
status varchar(12) [not null, note: 'PENDING(이체중)|HOLD(선차감 후 대기)|CONFIRMED(완료)|FAILED(실패)|CANCELED(취소·회수) - 이 5개가 전부. 종류별 허용 상태는 chk_txn_type_status 가 강제(예: PAYMENT 에 PENDING 불가, GIFT 는 CONFIRMED 뿐)']
initiator_type varchar(10) [not null, note: 'USER|MERCHANT|ADMIN|SYSTEM']
initiator_id bigint [not null, default: 0]
idempotency_key varchar(100) [note: 'UNIQUE(initiator_type, initiator_id, idempotency_key) - 사용자 스코프 멱등(#3). V6: 4.4 규약 키(DEP:소스:해시64=최대76자) 수용 위해 64→100 확장(실측 잘림 발견 교정)']
card_id bigint [ref: > cards.id]
counterparty_card_id bigint [ref: > cards.id, note: '선물 수신 카드 - 받은선물 내역·gift_recv 집계·FDS 선물집중의 키(감사 보완)']
merchant_id bigint [ref: > merchants.id]
amount decimal(15,0) [not null]
fee_amount decimal(15,0) [not null, default: 0]
fee_rate_snap decimal(7,4) [note: '적용 정률 스냅샷(#25)']
fee_fixed_snap decimal(15,0) [note: '적용 정액 스냅샷']
vat_amount decimal(15,0) [note: '결제 시점 부가세 스냅샷(#24)']
cancelable_until datetime [note: '결제 시점 취소기한 스냅샷']
scheduled_at datetime [note: '출금·정산 실행 예정 시각 - 신청 시점 payout_policies(+N일 HH시) 스냅샷. 정책 변경 소급 방지']
related_txn_id bigint [note: '역분개·이관·취소의 원거래 참조 - 추적 플로우차트(#32)의 간선']
bank_tran_ref varchar(64) [note: '펌뱅킹 거래 식별자 - 리컨실러 조회 키']
bank_account_id bigint [ref: > bank_accounts.id, note: '출금·정산 실행 대상 계좌 스냅샷 - 이후 계좌 변경해도 "어디로 나갔나" 불변(전수 대조에서 발견된 누락 보완)']
fail_reason varchar(30) [note: 'MERCHANT_INSUFFICIENT_BALANCE|CANCEL_WINDOW_EXPIRED 등 코드']
memo varchar(200) [note: '선물 메시지(50자·금칙어 필터) 등']
created_at datetime [not null]
confirmed_at datetime
Indexes {
(card_id, created_at) [name: 'ix_txn_card', note: '이용내역(#13)·월명세(#23) 주 쿼리']
(merchant_id, created_at) [name: 'ix_txn_merchant', note: '매장 매출 조회(#24)']
(status, created_at) [name: 'ix_txn_status', note: '리컨실러·장기체류 감시(#30)']
(initiator_type, initiator_id, idempotency_key) [unique, name: 'ux_txn_idem']
(initiator_type, initiator_id, type, created_at) [name: 'ix_txn_initiator_date', note: '한도 일/월 합산(#37 사용자 전 카드 합산)·통합 내역의 주 인덱스']
bank_tran_ref [name: 'ix_txn_bank_ref']
created_at [name: 'ix_txn_created', note: 'admin 기간 검색·일 마감 집계·FDS 배치 스캔(비파티션 보완)']
related_txn_id [name: 'ix_txn_related', note: '추적 CTE·역분개 역조회·재발행 상호참조']
(type, status, scheduled_at) [name: 'ix_txn_withdraw_sched', note: '출금 실행 배치 - filesort·과잠금 제거']
(initiator_type, initiator_id, created_at) [name: 'ix_txn_initiator_created', note: '통합 내역(type 무관 기간 조회)']
(counterparty_card_id, created_at) [name: 'ix_txn_counterparty', note: '받은 선물 조회']
}
Note: 'append-only(취소=CANCEL 신규 행). 비파티션(UNIQUE 멱등 보장 우선) - 장기 증가 대응은 연 단위 아카이브 절차로'
}
Table ledger_entries {
id bigint [pk, increment, note: '실PK는 (id, created_at) - 월 파티셔닝']
txn_id bigint [not null, note: 'FK(transactions) - 파티션 간 FK는 논리 참조(앱 강제)']
wallet_id bigint [not null, ref: > wallets.id]
direction char(2) [not null, note: 'DR|CR. 거래별 ΣDR=ΣCR (복식·Java 검증+일 대사)']
amount decimal(15,0) [not null, note: 'CHECK(amount > 0)']
balance_after decimal(15,0) [note: '사용자·매장 지갑=NOT NULL 체인(#29). SYSTEM 지갑 leg=NULL(핫로우 규약, 일 배치 SUM 검증) - NULL 규칙은 CHECK+대사로 강제']
created_at datetime [not null]
Indexes {
(wallet_id, id) [name: 'ix_ledger_wallet_chain', note: '잔액 체인 검증 순서']
txn_id [name: 'ix_ledger_txn']
}
Note: 'append-only 절대(#29): UPDATE/DELETE 방어 트리거 + 권한 미부여. 월 파티션'
}
Table lot_allocations {
id bigint [pk, increment]
ledger_entry_id bigint [not null, note: '차감(DR) 엔트리']
lot_id bigint [not null, ref: > point_lots.id]
amount decimal(15,0) [not null]
Indexes {
lot_id [name: 'ix_alloc_lot']
ledger_entry_id [name: 'ix_alloc_entry', note: '취소 시 원 소진 로트 역추적 - 풀스캔 방지(감사 보완)']
}
Note: '한 차감이 복수 로트에 걸친 배분 기록 - 유효기간 FIFO 소진·취소 복원 근거(환급은 소스 무관 전체 DEPOSIT 잔액 기준이라 유형판정 미사용)'
}
// ======================= 4. 충전 입금 (충전신청 기반) =======================
// 충전 프로세스(발주사 확정): 회원앱 충전신청 → 페이솔루스 입금요청 API → 응답기반 충전요청(deposit_requests)
// 생성(입금대기) → 입금통지 웹훅(PGVACCT/FIRMBANK) → 금액일치 처리 → 충전완료.
// 규약: (1)무신청 충전 불가 (2)회원당 진행 중 신청 1건(새 신청 시 이전 대기 자동취소)
// (3)신청 금액과 정확 일치할 때만 충전 (4)입금통지 미도착 시 만료(EXPIRED) —
// 단 만료/취소 후 늦게 입금이 와도 코드+금액 일치면 충전(환불 어려움).
// deposit_identifiers 는 레거시(회원당 고정 입금자코드 자동적립) — 충전신청 도입으로 미사용.
// 충전신청 - 무신청 충전 불가 강제. 신청마다 페이솔루스가 배정한 입금계좌(스냅샷)+고유코드,
// 신청 금액과 정확 일치 시에만 충전(V39).
Table deposit_requests {
id bigint [pk, increment]
user_id bigint [not null, ref: > users.id, note: '신청 회원']
card_id bigint [note: '충전 대상 카드(선택 - 비면 확정 시 대표카드)']
amount decimal(15,0) [not null, note: '신청(선언) 금액 - 이 금액 그대로 입금돼야 충전']
payer_name varchar(60) [note: '신청 시점 인증계좌의 예금주명(스냅샷) - 이 이름으로 들어온 입금만 인정(V67)']
payer_name_norm varchar(60) [note: '예금주명 비교용(공백·기호 제거+소문자) - 은행마다 표기가 조금씩 달라 정규화']
deposit_account_id bigint [note: '배정 회사 입금계좌(deposit_accounts.id 참조 - 스냅샷은 아래 3열)']
account_bank varchar(40) [not null, note: '배정 계좌 은행 스냅샷']
account_no varchar(40) [not null, note: '배정 계좌번호 스냅샷']
account_holder varchar(60) [not null, note: '배정 예금주 스냅샷']
status varchar(16) [not null, default: 'PENDING', note: 'PENDING(입금대기)|MATCHED(충전완료)|EXPIRED(만료)|CANCELED(취소)']
expires_at datetime [not null, note: '신청 만료 시각(신청 +5분) - 지나면 자동취소. 단 신청 +10분까지 도착한 입금은 인정(V67)']
matched_notice_id bigint [note: '매칭된 입금통지(deposit_notices.id)']
matched_txn_id bigint [note: '충전 확정 거래(transactions.id)']
matched_at datetime
created_at datetime [not null]
alive_key varchar(160) [note: '생성 칸(VIRTUAL) - PENDING 일 때만 `이름:금액:계좌` 값. 같은 조합 동시 대기를 막습니다']
Indexes {
alive_key [unique, name: 'ux_depreq_alive']
(user_id, status, created_at) [name: 'ix_depreq_user']
(status, expires_at) [name: 'ix_depreq_status']
(payer_name_norm, amount, deposit_account_id, status) [name: 'ix_depreq_match']
}
Note: '무신청 충전 불가 · 회원당 진행 중 1건 · [예금주명+금액+배정계좌+시각(-1분~+10분)] 이 모두 맞아야 충전(V67) · 대기 5분'
}
Table payout_accounts {
id bigint [pk, increment]
bank_code varchar(3) [not null, note: '은행 코드(banks.code) - 화면에는 이름으로 보여 줍니다']
account_no varchar(40) [not null, note: '계좌번호']
holder varchar(60) [not null, note: '예금주(우리 회사 이름)']
label varchar(40) [not null, note: '구분용 이름표 - 예) 출금 전용']
sort_order int [not null, default: 1, note: '여러 개일 때 쓰는 차례']
status varchar(10) [not null, default: 'ACTIVE', note: 'ACTIVE=사용함 | HIDDEN=쓰지 않음']
created_at datetime [not null]
alive_key varchar(10) [note: '생성 칸(VIRTUAL) - ACTIVE 일 때만 값. 활성 계좌가 하나뿐임을 보장']
Indexes {
(bank_code, account_no) [unique, name: 'ux_payout_acct']
alive_key [unique, name: 'ux_payout_active_only_one']
(status, sort_order) [name: 'ix_payout_list']
}
Note: '회사 출금계좌(V67) - 회원 출금·매장 정산을 보낼 때 돈이 나가는 우리 계좌. 활성은 항상 하나'
}
Table deposit_notices {
id bigint [pk, increment]
source varchar(10) [not null, note: 'PGVACCT(발주사 PG 가상계좌 입금통지 웹훅)|FIRMBANK(펌뱅킹 조회 확정)|SMS(보조 감지)']
raw_text text [note: 'SMS 원문 보존(#29 증거 보존) - 절단 방지 TEXT']
dedup_key char(64) [not null, note: 'SMS=HMAC(수신번호+원문+수신분), 펌뱅킹=bank_tran_ref 사본 - 소스별 결정적 중복키(SMS는 ref 부재)']
bank_tran_ref varchar(64) [note: '펌뱅킹 거래 식별자']
parsed_amount decimal(15,0)
parsed_name varchar(100)
parsed_name_norm varchar(100) [note: '정규화 prefix 매칭용']
identifier_value varchar(64) [note: '가상계좌/입금자코드 파싱값']
status varchar(20) [not null, default: 'NEW', note: 'NEW|MATCHED|UNMATCHED|IGNORED']
attempts int [not null, default: 0, note: '처리 시도 횟수 - 한계 도달 시 status=FAILED 로 격리(무한 재시도 방지, V44)']
matched_txn_id bigint [note: '매칭 성사 시 DEPOSIT(충전) 거래 참조']
received_at datetime [not null]
Indexes {
(source, dedup_key) [unique, name: 'ux_notice_ref', note: 'INSERT IGNORE 금지 - 1062만 앱에서 멱등 처리']
(status, received_at) [name: 'ix_notice_status']
}
}
Table unmatched_deposits {
id bigint [pk, increment]
notice_id bigint [not null, ref: > deposit_notices.id]
amount decimal(15,0) [not null]
status varchar(20) [not null, default: 'PENDING', note: 'PENDING|MATCHED|RETURNED|HOLD - 관리자 재량 처리(#48)']
resolved_by bigint [ref: > admin_accounts.id]
resolved_at datetime
resolution varchar(20) [note: 'MANUAL_MATCH|RETURN|HOLD']
resolution_note varchar(500) [note: '사유 필수 - 원장 역분개와 이중 기록']
return_method varchar(30) [note: '반환 방법(펌뱅킹 이체 등)']
return_bank_code varchar(10)
return_account_enc varbinary(128) [note: '반환 계좌 AES-256 - 계좌번호 암호화 의무(감사 보완)']
created_at datetime [not null]
Note: '입금 즉시 UNMATCHED 시스템 지갑에 원장 계상 - 장부 밖 돈 없음'
}
// =========================== 6. QR·PG 결제 ===========================
Table merchant_qrs {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
qr_type varchar(15) [not null, note: 'STORE_STATIC|STORE_DYNAMIC|ORDER - USER_PAY(CPM)·USER_RECEIVE는 Redis 단명 토큰(DB 미저장)']
label varchar(50) [note: '카운터1 등 - 매장당 복수 QR(#24)']
amount decimal(15,0) [note: 'ORDER형만']
key_version int [not null, default: 1, note: 'AES-GCM 키 버전']
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|REVOKED|USED(ORDER 1회용)']
expires_at datetime [note: 'DYNAMIC/ORDER TTL']
created_at datetime [not null]
Indexes { (merchant_id, status) [name: 'ix_qr_merchant'] }
}
Table merchant_api_credentials {
id bigint [pk, increment]
merchant_id bigint [not null, unique, ref: > merchants.id]
client_id varchar(32) [not null, unique]
api_key_hash varchar(255) [not null, note: '시크릿 해시만 보관(발급 시 1회 표시)']
prev_key_hash varchar(255) [note: '키 롤 유예(24h 신구 동시 유효)']
prev_key_expires_at datetime
webhook_url varchar(500) [note: '매장앱에서 입력(#26)']
webhook_secret_enc varbinary(512) [note: 'AES-256-GCM 암호화 - 발신 웹훅 HMAC 서명 생성에 원문 필요(해시 불가, 감사 보완)']
mall_domain varchar(255) [note: 'PG 신청 - 쇼핑몰(웹사이트) 도메인 - V40']
escrow_file_id bigint [ref: > files.id, note: 'PG 신청 - 에스크로 확인증 파일(옵션) - V40']
insurance_file_id bigint [ref: > files.id, note: 'PG 신청 - 이행보증보험증권 파일(옵션) - V40']
status varchar(15) [not null, default: 'REQUESTED', note: 'REQUESTED(신청)|APPROVED|REJECTED|REVOKED - admin 승인 큐(#28)']
requested_at datetime [not null]
approved_by bigint [ref: > admin_accounts.id]
mode varchar(10) [not null, default: 'SANDBOX', note: 'SANDBOX|LIVE - 테스트 통과 후 매장이 전환(#26·#28)']
test_create_ok_at datetime [note: '체크리스트: 결제 생성']
test_webhook_ok_at datetime [note: '체크리스트: 웹훅 2xx']
test_query_ok_at datetime [note: '체크리스트: 상태조회']
test_signature_ok_at datetime [note: '체크리스트: 웹훅 서명 검증(틀린 서명을 4xx로 거절) - V72']
created_at datetime [not null]
Indexes { (status, requested_at) [name: 'ix_apicred_queue', note: 'admin 신청 심사 큐'] }
Note: 'LIVE 전환 조건 = status=APPROVED + 3개 test_*_ok_at 모두 존재(앱 강제). SYSTEM VERSIONING(웹훅 URL·모드 변경 이력)'
}
Table merchant_api_allowed_ips {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
cidr varchar(50) [not null, note: '허용할 매장 서버 IP(단건 또는 CIDR)']
memo varchar(200)
status varchar(15) [not null, default: 'REQUESTED', note: 'REQUESTED(매장 신청)|APPROVED(관리자 승인)|REJECTED - 승인분만 open API 허용, 승인 0건 매장은 호출 거부(V7 확정 요구)']
approved_by bigint [ref: > admin_accounts.id]
approved_at datetime
created_at datetime [not null]
Indexes { (merchant_id, status) [name: 'ix_mip_merchant'] }
Note: '매장 open API(/pg/**) 화이트IP - 매장앱에서 신청, 관리자 승인 필수(V7)'
}
Table pg_orders {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
order_no varchar(64) [not null, note: '쇼핑몰 주문번호 - UNIQUE(merchant_id, order_no) 멱등']
amount decimal(15,0) [not null]
supply_amount decimal(15,0) [note: '공급가액']
vat_amount decimal(15,0) [not null, note: 'PG API로 수신·보관(#24)']
item_name varchar(200)
items_json text [note: '품목 스냅샷 JSON [{name,qty,unitPrice,amount,taxFree,supplyAmount,vatAmount}] - 다품목·수량·품목별 과세구분 원본 보존(V41). 과세·면세 혼합 시 주문 공급가/부가세=품목 합. NULL=단일 상품명 주문']
return_info varchar(500) [note: '쇼핑몰 임의 리턴정보(주문코드 등) - 조회·웹훅에 그대로 echo, 서버는 해석 안 함(V41)']
qr_id bigint [ref: > merchant_qrs.id]
txn_id bigint [note: '결제 성사 시 거래 참조']
status varchar(20) [not null, default: 'PENDING', note: 'PENDING|PAID|CANCELED|EXPIRED (생성 시 PENDING) - V43']
is_sandbox boolean [not null, default: false, note: '테스트 주문 실원장 완전 분리']
expires_at datetime [note: '유효시간 만료 시각 - 지나면 만료 처리(V27, 관리자 PG_ORDER_TTL_MINUTES)']
paid_at datetime [note: '결제 완료 시각(V27)']
canceled_at datetime [note: '취소·환불 시각(V27)']
created_at datetime [not null]
Indexes {
(merchant_id, order_no) [unique, name: 'ux_pg_order']
(status, created_at) [name: 'ix_pg_status', note: '미결제 주문 만료 스윕']
(status, expires_at) [name: 'ix_pg_expire', note: '유효시간 지난 PENDING 주문 만료 스위퍼']
txn_id [name: 'ix_pg_txn', note: '결제/취소 시 거래→주문 역참조']
}
}
Table webhook_deliveries {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
event_type varchar(30) [not null, note: 'PAYMENT_COMPLETED|PAYMENT_CANCELED|TEST 등']
pg_order_id bigint [ref: > pg_orders.id]
payload text [not null]
target_url varchar(500) [not null]
attempts int [not null, default: 0, note: '지수 백오프 최대 10회/24h']
next_retry_at datetime
last_http_code int
claimed_at datetime [note: '발송 클레임 시각 - SENDING 고아 복구 스위퍼 기준']
claim_token varchar(64) [note: '발송 워커의 집기 표식(UUID) - 2대 서버 중복 발송 방지(V34)']
status varchar(20) [not null, default: 'PENDING', note: 'PENDING|SENDING(클레임-커밋 후 발송)|DELIVERED|EXHAUSTED(#30 감시)']
created_at datetime [not null]
Indexes { (status, next_retry_at) [name: 'ix_webhook_retry'] }
}
// ========================= 7. 정책 (admin 설정) =======================
// 공통 패턴: 전역 기본(scope=GLOBAL_*, target_id NULL) + 개별 오버라이드.
// 전부 SYSTEM VERSIONING - "그 시점 정책" 증빙. 거래엔 스냅샷 저장.
Table fee_policies {
id bigint [pk, increment]
fee_type varchar(20) [not null, note: 'MERCHANT_PAYMENT|MERCHANT_PAYOUT|USER_DEPOSIT|USER_WITHDRAW|USER_GIFT']
scope varchar(15) [not null, note: 'GLOBAL|USER|MERCHANT']
target_id bigint
rate decimal(7,4) [not null, default: 0, note: '정률% - 정액과 합산 조합(#25)']
fixed_amount decimal(15,0) [not null, default: 0]
Indexes { (fee_type, scope, target_id) [unique, name: 'ux_fee_policy'] }
}
Table limit_policies {
id bigint [pk, increment]
limit_type varchar(15) [not null, note: 'DEPOSIT|BALANCE|WITHDRAW|GIFT|PAYMENT(#37)']
window varchar(10) [not null, note: 'PER_TXN|DAILY|MONTHLY|CAP(보유)']
scope varchar(15) [not null]
target_id bigint
amount decimal(15,0) [not null, note: 'BALANCE는 법정 상한 초과 설정 차단(앱 검증)']
Indexes { (limit_type, window, scope, target_id) [unique, name: 'ux_limit_policy'] }
Note: '집행은 사용자 전 카드 합산(#37)'
}
Table payout_policies {
id bigint [pk, increment]
scope varchar(20) [not null, note: 'GLOBAL_USER|GLOBAL_MERCHANT|USER|MERCHANT']
target_id bigint
delay_days int [not null, note: '신청 +N일. **매장(GLOBAL_MERCHANT) 기본 0 = 즉시 정산**(발주 확정 2026-07-22, 가맹점 출금요청 시 즉각). 사용자 출금만 대기 가능']
execute_time time [not null, note: '실행 시각 HH:mm (KST). delay_days=0이면 무시(즉시)']
Indexes { (scope, target_id) [unique, name: 'ux_payout_policy'] }
Note: '매장 정산 대기(+N일) 구조·로직 완전 구현 유지(제거 금지 - 향후 추가 비용 방지, 발주 2026-07-22). 매장 기본 delay=0(즉시 정산). 값만 바꾸면 +1일/+2일 대기 즉시 적용. scheduled_at = 신청 + delay일 HH시(delay 0이면 NOW → 배치가 바로 집행)'
}
Table cancel_policies {
id bigint [pk, increment]
scope varchar(15) [not null, note: 'GLOBAL|MERCHANT']
target_id bigint
days int [not null, note: '결제 후 N일 - 거래에 cancelable_until 스냅샷']
Indexes { (scope, target_id) [unique, name: 'ux_cancel_policy'] }
}
Table card_quota_policies {
id bigint [pk, increment]
scope varchar(15) [not null, note: 'GLOBAL|USER(#43)']
target_id bigint
max_cards int [not null, note: 'ACTIVE 카드 기준']
Indexes { (scope, target_id) [unique, name: 'ux_quota_policy'] }
}
Table point_expiry_policies {
id bigint [pk, increment]
lot_type varchar(10) [not null, unique, note: 'DEPOSIT|REWARD']
months int [not null, note: '충전성 값은 법무 확인 후 설정(#42)']
}
Table global_settings {
id bigint [pk, increment]
skey varchar(50) [not null, unique, note: 'REJOIN_WAIT_DAYS|CPM_CONFIRM_THRESHOLD|MAINTENANCE_ON|MAINTENANCE_MSG|MAINTENANCE_UNTIL|MIN_APP_VERSION_IOS|MIN_APP_VERSION_ANDROID|BANK_COOLDOWN_HOURS|FORFEIT_MAX_AMOUNT(탈퇴 소액포기 상한)|PUSH_FCM_PROJECT_ID|PUSH_FCM_KEY_FILE_ID(files 참조 - 서비스계정 JSON)|PUSH_ENABLED 등 단일값 설정']
svalue varchar(500) [not null]
updated_by bigint [ref: > admin_accounts.id]
updated_at datetime [not null]
Note: 'SYSTEM VERSIONING - 설정 변경 이력 자동 보존'
}
// ============================== 8. FDS ===============================
Table fds_rules {
id bigint [pk, increment]
code varchar(30) [not null, unique, note: 'PASSTHROUGH|GIFT_CONCENTRATION|SELF_PAYMENT|CANCEL_ABUSE|POST_CHANGE_WITHDRAW|ENUMERATION|MULTI_ACCOUNT_DEVICE|NIGHT_LARGE(#38 8종)']
name varchar(100) [not null]
params text [not null, note: 'JSON: 임계값·시간창 - admin에서 조정(배포 불필요)']
action varchar(10) [not null, note: 'BLOCK|HOLD|ALERT|MONITOR']
enabled boolean [not null, default: true]
updated_at datetime [not null]
Note: 'SYSTEM VERSIONING'
}
Table fds_alerts {
id bigint [pk, increment]
rule_id bigint [not null, ref: > fds_rules.id]
principal_type varchar(10) [not null]
principal_id bigint [not null]
txn_id bigint [note: '관련 거래']
detail text [note: '탐지 근거 JSON(증거 보존)']
status varchar(20) [not null, default: 'OPEN', note: 'OPEN|REVIEWED|ACTIONED|DISMISSED']
reviewed_by bigint [ref: > admin_accounts.id]
reviewed_at datetime
review_note varchar(500)
created_at datetime [not null]
Indexes { (status, created_at) [name: 'ix_fds_queue'] }
}
// ======================= 9. 콘텐츠·고객지원·약관 ======================
Table notices {
id bigint [pk, increment]
kind varchar(10) [not null, note: 'NOTICE|EVENT(#18)']
audience varchar(10) [not null, default: 'ALL', note: 'ALL|USER|STORE - 노출 대상 앱(V20)']
force_show tinyint [not null, default: 0, note: '1=앱 메인 강제 팝업(확인해야 사용)(V20)']
title varchar(200) [not null]
body text [not null]
image_file_id bigint [ref: > files.id, note: '이벤트 이미지 배너(V15) - 중앙 파일 대장 참조(FK 강제 V29)']
link_url varchar(500) [note: '이벤트 외부 링크(V15)']
starts_at datetime
ends_at datetime
status varchar(10) [not null, default: 'DRAFT', note: 'DRAFT|PUBLISHED|HIDDEN']
created_by bigint [ref: > admin_accounts.id]
created_at datetime [not null]
Indexes { (kind, status, starts_at) [name: 'ix_notice_pub'] }
}
Table banners {
id bigint [pk, increment]
position varchar(20) [not null, default: 'USER_HOME', note: 'USER_HOME|GUEST|USER_CHARGE|STORE_HOME - 앱 노출 위치(V21). 같은 위치 여러개면 롤링']
image_file_id bigint [not null, ref: > files.id, note: '배너 이미지 - 중앙 파일 대장 참조(V8 원칙 교정: 경로 직접 보관 금지)']
link_type varchar(10) [not null, note: 'NOTICE|EVENT|URL|NONE - 외부 URL 허용(승인 정책)']
link_target varchar(500)
sort_order int [not null, default: 0]
starts_at datetime
ends_at datetime
status varchar(10) [not null, default: 'DRAFT']
}
Table faqs {
id bigint [pk, increment]
category varchar(30) [not null]
question varchar(300) [not null]
answer text [not null]
sort_order int [not null, default: 0]
status varchar(10) [not null, default: 'PUBLISHED']
}
Table inquiries {
id bigint [pk, increment]
principal_type varchar(10) [not null, note: 'USER|MERCHANT']
principal_id bigint [not null]
title varchar(200) [not null]
body text [not null]
status varchar(10) [not null, default: 'OPEN', note: 'OPEN|ANSWERED|CLOSED']
answer text
answered_by bigint [ref: > admin_accounts.id]
answered_at datetime [note: '답변 시 푸시 통지']
created_at datetime [not null]
Indexes {
(principal_type, principal_id, created_at) [name: 'ix_inquiry_owner']
(status, created_at) [name: 'ix_inquiry_queue', note: 'admin 미답변 큐']
}
}
Table inquiry_attachments {
id bigint [pk, increment]
inquiry_id bigint [not null, ref: > inquiries.id]
file_id bigint [not null, ref: > files.id, note: '첨부 이미지 - 중앙 파일 대장 참조(V8 원칙 교정). 실물은 두 WAS 가 함께 바라보는 공유 폴더에 저장(발주사 확정 2026-08-11)']
created_at datetime [not null]
Note: '이미지만, 최대 5장/장당 10MB(#19 확정)'
}
Table policy_documents {
id bigint [pk, increment]
kind varchar(20) [not null, note: 'TERMS|PRIVACY|WITHDRAW_GUIDE(탈퇴 안내)(#21,V17)']
version varchar(20) [not null]
body text [not null]
effective_at datetime [not null, note: '시행일 - 사전 고지·예약 게시']
requires_reconsent boolean [not null, default: false, note: '개정 건별 재동의 여부 admin 선택(승인 정책)']
status varchar(10) [not null, default: 'DRAFT', note: 'DRAFT|ACTIVE|ARCHIVED - 과거 버전 영구 보존']
Indexes { (kind, version) [unique, name: 'ux_policy_ver'] }
}
// 회원 탈퇴 신청·승인 대장(V18). 신청(PENDING) → 관리자 승인(APPROVED=탈퇴 확정)/반려(REJECTED).
// 승인 순간 users.status='WITHDRAWN' + 카드 CLOSED, 보유 포인트는 소멸(소멸 금액 기록).
Table account_closure_requests {
id bigint [pk, increment]
user_id bigint [not null, note: '신청 회원']
status varchar(20) [not null, default: 'PENDING', note: 'PENDING|APPROVED|REJECTED']
reason varchar(200) [note: '회원이 남긴 탈퇴 사유(선택)']
points_at_request decimal(15,0) [not null, default: 0, note: '신청 시점 보유 포인트(소멸 예정 안내)']
points_forfeited decimal(15,0) [note: '승인 시점 실제 소멸 포인트']
requested_at datetime [not null]
processed_by bigint [note: '승인·반려한 관리자']
processed_at datetime
Indexes {
user_id [name: 'ix_closure_user']
status [name: 'ix_closure_status']
}
}
Table policy_agreements {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
policy_id bigint [not null, ref: > policy_documents.id]
agreed_at datetime [not null, note: '분쟁 대비 증빙']
Indexes { (principal_type, principal_id, policy_id) [unique, name: 'ux_agreement'] }
}
// =========================== 10. 알림·푸시 ===========================
Table notification_settings {
event_key varchar(40) [pk, note: '알림 이벤트 키(enqueue payload 의 kind 와 일치, V16)']
description varchar(200) [not null]
enabled boolean [not null, default: true, note: '발송 여부(끄면 이 이벤트 알림 안 감)']
channel varchar(10) [not null, default: 'BOTH', note: 'INBOX(앱 알림함) | PUSH(폰 푸시) | BOTH']
severity varchar(10) [not null, default: 'INFO', note: 'INFO|WARN|CRITICAL(표시용)']
updated_at datetime [not null]
Note: '이벤트별 알림 발송 설정(관리자 조정). OutboxWorker 가 발송 시 참조. NestPay 실제 이벤트만 시드'
}
Table push_tokens {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
platform varchar(10) [not null]
token varchar(255) [not null, unique, note: 'FCM(#20)']
updated_at datetime [not null]
Indexes { (principal_type, principal_id) [name: 'ix_push_principal'] }
}
Table push_campaigns {
id bigint [pk, increment]
title varchar(100) [not null]
body varchar(500) [not null]
target varchar(20) [not null, note: 'ALL(회원 전체)|IOS|ANDROID|MERCHANT_ALL(매장 전체)|SELECTED(개별 - 대상은 push_campaign_targets, JSON 금지 원칙). 매장 푸시 확정 요구로 MERCHANT_ALL 추가']
is_ad boolean [not null, note: '광고성=수신동의자만+야간(21~08) 차단 자동']
scheduled_at datetime
sent_count int [default: 0]
fail_count int [default: 0]
status varchar(10) [not null, default: 'DRAFT', note: 'DRAFT|QUEUED(발송 대기)|SENDING|SENT']
created_by bigint [ref: > admin_accounts.id]
created_at datetime [not null]
}
Table push_campaign_targets {
id bigint [pk, increment]
campaign_id bigint [not null, ref: > push_campaigns.id]
principal_type varchar(10) [not null]
principal_id bigint [not null]
sent_at datetime
result varchar(20) [note: 'SENT|FAILED|NO_TOKEN|OPTED_OUT']
Indexes { (campaign_id, principal_type, principal_id) [unique, name: 'ux_campaign_target'] }
Note: '지정 발송 대상 + 개별 발송 결과 - 도달 분석'
}
Table notification_inbox {
id bigint [pk, increment, note: '실PK (id, created_at) 월 파티션 - 30일 보존 배치 삭제(#46 확정)']
principal_type varchar(10) [not null]
principal_id bigint [not null]
ntype varchar(20) [not null, note: 'TXN|GIFT|SETTLEMENT|INQUIRY|CAMPAIGN 등']
title varchar(100) [not null]
body varchar(500)
deeplink varchar(200)
read_at datetime
created_at datetime [not null]
Indexes { (principal_type, principal_id, created_at) [name: 'ix_inbox_owner'] }
Note: '푸시 발송과 동일 outbox 작업에서 원자적 기록'
}
// ==================== 11. 명세·정산 (월말 스냅샷) =====================
// [제거됨 V46] monthly_statements(회원 월명세 사전집계) — 실시간 계산(AppStatementService)으로 대체되어 미사용.
Table merchant_monthly_statements {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
yyyymm char(6) [not null]
sales_count int [not null, default: 0]
sales_sum decimal(15,0) [not null, default: 0]
cancel_count int [not null, default: 0]
cancel_sum decimal(15,0) [not null, default: 0]
supply_sum decimal(15,0) [not null, default: 0, note: '공급가액(#24 부가세 분리)']
vat_sum decimal(15,0) [not null, default: 0]
fee_sum decimal(15,0) [not null, default: 0, note: '결제+정산 수수료']
payout_sum decimal(15,0) [not null, default: 0]
closing_balance decimal(15,0) [not null]
created_at datetime [not null]
Indexes { (merchant_id, yyyymm) [unique, name: 'ux_mstmt_month'] }
Note: '월 정산서(#24-5) - 카드사 동일 수준, 부가세 포함'
}
// ===================== 12. 비동기(outbox)·감사·운영 ===================
Table files {
id bigint [pk, increment]
original_name varchar(300) [not null, note: '사용자가 올린 원래 파일 이름']
stored_name varchar(100) [not null, unique, note: '서버 저장 파일명(무작위 - 겹침·경로조작 방지)']
content_type varchar(100) [note: '파일 형식(application/pdf 등)']
size_bytes bigint [not null]
uploaded_by bigint [not null, note: '올린 주체의 번호(관리자 또는 매장계정)']
uploader_type varchar(10) [not null, default: 'ADMIN', note: 'ADMIN(관리자)|MERCHANT(매장) - V7 주체 구분']
created_at datetime [not null]
Note: '중앙 파일 대장(V4) - 모든 업로드 파일은 이 한 테이블로만 관리, 타 테이블은 file_id 참조(확정 원칙). 실물은 두 WAS 가 함께 바라보는 공유 폴더(app.storage.dir)에 저장, stored_name=폴더 안 파일 이름'
}
Table merchant_documents {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
doc_name varchar(100) [not null, note: '서류 이름(예: 사업자등록증, 통장 사본)']
doc_type varchar(30) [note: '서류 종류코드 BIZ_CERT|CEO_ID|SETTLE_BANKBOOK|CEO_SEAL|CORP_SEAL|CORP_REGISTRY|SHAREHOLDERS - V40. 가입 시 개인/법인별로 자동 생성']
file_id bigint [ref: > files.id, note: 'NULL=요구만 됨(미제출), 값=제출 완료']
requested_by bigint [ref: > admin_accounts.id, note: 'V40 NULL 허용: 가입 시 자동 생성 기본서류는 NULL, 관리자 추가 요구분은 관리자 id']
submitted_at datetime [note: '제출(업로드) 시각']
created_at datetime [not null]
Indexes {
(merchant_id, doc_name) [unique, name: 'ux_mdoc_merchant_doc', note: '같은 서류명 중복 요구 방지']
merchant_id [name: 'ix_mdoc_merchant']
}
Note: '매장 필수서류(V4) - 요구 서류가 전부 제출(file_id 채움)되어야 승인 가능(서버 강제). 매장 승인은 관리자 필수. 가입 시 개인 4종/법인 6종 자동 생성(V40)'
}
Table outbox_jobs {
id bigint [pk, increment]
job_type varchar(30) [not null, note: 'PUSH|WEBHOOK|RECONCILE|GIFT_EXPIRE|LOT_EXPIRE|SETTLEMENT|STATEMENT|PARTITION 등 - 큐 미사용(#22) 단일 패턴']
payload text [not null]
run_after datetime [not null]
attempts int [not null, default: 0]
max_attempts int [not null, default: 10]
status varchar(10) [not null, default: 'PENDING', note: 'PENDING|RUNNING|DONE|EXHAUSTED']
claimed_at datetime [note: '워커가 RUNNING으로 집어간 시각 - 고아 스위퍼 기준(13장, V10 정본모순 교정)']
claim_token char(36) [note: '집기 표 - UPDATE로 붙이고 자기 것만 SELECT. SKIP LOCKED가 10.3 미지원이라 대체(V11)']
last_error varchar(500)
created_at datetime [not null]
Indexes {
(status, run_after) [name: 'ix_outbox_poll', note: '워커 폴링 주 쿼리']
claim_token [name: 'ix_outbox_claim', note: '내 표 붙은 작업 찾기(10.3 호환 클레임)']
}
Note: '원장 트랜잭션과 같은 커밋에 INSERT - 후속작업 유실 불가(#29)'
}
Table audit_logs {
id bigint [pk, increment]
actor_type varchar(10) [not null, note: 'ADMIN|SYSTEM']
actor_id bigint
action varchar(50) [not null, note: 'MERCHANT_APPROVE|FORCE_SUSPEND|POLICY_CHANGE|MANUAL_MATCH|OTP_RESET|IP_ADD 등']
target_type varchar(30)
target_id bigint
detail text [note: '변경 전후 값 JSON']
reason varchar(500) [note: '강제 조치·조정은 사유 필수(#40·#48)']
ip varchar(45)
created_at datetime [not null]
Indexes {
(actor_type, actor_id, created_at) [name: 'ix_audit_actor']
(target_type, target_id) [name: 'ix_audit_target']
}
Note: 'append-only. admin 전 조회·변경 행위 기록'
}
Table pii_access_logs {
id bigint [pk, increment]
actor_type varchar(10) [not null, note: 'ADMIN|MEMBER - 개인정보를 누가 열람했는지']
admin_id bigint [not null, ref: > admin_accounts.id]
target_type varchar(10) [not null]
target_id bigint [not null]
fields varchar(200) [not null, note: '열람 항목(전화번호·계좌 등)']
purpose varchar(200)
created_at datetime [not null]
Indexes {
(admin_id, created_at) [name: 'ix_pii_admin', note: '관리자별 열람 조사']
(target_type, target_id, created_at) [name: 'ix_pii_target', note: '특정 고객 열람 조사(유출 대응)']
}
Note: '개인신용정보 접근기록(감독규정). 월 파티션(실PK id,created_at)·5년 DROP·방어 트리거. 기록 지점=*_enc 복호화 표시 API 전부(카탈로그 17.9b 규칙)'
}
// ==================== 13. 운영 로그·분석·대사 이력 ====================
// 로그 계층 원칙(유지보수 우선):
// - "판단·운영·분쟁 대응에 쓰는 구조적 로그" = DB (아래 테이블)
// - "대량 원시 로그"(HTTP 액세스 전건·디버그) = 파일 로그(로테이션·보존·수집)
// → 원장 DB를 로그로 비대화시키지 않으면서 분석 가능성은 확보
Table external_api_logs {
id bigint [pk, increment, note: '실PK (id, created_at) 월 파티션']
provider varchar(30) [not null, note: 'FIRMBANK|NICE|NTS(국세청)|FCM|WEBHOOK_OUT 등']
operation varchar(50) [not null, note: 'TRANSFER|BALANCE_QUERY|VERIFY_NAME|SEND_PUSH 등']
txn_id bigint [note: '관련 거래 - 리컨실러·분쟁 추적의 핵심 연결고리']
request_body text [note: '민감정보 마스킹 후 저장']
response_body text [note: '계좌번호·CI류만 선별 마스킹 후 보존(결과코드·금액·거래참조는 원문 - 증거 가치 유지, 감사 확정)']
http_status int
result_code varchar(30) [note: '기관 응답 코드']
latency_ms int
created_at datetime [not null]
Indexes {
(provider, created_at) [name: 'ix_extapi_provider']
txn_id [name: 'ix_extapi_txn']
(result_code, created_at) [name: 'ix_extapi_result', note: '기관별 오류율 분석']
}
Note: '모든 대외 호출의 요청·응답 전건 기록 - "은행은 됐다는데 우리는 왜 실패?"의 판정 근거'
}
Table pg_api_logs {
id bigint [pk, increment, note: '실PK (id, created_at) 월 파티션']
merchant_id bigint [note: '인증을 통과했을 때만 채움 - 문 앞에서 막힌 요청은 NULL']
client_id varchar(64) [note: '요청이 들고 온 매장코드 - 인증에 실패해도 남김(누가 시도했는지 실마리)']
mode varchar(10) [note: 'SANDBOX(테스트)|LIVE(실거래) - 인증 통과 시']
ip varchar(45) [not null, note: '요청이 들어온 주소']
method varchar(10) [not null]
path varchar(200) [not null, note: '예: /pg/payments']
query varchar(500)
request_body text [note: '요청 원문(JSON) - 16000자 초과분만 절단 표시']
response_body text [note: '응답 원문(JSON)']
http_status int [not null]
outcome varchar(20) [not null, note: 'OK|AUTH_FAIL(문 앞에서 막힘)|ERROR(처리 중 실패)']
reject_reason varchar(200) [note: '막힌 이유 한 줄 - 목록에서 원문을 펼치지 않고 확인']
latency_ms int
created_at datetime [not null]
Indexes {
(merchant_id, created_at) [name: 'ix_pgapi_merchant', note: '관리자 매장상세 조회 경로']
(outcome, created_at) [name: 'ix_pgapi_outcome']
(http_status, created_at) [name: 'ix_pgapi_status']
(client_id, created_at) [name: 'ix_pgapi_client', note: '인증 실패 건은 매장코드로만 찾을 수 있음']
}
Note: '쇼핑몰(매장 서버)이 부른 PG API 전건 기록 - 연동 테스트 추적·장애 판정 근거 (append-only · 월 파티션 · 5년)'
}
Table reject_logs {
id bigint [pk, increment, note: '실PK (id, created_at) 월 파티션']
principal_type varchar(10) [not null, note: 'USER|MERCHANT|ANON(비인증 시도)']
principal_id bigint [note: '비인증 시도는 NULL(감사 보완)']
ip varchar(45) [note: 'FDS 열거(ENUMERATION) 룰 - IP 기준 탐지']
action varchar(30) [not null, note: 'PAYMENT|WITHDRAW|GIFT|DEPOSIT_MATCH|QR_VERIFY|LOGIN|CARD_ISSUE 등']
reject_code varchar(40) [not null, note: 'LIMIT_EXCEEDED|INSUFFICIENT_BALANCE|FDS_BLOCK|QR_EXPIRED|CANCEL_WINDOW_EXPIRED|PIN_LOCKED 등']
context text [note: '판정 근거 JSON(한도값·잔액·룰ID 등 당시 상태)']
created_at datetime [not null]
Indexes {
(principal_type, principal_id, created_at) [name: 'ix_reject_principal']
(reject_code, created_at) [name: 'ix_reject_code', note: '거절 사유 통계 - UX·정책 개선 판단']
(ip, created_at) [name: 'ix_reject_ip', note: 'IP 열거 공격 탐지']
}
Note: '거래가 생성되기 전 거절된 시도의 기록 - transactions에 없는 "안 된 일"의 분석용. CS 문의("왜 안 돼요") 즉답 근거'
}
Table integrity_check_runs {
id bigint [pk, increment]
check_type varchar(30) [not null, note: 'DOUBLE_ENTRY|BALANCE_CHAIN|WALLET_CACHE|SEGREGATION|STALE_TXN|OUTBOX_HEALTH|ORPHAN_LEDGER(고아 원장)|SUMMARY_RECON(집계-원장 대사)|SYSTEM_WALLET(시스템 지갑 SUM 검증)']
scope varchar(100) [note: '검사 범위(일자·파티션)']
status varchar(10) [not null, note: 'PASS|FAIL|RUNNING']
checked_count bigint [note: '검사 행 수']
anomaly_count int [not null, default: 0]
started_at datetime [not null]
finished_at datetime
Indexes { (check_type, started_at) [name: 'ix_check_history'] }
Note: '#30 모니터링 대시보드의 데이터 원천 - 검증 실행 자체의 이력(언제 무엇을 검사했고 결과가 무엇이었나)'
}
Table integrity_findings {
id bigint [pk, increment]
run_id bigint [not null, ref: > integrity_check_runs.id]
target_type varchar(30) [not null, note: 'WALLET|TXN|LOT 등']
target_id bigint [not null]
detail text [not null, note: '기대값 vs 실제값']
status varchar(20) [not null, default: 'OPEN', note: 'OPEN|INVESTIGATING|RESOLVED(조정 역분개 참조)|FALSE_POSITIVE']
resolved_by bigint [ref: > admin_accounts.id]
resolved_at datetime
resolution_note varchar(500)
Indexes { (status) [name: 'ix_finding_open'] }
Note: '이상 건 개별 추적 - 발견부터 해소(역분개 조정)까지의 생애주기 기록'
}
Table daily_wallet_snapshots {
id bigint [pk, increment]
snap_date date [not null]
wallet_id bigint [not null, ref: > wallets.id]
balance decimal(15,0) [not null]
last_ledger_id bigint [not null, note: '스냅샷 시점의 마지막 원장 행 - 체인 검증 재개점']
Indexes {
(snap_date, wallet_id) [unique, name: 'ux_snap_day_wallet']
(wallet_id, snap_date) [name: 'ix_snap_wallet_latest', note: '지갑별 최신 스냅샷 조회(희소 방식)']
}
Note: '희소 스냅샷(감사 확정): 당일 원장 변동 지갑만 기록 + 월 1회 전량 베이스라인. 과거일 잔액 = 해당일 이전 최신 스냅샷. ODKU 멱등 적재'
}
Table daily_summaries {
id bigint [pk, increment]
summary_date date [not null]
metric varchar(40) [not null, note: 'DEPOSIT_SUM|WITHDRAW_SUM|PAYMENT_SUM|GIFT_SUM|CANCEL_SUM|FEE_SUM|EXPIRE_SUM|NEW_USERS|NEW_MERCHANTS|ACTIVE_USERS|FDS_ALERTS|REJECTS 등']
value decimal(18,0) [not null]
Indexes { (summary_date, metric) [unique, name: 'ux_summary_day_metric'] }
Note: 'admin 운영 대시보드·추이 그래프의 원천 - 원장 재집계 없이 즉시 조회'
}
Table app_error_logs {
id bigint [pk, increment, note: '실PK (id, created_at) 월 파티션']
severity varchar(10) [not null, note: 'ERROR|CRITICAL']
source varchar(30) [not null, note: 'API|WORKER|BATCH']
error_code varchar(50)
message varchar(1000) [not null]
trace_id varchar(64) [note: '파일 로그(전체 스택)와의 연결 키']
txn_id bigint
created_at datetime [not null]
Indexes { (severity, created_at) [name: 'ix_error_severity'] }
Note: '서버 예외 요약(운영자 화면·알림용). 전체 스택트레이스는 파일 로그 - trace_id로 연결'
}
// 사용자 충전 입금통장(회사 수납 계좌) 관리(V25) — 관리자 등록·노출, 앱 충전화면 안내.
Table deposit_accounts {
id bigint [pk, increment]
bank_name varchar(40) [not null]
account_no varchar(40) [not null]
holder varchar(60) [not null]
label varchar(60)
sort_order int [not null, default: 0]
status varchar(10) [not null, default: 'ACTIVE', note: 'ACTIVE|HIDDEN']
created_at datetime [not null]
Indexes { (status, sort_order) [name: 'ix_deposit_account_status'] }
}
// ================= 14. 발주사(외부 PG) 연동 대장·현금영수증 ================
// 발주사 API 를 부를 때마다 "무엇을 요청했고 결과가 무엇인지"를 남기는 대장입니다.
// 결과를 못 받은 건(UNKNOWN)은 자동 재시도하지 않고 관리자 [미결 건] 화면에서
// 사람이 발주사에 확인한 뒤 종결합니다 — 세금 자료·송금은 두 번 처리되면 안 되기 때문입니다.
Table vendor_requests {
id bigint [pk, increment, note: '내부 일련번호']
track_id varchar(50) [not null, unique, note: '우리가 발주사에 보낸 거래고유번호(재사용 금지 — PVAC.3500 방지)']
operation varchar(50) [not null, note: '요청 종류 (identity/send · bank/owner-check · payout/transfer · cash/issue 등)']
subject_type varchar(20) [not null, note: 'USER(회원) | MERCHANT(매장) | GUEST(가입 전)']
subject_id bigint [note: '회원/매장 번호 (가입 전에는 아직 없어 비어 있음)']
vendor_trx_id varchar(50) [note: '발주사 거래고유번호(trxId)']
vendor_seq_no varchar(50) [note: '인증 거래번호(TX_SEQ_NO) — 재전송·확인 단계에서 사용']
verify_tr_dt varchar(8) [note: '1원인증 검증 거래일자(8자리) — 확인 단계 필수 보관']
verify_tr_no varchar(20) [note: '1원인증 검증 거래번호 — 확인 단계 필수 보관']
verify_txt varchar(20) [note: '통장 적요에 찍히는 한글 단어(사용자 안내용)']
result_enc varbinary(1024) [note: '본인인증 결과를 우리 열쇠로 잠근 값(이름·생년월일·성별·통신사·내외국인·CI). 사용 후 삭제']
result_at datetime [note: '알림으로 결과가 도착한 시각']
status varchar(20) [not null, note: 'REQUESTED=요청함 | SUCCEEDED=성공 | FAILED=실패 | UNKNOWN=결과확인불가(사람 확인 필요)']
result_code varchar(30) [note: '발주사 결과코드 (예: A000, DV50, PVAC.3500)']
result_message varchar(200) [note: '발주사 결과 메시지(개인정보 제외)']
created_at datetime [not null, note: '요청 시각']
updated_at datetime [note: '결과 반영 시각']
Note: '발주사 요청 대장 — 결과 미확정(UNKNOWN)은 자동 재시도 금지, 관리자가 확인 후 종결'
}
// 결제한 손님에게 매장이 발행하는 현금영수증입니다. 부가세 유무와 무관하게 발행합니다.
// 관리자도 조회·대리 취소할 수 있고, 대리 발행은 [거래 관리]에서 결제 건을 골라 진행합니다.
Table cash_receipts {
id bigint [pk, increment]
txn_id bigint [not null, note: '대상 결제 거래 번호(transactions.id)']
merchant_id bigint [not null, note: '발행한 매장 번호 - 남의 매장 건을 발행하지 못하게 대조용']
track_id varchar(50) [not null, unique, note: '발행 요청 시 우리가 만든 거래고유번호(재사용 금지)']
cancel_track_id varchar(50) [note: '취소 요청 시 우리가 만든 거래고유번호(발행과 달라야 함)']
vendor_trx_id varchar(50) [note: '발주사 거래고유번호(trxId) - 취소할 때 rootTrxId 로 넣습니다']
auth_code varchar(50) [note: '발급기관 승인번호(authCd) - 고객에게 보여 주는 값']
auth_type varchar(2) [not null, note: '인증구분 03=개인(휴대폰) · 04=법인(사업자번호)']
identity_enc varbinary(255) [not null, note: '인증값(휴대폰번호/사업자번호) - 개인정보라 암호화 보관']
identity_hash char(64) [note: '인증값 SHA-256 - 원문 없이 같은 값을 찾기 위한 검색용']
usage_type varchar(20) [not null, note: '발행용도 소득공제용 | 지출증빙용 | 자진발급']
cust_name varchar(100) [not null, note: '고객명(발주사 필수 항목)']
product_name varchar(200) [not null, note: '상품명(pdtName)']
amount decimal(15,0) [not null, note: '총 결제금액 - 취소할 때도 이 금액을 그대로 보냅니다']
supply_amount decimal(15,0) [note: '공급가액(발주사 회신값)']
vat_amount decimal(15,0) [note: '부가세(발주사 회신값). 면세 매장이면 0 이거나 비어 있습니다']
tax_type varchar(20) [note: '과세구분(발주사 회신값) 예: 과세 · 면세']
status varchar(20) [not null, note: 'UNKNOWN=결과 모름 · ISSUED=발행완료 · FAILED=발행실패 · CANCELED=취소완료 · CANCEL_FAILED=취소실패']
result_code varchar(30) [note: '발주사 결과코드(예: 0000)']
result_message varchar(200) [note: '발주사 결과 메시지']
issued_by bigint [note: '발행을 누른 매장 계정 번호. 자동 취소 건은 비어 있습니다']
issued_at datetime [note: '발행 완료 시각']
canceled_at datetime [note: '취소 완료 시각']
created_at datetime [not null]
updated_at datetime [not null]
alive_key bigint [unique, note: '생성 칸(VIRTUAL): status 가 UNKNOWN·ISSUED·CANCEL_FAILED 일 때만 txn_id 를 갖습니다. 이 유니크 하나로 "결제 1건당 살아있는 영수증 최대 1장"이 DB 차원에서 보장됩니다(동시요청 실측 검증)']
Note: '현금영수증 - 결제 1건당 살아있는 영수증은 최대 1장(alive_key 유니크). 결과 미확정(UNKNOWN)은 재발행 차단'
}
// ============================ 15. 은행 목록 ===========================
// 앱·매장앱·관리자가 계좌 등록 화면의 "은행 고르기"에 쓰는 목록입니다.
//
// 왜 표로 두나요?
// 예전에는 앱(kBankNames)과 관리자(BANK_NAMES)에 따로 적혀 있었습니다. 그 결과
// · 앱에만 없는 은행(우체국·새마을금고 등)을 쓰는 사람은 계좌 등록 자체가 불가능했고,
// · 같은 코드인데 이름이 달랐으며(003 = 'IBK기업' / '기업'),
// · 은행이 하나 늘 때마다 두 곳을 고치고 앱을 새로 배포해야 했습니다.
// 이제 이 표가 유일한 출처이고, 모두 GET /app/content/banks 로 받아 씁니다.
//
// ★ 코드는 화면에 보여 주지 않습니다. 사람에게는 이름만 보이고, 고른 항목의 코드를 서버로 보냅니다.
Table banks {
code varchar(3) [pk, note: '금융결제원 표준 은행 코드 3자리 - 시스템 내부 값(화면에 노출 금지)']
name varchar(40) [not null, note: '사람에게 보여 줄 은행 이름']
sort_order int [not null, default: 0, note: '선택 목록에 나오는 차례(작을수록 위). 자주 쓰는 은행을 앞에 둡니다']
status varchar(10) [not null, default: 'ACTIVE', note: 'ACTIVE=목록에 보임 | HIDDEN=목록에서 감춤. 합병·폐업 은행은 지우지 않고 감춥니다 — 이미 그 은행으로 등록된 계좌의 코드가 살아 있어야 과거 기록이 깨지지 않습니다']
created_at datetime [not null]
Indexes { (status, sort_order) [name: 'ix_banks_list', note: '목록 조회 — 정렬까지 인덱스로 해결'] }
Note: '은행 목록(단일 출처) - 앱·매장앱·관리자가 모두 이 표를 받아 씁니다'
}
// ========================= 16. 기능별 이용제한 ========================
// 지금까지는 "전부 열림(ACTIVE)" 아니면 "전부 닫힘(SUSPENDED)" 둘뿐이었습니다.
// 실제 운영에서는 이런 경우가 생깁니다.
// · 출금만 막고 결제는 계속 (계좌 도용 의심)
// · 선물만 막고 싶다 (선물 되팔이 의심)
// · 매장 정산만 멈추고 결제는 받게 (정산 계좌 확인 중)
//
// ★ "전체 정지"는 여기에 넣지 않습니다 — users.status / merchants.status 가 이미 합니다.
// 같은 일을 두 곳에 두면 어느 쪽이 진짜인지 알 수 없게 됩니다. 이 표는 개별 기능만 다룹니다.
// ★ 해제해도 행을 지우지 않습니다(released_at 에 시각 기록) — 소명 자료로 남아야 합니다.
Table account_restrictions {
id bigint [pk, increment]
principal_type varchar(10) [not null, note: 'USER(회원) | MERCHANT(매장)']
principal_id bigint [not null, note: '회원 번호 또는 매장 번호']
feature varchar(20) [not null, note: '막을 기능 — 회원: DEPOSIT·WITHDRAW·GIFT·PAYMENT / 매장: PAYMENT·SETTLEMENT. 조합은 chk_restriction_feature 가 강제(회원에게 정산 제한 불가)']
reason varchar(200) [not null, note: '막은 이유 - 필수. 나중에 왜 막았는지 설명할 수 있어야 합니다']
created_by bigint [not null, note: '막은 관리자']
created_at datetime [not null]
released_by bigint [note: '푼 관리자(아직 막혀 있으면 비어 있음)']
released_at datetime [note: '푼 시각. 비어 있으면 지금도 막힌 상태']
release_reason varchar(200) [note: '푼 이유']
alive_key varchar(40) [unique, note: '생성 칸(VIRTUAL): 아직 안 푼 것만 principal_type:id:feature 값을 갖습니다. 이 유니크로 "같은 대상·기능에 살아 있는 제한은 하나"가 DB 차원에서 보장됩니다 — 두 번 걸면 한 번 풀어도 안 풀립니다']
Indexes {
(principal_type, principal_id, released_at) [name: 'ix_restriction_lookup', note: '거래 직전 문지기 조회 — EXPLAIN 확인(type=ref)']
}
Note: '기능별 이용제한 - 전체 정지는 users.status/merchants.status 담당, 이 표는 개별 기능만'
}
// ========================== 17. 포인트 압류 ==========================
// 사고(도용·분쟁·수사 협조)가 나면 그 돈이 빠져나가지 못하게 묶어 둡니다.
// 조사 결과에 따라 돌려주거나(반환) 없앱니다(소각).
//
// ★ 어떻게 묶나요 — 돈을 실제로 옮깁니다.
// 회원/매장 지갑 → SEIZED 시스템 지갑. 잔액이 그만큼 줄어 어디에서도 쓸 수 없습니다.
// ("쓸 수 있는 금액"을 따로 계산하는 방식은 돈 쓰는 곳마다 검사를 넣어야 하고,
// 한 곳만 빠뜨려도 묶어 둔 돈이 새어 나갑니다)
//
// ★ 전부 아니면 전무 — 부분 반환·부분 소각은 없습니다.
// 나눠 처리해야 하면 압류를 나눠서 겁니다. 돈 계산에 갈래가 늘수록 어긋날 자리가 늘어납니다.
//
// ★ 회원은 포인트 덩어리(point_lots)도 함께 다룹니다.
// 묶을 때 만료가 가까운 것부터 덜어 내고, 돌려줄 때 원래 유효기간을 이어받아 되살립니다.
// 매장 지갑에는 덩어리가 없어 잔액만 옮깁니다.
//
// 거래는 ADJUST 계열입니다 — SEIZE(묶기) · SEIZE_RETURN(반환) · SEIZE_BURN(소각).
Table seizures {
id bigint [pk, increment]
principal_type varchar(10) [not null, note: 'USER(회원) | MERCHANT(매장)']
principal_id bigint [not null, note: '회원 번호 또는 매장 번호']
wallet_id bigint [not null, ref: > wallets.id, note: '묶은 지갑(회원은 카드 지갑, 매장은 매장 지갑)']
amount decimal(15,0) [not null, note: 'CHECK(amount > 0). 묶은 금액']
reason varchar(200) [not null, note: '묶은 이유 - 필수. 관리자 내부용이며 앱에는 보여 주지 않습니다']
status varchar(10) [not null, default: 'HELD', note: 'HELD(보관중)|RETURNED(돌려줌)|BURNED(소각함) - CHECK 로 강제']
seize_txn_id bigint [not null, ref: > transactions.id, note: '묶을 때 만든 거래(ADJUST/SEIZE)']
resolve_txn_id bigint [ref: > transactions.id, note: '반환·소각 거래(ADJUST/SEIZE_RETURN·SEIZE_BURN)']
created_by bigint [not null, note: '묶은 관리자']
created_at datetime [not null]
resolved_by bigint [note: '끝낸 관리자']
resolved_at datetime [note: '끝낸 시각. 비어 있으면 아직 조사 중']
resolve_reason varchar(200) [note: '반환·소각한 이유']
Indexes {
(principal_type, principal_id, status) [name: 'ix_seizure_holder', note: '앱·관리자의 "지금 묶인 돈" 조회']
}
Note: '포인트 압류 대장 - 앱에는 묶인 금액만 보여 주고 사유는 내부용'
}