40766d805d
증상: 상세페이지를 적용해도 편집기에 수정 전 소스가 보이고 한참 뒤에야 반영됨. 원인은 우리 캐시가 아니라(전부 no-store) 카페24 관리자 API 가 PUT 뒤 한동안 GET 에서 예전 값을 돌려주는 읽기 지연. 예전 코드는 2.4초만 기다린 뒤 GET 값을 그대로 믿어 예전 소스 표시·지문 충돌 오판·예전 값 백업이 생겼다. - 상세설명: 쓰기 성공 시 MANUAL/SCHEDULED revision 을 기준으로, 카페24 값이 유예시간 안의 revision 중 하나와 같으면 지연(pending)으로 보고 마지막 쓰기를 표시·지문 기준으로 쓴다. 모르는 값이면 외부 변경(external). store.resolve_description / db.revision_digests(md5) / 배너 2종. - 적용(apply)은 유효 현재값으로 BACKUP·지문 대조·변경없음 판정. 재조회 확인 결과는 감사로그에만 남긴다. - 스칼라(상품명·가격·이미지·진열/판매): PUT 응답을 cafe24_products. last_write_snapshot(JSONB, 마이그레이션 004)에 남기고 GET 의 updated_date 가 그보다 이전이면 스냅샷으로 덮어씀. 옵션/품목도 섹션별 스냅샷. - 3분할 화면: 목록 | 편집기 | 상품 정보 패널(_side.html, /pane 이 두 조각을 한 응답으로). routes_product_info.py JSON API — 상품명/판매가/공급가/ 소비자가, 대표이미지 업로드(POST /admin/products/images → PUT detail_image + image_upload_type=A), 옵션 생성/이름·썸네일·표시방식 수정/삭제, 품목 자체코드·추가금액·진열·판매 일괄 수정. 화면은 PUT 응답으로 그린다. - client.delete/timeout, products.upload_images·options·variants 래퍼. - 유닛테스트 21건 추가(88 통과), 문서(CAFE24_MODULE 3-3/3-4, DATABASES, .env.example CAFE24_READ_LAG_GRACE_MIN) 갱신. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
242 lines
13 KiB
PL/PgSQL
242 lines
13 KiB
PL/PgSQL
-- =====================================================================
|
|
-- 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,
|
|
-- 쓰기 직후 스냅샷(카페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;
|