feat(cafe24): 상품 상세페이지 관리 모듈 Phase 1
카페24 관리자에 직접 접속하지 않고 상품 상세페이지(description HTML)를 편집·예약 적용·복원하기 위한 모듈의 기반을 만든다. Phase 1 은 공통 Integration 계층, cafe24_db, OAuth 연결 화면까지다. 카페24 OAuth/API 클라이언트를 상품관리 모듈 안에 두지 않고 app/integrations/cafe24/ 로 분리했다. 향후 추가할 주문관리(주문 조회·송장 일괄등록·취소/반품/교환)가 같은 토큰과 클라이언트를 그대로 재사용해야 하기 때문이다. 라우터에서 httpx 를 직접 부르지 않고 Cafe24Client 만 쓰게 해서 재시도·rate limit·API 로그·토큰 갱신을 한 곳에 모았다. 토큰은 Fernet 으로 암호화해 저장한다(CAFE24_TOKEN_SECRET). DB 덤프가 유출돼도 access/refresh token 이 평문으로 남지 않게 하기 위함이며, API 로그와 연결 상태 화면에는 토큰·시크릿을 일절 기록/표시하지 않는다. 토큰 갱신은 행 잠금(SELECT ... FOR UPDATE) 안에서 한다. 카페24는 refresh token 을 회전시키므로, 이후 추가될 예약 worker 컨테이너와 web 컨테이너가 동시에 갱신하면 한쪽 토큰이 무효화된다. 기존 파일 변경은 목록에 한 줄씩 추가하는 형태로 44줄뿐이며 기존 라우트· 테이블·인증 로직은 건드리지 않았다. CAFE24_DB_URL 미설정 시 store 가 None 이라 앱은 정상 기동하고 모듈만 "설정 필요" 안내를 표시한다. 가드 헬퍼를 common.py 로 분리한 것은 router.py 가 routes_system.py 를 include 하는 구조에서 순환 import 가 생기기 때문이다. 검증: 신규 테스트 16개 통과(암호화 왕복, 토큰 만료·자동갱신, 상태 노출 시 토큰 미유출, 재시도 예산, 예약 상태 전이). dispatch 기존 테스트 9개 통과. cafe24_db_init.sql 은 로컬에 Docker 가 없어 미실행 — 서버 적용 시 확인 필요. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
-- =====================================================================
|
||||
-- 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:<APP_PWD>@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,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
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;
|
||||
Reference in New Issue
Block a user