feat(cafe24): 편집기에 상품 다이렉트 주소 + 클립보드 복사
상세 화면 상단에 고객이 보는 상세페이지 주소를 보여주고 「주소 복사」·「쇼핑몰에서
열기」를 붙였다.
https://miras.co.kr/product/detail.html?product_no=119
도메인은 하드코딩하지 않고 CAFE24_SHOP_URL 로 받는다. 커스텀 도메인은 mall_id 로
알 수 없기 때문이다. 미설정 시 카페24 기본 도메인(https://<mall_id>.cafe24.com)으로
대체해 환경변수가 없어도 항상 유효한 주소가 나온다. 스킴 누락·끝 슬래시도 정규화한다.
주소 칸은 readonly <input> 이라 기존 복사 버튼(data-copy)이 값을 그대로 읽어간다 —
클립보드 로직을 새로 만들지 않았다. 클립보드 API 가 막힌 환경에서는 입력칸 선택으로
대체되고, 칸을 클릭하면 전체 선택된다.
CAFE24_SHOP_URL 은 .env.example 과 문서에 설명을 함께 넣었다(신규 환경변수 규칙).
검증: 유닛테스트 61개 통과(신규 2개 — 스킴/슬래시 유무 3가지 입력에서 같은 주소,
미설정 시 카페24 도메인 대체). 렌더 확인 — 주소 표시·복사 버튼·새 창 열기(noopener),
주소를 만들 수 없으면 줄 자체를 숨김.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,12 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
|
|||||||
# CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
|
# CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
|
||||||
# CAFE24_API_VERSION=2026-03-01
|
# CAFE24_API_VERSION=2026-03-01
|
||||||
#
|
#
|
||||||
|
# 고객이 보는 쇼핑몰 주소. 상품관리 화면에서 상세페이지 다이렉트 주소를 만들 때 쓴다
|
||||||
|
# (예: https://miras.co.kr/product/detail.html?product_no=119).
|
||||||
|
# 커스텀 도메인은 mall_id 로 알 수 없어 직접 지정해야 한다.
|
||||||
|
# 미설정 시 카페24 기본 도메인(https://<mall_id>.cafe24.com)으로 대체된다.
|
||||||
|
# CAFE24_SHOP_URL=https://miras.co.kr
|
||||||
|
#
|
||||||
# access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키.
|
# access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키.
|
||||||
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
|
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
|
||||||
# 카페24 재연결(재인증)이 필요하다.
|
# 카페24 재연결(재인증)이 필요하다.
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class Cafe24Config:
|
|||||||
api_version: str
|
api_version: str
|
||||||
token_secret: str
|
token_secret: str
|
||||||
scopes: tuple[str, ...]
|
scopes: tuple[str, ...]
|
||||||
|
# 쇼핑몰 표시 주소(고객이 보는 도메인). 커스텀 도메인은 mall_id 로 알 수 없어
|
||||||
|
# 환경변수로 받는다. 미설정 시 카페24 기본 도메인으로 대체한다.
|
||||||
|
shop_url: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
@@ -78,6 +81,23 @@ class Cafe24Config:
|
|||||||
def scope_param(self) -> str:
|
def scope_param(self) -> str:
|
||||||
return ",".join(self.scopes)
|
return ",".join(self.scopes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shop_base(self) -> str:
|
||||||
|
"""고객이 보는 쇼핑몰 주소(끝 슬래시 없음).
|
||||||
|
|
||||||
|
`CAFE24_SHOP_URL` 이 없으면 카페24 기본 도메인을 쓴다 — 커스텀 도메인을
|
||||||
|
모르더라도 항상 유효한 주소가 나온다.
|
||||||
|
"""
|
||||||
|
url = (self.shop_url or "").strip().rstrip("/")
|
||||||
|
if url:
|
||||||
|
return url if "://" in url else f"https://{url}"
|
||||||
|
return f"https://{self.mall_id}.cafe24.com" if self.mall_id else ""
|
||||||
|
|
||||||
|
def product_url(self, product_no: int | str) -> str:
|
||||||
|
"""상품 상세페이지 다이렉트 주소."""
|
||||||
|
base = self.shop_base
|
||||||
|
return f"{base}/product/detail.html?product_no={product_no}" if base else ""
|
||||||
|
|
||||||
|
|
||||||
def load_config(*, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> Cafe24Config:
|
def load_config(*, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> Cafe24Config:
|
||||||
"""환경변수에서 설정을 읽는다. 값이 없어도 예외를 던지지 않는다.
|
"""환경변수에서 설정을 읽는다. 값이 없어도 예외를 던지지 않는다.
|
||||||
@@ -93,4 +113,5 @@ def load_config(*, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> Cafe24Config:
|
|||||||
api_version=_env("CAFE24_API_VERSION", DEFAULT_API_VERSION),
|
api_version=_env("CAFE24_API_VERSION", DEFAULT_API_VERSION),
|
||||||
token_secret=_env("CAFE24_TOKEN_SECRET"),
|
token_secret=_env("CAFE24_TOKEN_SECRET"),
|
||||||
scopes=scopes,
|
scopes=scopes,
|
||||||
|
shop_url=_env("CAFE24_SHOP_URL"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
|
|||||||
info = products.normalize_product(product) if product else {}
|
info = products.normalize_product(product) if product else {}
|
||||||
return {
|
return {
|
||||||
"product_no": product_no,
|
"product_no": product_no,
|
||||||
|
# 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인)
|
||||||
|
"product_url": api.config.product_url(product_no),
|
||||||
"info": {
|
"info": {
|
||||||
**info,
|
**info,
|
||||||
"price": str(product.get("price") or ""),
|
"price": str(product.get("price") or ""),
|
||||||
|
|||||||
@@ -29,6 +29,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# 고객이 보는 상세페이지 주소. readonly input 이라 기존 복사 버튼(data-copy)이
|
||||||
|
그대로 동작한다(값을 box.value 로 읽는다). #}
|
||||||
|
{% if product_url %}
|
||||||
|
<div class="cf24-url-row">
|
||||||
|
<input class="cf24-url" id="cf24-url" type="text" readonly value="{{ product_url }}"
|
||||||
|
onclick="this.select();" aria-label="상품 상세페이지 주소" />
|
||||||
|
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-url">주소 복사</button>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="{{ product_url }}"
|
||||||
|
target="_blank" rel="noopener noreferrer">쇼핑몰에서 열기</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if desc %}
|
{% if desc %}
|
||||||
<form class="cf24-editor-form" method="post"
|
<form class="cf24-editor-form" method="post"
|
||||||
action="/cafe24/products/{{ product_no }}/apply"
|
action="/cafe24/products/{{ product_no }}/apply"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814q" />
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260814r" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814q" />
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260814r" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814q" />
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260814r" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -134,6 +134,36 @@ def test_config_basics():
|
|||||||
assert "mall.write_product" in config.scope_param
|
assert "mall.write_product" in config.scope_param
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_url_uses_shop_url_env():
|
||||||
|
"""다이렉트 주소는 CAFE24_SHOP_URL 기준. 스킴·끝 슬래시가 없어도 맞춘다."""
|
||||||
|
saved = os.environ.get("CAFE24_SHOP_URL")
|
||||||
|
try:
|
||||||
|
for value in ("https://miras.co.kr", "miras.co.kr", "https://miras.co.kr/"):
|
||||||
|
os.environ["CAFE24_SHOP_URL"] = value
|
||||||
|
config = _config()
|
||||||
|
assert (
|
||||||
|
config.product_url(119)
|
||||||
|
== "https://miras.co.kr/product/detail.html?product_no=119"
|
||||||
|
), value
|
||||||
|
finally:
|
||||||
|
if saved is None:
|
||||||
|
os.environ.pop("CAFE24_SHOP_URL", None)
|
||||||
|
else:
|
||||||
|
os.environ["CAFE24_SHOP_URL"] = saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_url_falls_back_to_cafe24_domain():
|
||||||
|
"""CAFE24_SHOP_URL 이 없어도 항상 유효한 주소가 나와야 한다."""
|
||||||
|
saved = os.environ.pop("CAFE24_SHOP_URL", None)
|
||||||
|
try:
|
||||||
|
assert _config().product_url(119) == (
|
||||||
|
"https://testmall.cafe24.com/product/detail.html?product_no=119"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if saved is not None:
|
||||||
|
os.environ["CAFE24_SHOP_URL"] = saved
|
||||||
|
|
||||||
|
|
||||||
def test_authorize_url_has_state_and_no_secret():
|
def test_authorize_url_has_state_and_no_secret():
|
||||||
url = oauth.build_authorize_url(_config(), state="STATE123")
|
url = oauth.build_authorize_url(_config(), state="STATE123")
|
||||||
assert url.startswith("https://testmall.cafe24api.com/api/v2/oauth/authorize?")
|
assert url.startswith("https://testmall.cafe24api.com/api/v2/oauth/authorize?")
|
||||||
|
|||||||
@@ -624,3 +624,26 @@
|
|||||||
color: var(--color-rich-black, #0a0a0a);
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
background: var(--color-canvas-white, #fff);
|
background: var(--color-canvas-white, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 상품 다이렉트 주소 ── */
|
||||||
|
.cf24-url-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-url {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 var(--sp-10, 10px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
background: var(--color-ghost-gray, #f6f8fa);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
font-family: var(--font-geist-mono, ui-monospace, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ app/modules/cafe24/ ← 상품관리 모듈
|
|||||||
덕분에 목록 전체가 대상이 된다.
|
덕분에 목록 전체가 대상이 된다.
|
||||||
- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지
|
- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지
|
||||||
않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
|
않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
|
||||||
|
- 편집기 상단에 **상품 다이렉트 주소**(고객이 보는 상세페이지 URL)와 「주소 복사」·
|
||||||
|
「쇼핑몰에서 열기」를 둔다. 주소는 `CAFE24_SHOP_URL` 기준으로 만들고, 미설정 시
|
||||||
|
카페24 기본 도메인(`https://<mall_id>.cafe24.com`)으로 대체한다 — 커스텀 도메인은
|
||||||
|
`mall_id` 로 알 수 없으므로 환경변수가 필요하다.
|
||||||
- 편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 **저장 안 됨 경고**가 뜬다.
|
- 편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 **저장 안 됨 경고**가 뜬다.
|
||||||
- **캐시 금지.** 화면·조각 응답에 `Cache-Control: no-store` 를 붙이고 조각 fetch 에도
|
- **캐시 금지.** 화면·조각 응답에 `Cache-Control: no-store` 를 붙이고 조각 fetch 에도
|
||||||
`cache: "no-store"` 를 건다. 캐시된 조각이 다시 그려지면 카페24 관리자에서 값을 바꾼
|
`cache: "no-store"` 를 건다. 캐시된 조각이 다시 그려지면 카페24 관리자에서 값을 바꾼
|
||||||
@@ -318,6 +322,7 @@ CAFE24_CLIENT_SECRET=...
|
|||||||
CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
|
CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
|
||||||
CAFE24_API_VERSION=2026-03-01
|
CAFE24_API_VERSION=2026-03-01
|
||||||
CAFE24_TOKEN_SECRET=<openssl rand -hex 32>
|
CAFE24_TOKEN_SECRET=<openssl rand -hex 32>
|
||||||
|
CAFE24_SHOP_URL=https://miras.co.kr # 선택 — 다이렉트 주소용 커스텀 도메인
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5-4. 재기동 + 권한 부여
|
### 5-4. 재기동 + 권한 부여
|
||||||
|
|||||||
Reference in New Issue
Block a user