-- ===================================================================== -- cafe24_db 초기화 스크립트 (PostgreSQL) — 카페24 상품 상세페이지 관리 -- ===================================================================== -- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다. -- -- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음. -- -- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db): -- -- 1) DB / 역할 / 스키마 생성 (superuser 로 1회) -- read -s -p "cafe24_app password: " APP_PWD; echo -- docker exec -i postgres-db psql -U postgres \ -- -v app_password="$APP_PWD" \ -- < scripts/sql/cafe24_db_init.sql -- -- 2) main-app .env 에 연결 정보 등록 -- CAFE24_DB_URL=postgresql://cafe24_app:@postgres-db:5432/cafe24_db -- -- 3) main-app 재기동 -- cd /opt/www/main && docker compose up -d --build web -- -- 주의: -- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만). -- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달. -- - OAuth 토큰(access/refresh)은 애플리케이션에서 Fernet 으로 암호화한 뒤 -- 저장한다. 키는 .env 의 CAFE24_TOKEN_SECRET. 이 DB 에 평문 토큰은 없다. -- - cafe24_api_logs 에는 Authorization 헤더/토큰/client_secret 을 절대 -- 기록하지 않는다(엔드포인트·상태코드·소요시간·오류메시지만). -- - 향후 카페24 주문관리 모듈도 이 DB(특히 cafe24_oauth_tokens)를 재사용한다. -- ===================================================================== \set ON_ERROR_STOP on -- DB 가 없을 때만 생성 SELECT 'CREATE DATABASE cafe24_db ENCODING ''UTF8'' TEMPLATE template0' WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cafe24_db') \gexec -- 앱 전용 로그인 역할 SELECT 'CREATE ROLE cafe24_app LOGIN PASSWORD ' || quote_literal(:'app_password') WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cafe24_app') \gexec -- 항상 최신 비밀번호로 동기화 SELECT 'ALTER ROLE cafe24_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password') \gexec GRANT CONNECT ON DATABASE cafe24_db TO cafe24_app; -- cafe24_db 컨텍스트로 전환 \connect cafe24_db -- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ── CREATE OR REPLACE FUNCTION cafe24_set_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- ════════════════════════════════════════════════════════════ -- 1) OAuth 토큰 (쇼핑몰 1개당 1행) -- access_token / refresh_token 은 Fernet 암호문(TEXT)으로 저장한다. -- 상품관리 + 향후 주문관리가 공유한다. -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS cafe24_oauth_tokens ( id BIGSERIAL PRIMARY KEY, mall_id TEXT NOT NULL UNIQUE, access_token TEXT NOT NULL DEFAULT '', refresh_token TEXT NOT NULL DEFAULT '', access_token_expires_at TIMESTAMPTZ, refresh_token_expires_at TIMESTAMPTZ, scopes TEXT NOT NULL DEFAULT '', last_refreshed_at TIMESTAMPTZ, last_error TEXT NOT NULL DEFAULT '', connected_by TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DROP TRIGGER IF EXISTS trg_cafe24_oauth_tokens_updated ON cafe24_oauth_tokens; CREATE TRIGGER trg_cafe24_oauth_tokens_updated BEFORE UPDATE ON cafe24_oauth_tokens FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at(); -- ════════════════════════════════════════════════════════════ -- 2) 상품 캐시 (source of truth 는 언제나 Cafe24. 목록/검색 속도용) -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS cafe24_products ( id BIGSERIAL PRIMARY KEY, product_no BIGINT NOT NULL UNIQUE, product_code TEXT NOT NULL DEFAULT '', product_name TEXT NOT NULL DEFAULT '', display BOOLEAN NOT NULL DEFAULT TRUE, selling BOOLEAN NOT NULL DEFAULT TRUE, last_synced_at TIMESTAMPTZ, -- 쓰기 직후 스냅샷(카페24 읽기 지연 보정, 마이그레이션 004) last_write_snapshot JSONB, last_written_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE cafe24_products ADD COLUMN IF NOT EXISTS last_write_snapshot JSONB, ADD COLUMN IF NOT EXISTS last_written_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_cafe24_products_name ON cafe24_products (product_name); CREATE INDEX IF NOT EXISTS idx_cafe24_products_code ON cafe24_products (product_code); DROP TRIGGER IF EXISTS trg_cafe24_products_updated ON cafe24_products; CREATE TRIGGER trg_cafe24_products_updated BEFORE UPDATE ON cafe24_products FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at(); -- ════════════════════════════════════════════════════════════ -- 3) 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다) -- revision_type: -- SYNC Cafe24 에서 읽어온 현재값 스냅샷 -- DRAFT 저장만 한 초안(미적용) -- BACKUP Cafe24 에 쓰기 직전 자동 백업 ← 복원 기준 -- MANUAL 즉시 적용한 내용 -- SCHEDULED 예약으로 적용한 내용 -- ROLLBACK 과거 버전을 되돌린 내용 -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS cafe24_product_revisions ( id BIGSERIAL PRIMARY KEY, product_no BIGINT NOT NULL, html_content TEXT NOT NULL DEFAULT '', revision_type TEXT NOT NULL DEFAULT 'DRAFT', memo TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT chk_cafe24_revision_type CHECK ( revision_type IN ('SYNC','DRAFT','BACKUP','MANUAL','SCHEDULED','ROLLBACK') ) ); CREATE INDEX IF NOT EXISTS idx_cafe24_revisions_product ON cafe24_product_revisions (product_no, created_at DESC, id DESC); -- ════════════════════════════════════════════════════════════ -- 4) 예약 작업 -- 한 상품 = 한 행. 일괄 예약은 parent_job_id 로 묶되 행은 개별이므로 -- 한 상품 실패가 나머지를 막지 않는다. -- end_at/end_action: 프로모션 종료 후 자동 복원용. -- restore = 적용 직전 BACKUP(restore_revision_id)으로 되돌림 -- revision = end_revision_id 를 적용 -- restore_revision_id 는 예약 "실행 순간" Cafe24 에서 다시 읽어 만든 -- BACKUP revision 을 가리킨다(예약 생성 시점 값이 아님). -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS cafe24_product_schedules ( id BIGSERIAL PRIMARY KEY, product_no BIGINT NOT NULL, revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE RESTRICT, scheduled_at TIMESTAMPTZ NOT NULL, status TEXT NOT NULL DEFAULT 'PENDING', retry_count INTEGER NOT NULL DEFAULT 0, next_retry_at TIMESTAMPTZ, last_error TEXT NOT NULL DEFAULT '', restore_revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE SET NULL, end_at TIMESTAMPTZ, end_action TEXT NOT NULL DEFAULT '', end_revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE SET NULL, parent_job_id TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, CONSTRAINT chk_cafe24_schedule_status CHECK ( status IN ('PENDING','PROCESSING','SUCCESS','FAILED','CANCELLED') ), CONSTRAINT chk_cafe24_schedule_end_action CHECK ( end_action IN ('','restore','revision') ) ); -- worker 의 due 조회 인덱스 (PENDING + 시간순) CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_due ON cafe24_product_schedules (scheduled_at) WHERE status = 'PENDING'; CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_product ON cafe24_product_schedules (product_no, scheduled_at DESC); CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_parent ON cafe24_product_schedules (parent_job_id); DROP TRIGGER IF EXISTS trg_cafe24_schedules_updated ON cafe24_product_schedules; CREATE TRIGGER trg_cafe24_schedules_updated BEFORE UPDATE ON cafe24_product_schedules FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at(); -- ════════════════════════════════════════════════════════════ -- 5) 작업 감사 로그 (누가 무엇을 바꿨나) -- worker 가 수행한 작업은 actor='SCHEDULER'. -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS cafe24_audit_logs ( id BIGSERIAL PRIMARY KEY, actor TEXT NOT NULL DEFAULT '', action TEXT NOT NULL DEFAULT '', product_no BIGINT, revision_id BIGINT, schedule_id BIGINT, result TEXT NOT NULL DEFAULT '', detail TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_cafe24_audit_created ON cafe24_audit_logs (created_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_cafe24_audit_product ON cafe24_audit_logs (product_no, created_at DESC); -- ════════════════════════════════════════════════════════════ -- 6) Cafe24 API 호출 로그 (실패 분석용 최소 정보) -- ⚠️ Authorization 헤더 / access_token / refresh_token / client_secret 은 -- 절대 저장하지 않는다. -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS cafe24_api_logs ( id BIGSERIAL PRIMARY KEY, endpoint TEXT NOT NULL DEFAULT '', method TEXT NOT NULL DEFAULT '', product_no BIGINT, http_status INTEGER, result TEXT NOT NULL DEFAULT '', error_message TEXT NOT NULL DEFAULT '', duration_ms INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_cafe24_api_logs_created ON cafe24_api_logs (created_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_cafe24_api_logs_status ON cafe24_api_logs (http_status); -- ════════════════════════════════════════════════════════════ -- 7) 권한 (cafe24_app: CRUD only, DDL 없음) -- ════════════════════════════════════════════════════════════ GRANT USAGE ON SCHEMA public TO cafe24_app; GRANT SELECT, INSERT, UPDATE, DELETE ON cafe24_oauth_tokens, cafe24_products, cafe24_product_revisions, cafe24_product_schedules, cafe24_audit_logs, cafe24_api_logs TO cafe24_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cafe24_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cafe24_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO cafe24_app; SELECT 'cafe24_db ready' AS status;