bootpay-backend 2.7.0__tar.gz → 2.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/PKG-INFO +4 -2
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/README.md +3 -1
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/__init__.py +66 -1
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/commerce_resource.py +122 -26
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/__init__.py +25 -1
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_message.py +90 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_official.py +90 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_optout.py +97 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_send.py +103 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_sender.py +144 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_template.py +238 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_webhook.py +110 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/faq.py +141 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/inquiry.py +192 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/notice.py +146 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/product_qna.py +216 -0
- bootpay_backend-2.9.0/bootpay_backend/commerce/modules/product_review.py +204 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/types/__init__.py +218 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/rest_client.py +3 -2
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/PKG-INFO +4 -2
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/SOURCES.txt +15 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/setup.py +1 -1
- bootpay_backend-2.9.0/tests/commerce/test_alimtalk.py +537 -0
- bootpay_backend-2.9.0/tests/commerce/test_board.py +464 -0
- bootpay_backend-2.9.0/tests/pg/test_cash_receipt_wire_format.py +54 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/LICENSE +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/__init__.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/bankcode.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/cart.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/category.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/coupon.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/invoice.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/mall_setting.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order_cancel.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order_subscription.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order_subscription_adjustment.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order_subscription_bill.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order_subscription_request.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/point.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/product.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/store.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/user.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/user_group.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/webhook.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/dependency_links.txt +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/not-zip-safe +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/requires.txt +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/top_level.txt +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/setup.cfg +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/__init__.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/__init__.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_auth_contract.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_mall_setting.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_order.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_order_subscription_charge.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_product.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_product_mall.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_store.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_token.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_user.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_user_mall.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_webhook.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_wire_format.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/conftest.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/__init__.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_auth.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_billing.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_cash_receipt.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_legacy_compatibility.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_payment.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_sequential_billing_key.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_token.py +0 -0
- {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_wallet.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bootpay_backend
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.9.0
|
|
4
4
|
Summary: bootpay server side plugin for python
|
|
5
5
|
Home-page: https://github.com/bootpay/backend-python/tree/2-x-development
|
|
6
6
|
Author: bootpay <bootpay.co.kr@gmail.com>
|
|
@@ -404,9 +404,11 @@ if 'error_code' not in token:
|
|
|
404
404
|
|
|
405
405
|
## 9-3. (별건) 현금영수증 발행
|
|
406
406
|
부트페이 결제와 상관없이 금액, 상품명, 현금영수증 발행정보 등을 보내 현금영수증을 발행하는 API 입니다
|
|
407
|
+
|
|
408
|
+
`pg` 는 선택 파라미터입니다. 생략하면 프로젝트에 설정된 기본 PG 로 발행됩니다.
|
|
407
409
|
```python
|
|
408
410
|
response = bootpay.request_cash_receipt(
|
|
409
|
-
pg='토스',
|
|
411
|
+
pg='토스', # 생략 가능 (미지정 시 기본 PG 사용)
|
|
410
412
|
price=1000,
|
|
411
413
|
order_name='테스트',
|
|
412
414
|
cash_receipt_type='소득공제',
|
|
@@ -364,9 +364,11 @@ if 'error_code' not in token:
|
|
|
364
364
|
|
|
365
365
|
## 9-3. (별건) 현금영수증 발행
|
|
366
366
|
부트페이 결제와 상관없이 금액, 상품명, 현금영수증 발행정보 등을 보내 현금영수증을 발행하는 API 입니다
|
|
367
|
+
|
|
368
|
+
`pg` 는 선택 파라미터입니다. 생략하면 프로젝트에 설정된 기본 PG 로 발행됩니다.
|
|
367
369
|
```python
|
|
368
370
|
response = bootpay.request_cash_receipt(
|
|
369
|
-
pg='토스',
|
|
371
|
+
pg='토스', # 생략 가능 (미지정 시 기본 PG 사용)
|
|
370
372
|
price=1000,
|
|
371
373
|
order_name='테스트',
|
|
372
374
|
cash_receipt_type='소득공제',
|
|
@@ -17,7 +17,19 @@ from .modules import (
|
|
|
17
17
|
CartModule,
|
|
18
18
|
OrderSubscriptionRequestModule,
|
|
19
19
|
MallSettingModule,
|
|
20
|
-
WebhookModule
|
|
20
|
+
WebhookModule,
|
|
21
|
+
FaqModule,
|
|
22
|
+
NoticeModule,
|
|
23
|
+
InquiryModule,
|
|
24
|
+
ProductQnaModule,
|
|
25
|
+
ProductReviewModule,
|
|
26
|
+
AlimtalkMessageModule,
|
|
27
|
+
AlimtalkOfficialModule,
|
|
28
|
+
AlimtalkOptoutModule,
|
|
29
|
+
AlimtalkSendModule,
|
|
30
|
+
AlimtalkSenderModule,
|
|
31
|
+
AlimtalkTemplateModule,
|
|
32
|
+
AlimtalkWebhookModule
|
|
21
33
|
)
|
|
22
34
|
from .types import *
|
|
23
35
|
|
|
@@ -80,6 +92,20 @@ class BootpayCommerce(BootpayCommerceResource):
|
|
|
80
92
|
self.order_subscription_request = OrderSubscriptionRequestModule(self)
|
|
81
93
|
self.mall_setting = MallSettingModule(self)
|
|
82
94
|
self.webhook = WebhookModule(self)
|
|
95
|
+
# 게시판 계열 (FAQ·공지사항·1:1문의·상품문의·상품평)
|
|
96
|
+
self.faq = FaqModule(self)
|
|
97
|
+
self.notice = NoticeModule(self)
|
|
98
|
+
self.inquiry = InquiryModule(self)
|
|
99
|
+
self.product_qna = ProductQnaModule(self)
|
|
100
|
+
self.product_review = ProductReviewModule(self)
|
|
101
|
+
# 알림톡 (/alimtalk 계열 — 메시지 API 호스트) — 주문·구독 웹훅과 별개다
|
|
102
|
+
self.alimtalk_message = AlimtalkMessageModule(self)
|
|
103
|
+
self.alimtalk_official = AlimtalkOfficialModule(self)
|
|
104
|
+
self.alimtalk_optout = AlimtalkOptoutModule(self)
|
|
105
|
+
self.alimtalk_send = AlimtalkSendModule(self)
|
|
106
|
+
self.alimtalk_sender = AlimtalkSenderModule(self)
|
|
107
|
+
self.alimtalk_template = AlimtalkTemplateModule(self)
|
|
108
|
+
self.alimtalk_webhook = AlimtalkWebhookModule(self)
|
|
83
109
|
|
|
84
110
|
def get_access_token(self):
|
|
85
111
|
"""
|
|
@@ -212,6 +238,18 @@ __all__ = [
|
|
|
212
238
|
'OrderSubscriptionRequestModule',
|
|
213
239
|
'MallSettingModule',
|
|
214
240
|
'WebhookModule',
|
|
241
|
+
'FaqModule',
|
|
242
|
+
'NoticeModule',
|
|
243
|
+
'InquiryModule',
|
|
244
|
+
'ProductQnaModule',
|
|
245
|
+
'ProductReviewModule',
|
|
246
|
+
'AlimtalkMessageModule',
|
|
247
|
+
'AlimtalkOfficialModule',
|
|
248
|
+
'AlimtalkOptoutModule',
|
|
249
|
+
'AlimtalkSendModule',
|
|
250
|
+
'AlimtalkSenderModule',
|
|
251
|
+
'AlimtalkTemplateModule',
|
|
252
|
+
'AlimtalkWebhookModule',
|
|
215
253
|
# Types
|
|
216
254
|
'ListParams',
|
|
217
255
|
'CommerceAddress',
|
|
@@ -309,4 +347,31 @@ __all__ = [
|
|
|
309
347
|
'MallSettingUpdateParams',
|
|
310
348
|
'CommerceMallSetting',
|
|
311
349
|
'SendTestWebhookParams',
|
|
350
|
+
# Alimtalk Types
|
|
351
|
+
'ALIMTALK_WEBHOOK_EVENT_SEND_REQUESTED',
|
|
352
|
+
'ALIMTALK_WEBHOOK_EVENT_SEND_SUCCESS',
|
|
353
|
+
'ALIMTALK_WEBHOOK_EVENT_SEND_FAILED',
|
|
354
|
+
'ALIMTALK_WEBHOOK_EVENT_SEND_CANCELED',
|
|
355
|
+
'ALIMTALK_WEBHOOK_EVENT_FALLBACK_SENT',
|
|
356
|
+
'ALIMTALK_WEBHOOK_EVENT_TEMPLATE_APPROVED',
|
|
357
|
+
'ALIMTALK_WEBHOOK_EVENT_TEMPLATE_REJECTED',
|
|
358
|
+
'ALIMTALK_WEBHOOK_EVENT_OPTOUT_CREATED',
|
|
359
|
+
'AlimtalkMessageListParams',
|
|
360
|
+
'AlimtalkMessageStatsParams',
|
|
361
|
+
'AlimtalkOfficialListParams',
|
|
362
|
+
'AlimtalkOfficialRecommendParams',
|
|
363
|
+
'AlimtalkOptoutListParams',
|
|
364
|
+
'AlimtalkOptoutCreateParams',
|
|
365
|
+
'AlimtalkOptoutCheckParams',
|
|
366
|
+
'AlimtalkSendParams',
|
|
367
|
+
'AlimtalkSendRecipient',
|
|
368
|
+
'AlimtalkSendBulkParams',
|
|
369
|
+
'AlimtalkSenderOtpParams',
|
|
370
|
+
'AlimtalkSenderCreateParams',
|
|
371
|
+
'AlimtalkTemplateListParams',
|
|
372
|
+
'AlimtalkTemplateCreateParams',
|
|
373
|
+
'AlimtalkTemplateUpdateParams',
|
|
374
|
+
'AlimtalkTemplateExportParams',
|
|
375
|
+
'AlimtalkWebhookUpdateParams',
|
|
376
|
+
'AlimtalkWebhookDeliveryListParams',
|
|
312
377
|
]
|
{bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/commerce_resource.py
RENAMED
|
@@ -13,6 +13,13 @@ class BootpayCommerceResource:
|
|
|
13
13
|
'stage': 'https://stage-api.bootapi.com/v1',
|
|
14
14
|
'production': 'https://api.bootapi.com/v1'
|
|
15
15
|
}
|
|
16
|
+
# 알림톡 API 전용 — 메시지 API 가 직접 받는다(경로에 /v1 없음). _api_base_url 이 고른다.
|
|
17
|
+
# @date: 26-09-18
|
|
18
|
+
MESSAGE_API_ENTRYPOINTS = {
|
|
19
|
+
'development': 'https://dev-m.bootapi.com',
|
|
20
|
+
'stage': 'https://stage-m.bootapi.com',
|
|
21
|
+
'production': 'https://message.bootapi.com'
|
|
22
|
+
}
|
|
16
23
|
API_VERSION = '1.0.0'
|
|
17
24
|
SDK_VERSION = '1.0.0'
|
|
18
25
|
|
|
@@ -23,6 +30,7 @@ class BootpayCommerceResource:
|
|
|
23
30
|
self.client_key: Optional[str] = None
|
|
24
31
|
self.secret_key: Optional[str] = None
|
|
25
32
|
self.timeout = 60
|
|
33
|
+
self._message_api_url: Optional[str] = None
|
|
26
34
|
|
|
27
35
|
def set_configuration(self, client_key: str, secret_key: str, mode: str = 'production'):
|
|
28
36
|
"""
|
|
@@ -64,9 +72,27 @@ class BootpayCommerceResource:
|
|
|
64
72
|
encoded = base64.b64encode(credentials.encode()).decode()
|
|
65
73
|
return f'Basic {encoded}'
|
|
66
74
|
|
|
75
|
+
def set_message_api_url(self, url: str):
|
|
76
|
+
"""
|
|
77
|
+
알림톡(메시지 API) URL 을 변경한다.
|
|
78
|
+
@date: 26-09-18
|
|
79
|
+
"""
|
|
80
|
+
self._message_api_url = url
|
|
81
|
+
|
|
82
|
+
def _api_base_url(self, url: str) -> str:
|
|
83
|
+
"""
|
|
84
|
+
요청 경로에 맞는 API 기본 주소.
|
|
85
|
+
알림톡 API 는 26-09-18 부터 커머스 API(api.bootapi.com/v1)가 아니라 메시지 API(message.bootapi.com, /v1 없음)가 받는다.
|
|
86
|
+
경로·파라미터·응답은 그대로이고 호스트만 다르다 — 그래서 알림톡 모듈은 고치지 않고 여기서 주소만 가른다.
|
|
87
|
+
옛 주소(/v1/alimtalk/*)는 410 으로 응답한다.
|
|
88
|
+
"""
|
|
89
|
+
if url.lstrip('/').startswith('alimtalk'):
|
|
90
|
+
return self._message_api_url or self.MESSAGE_API_ENTRYPOINTS[self.mode]
|
|
91
|
+
return self.API_ENTRYPOINTS[self.mode]
|
|
92
|
+
|
|
67
93
|
def _entrypoints(self, url: str) -> str:
|
|
68
94
|
"""엔트리포인트 URL 생성"""
|
|
69
|
-
return '/'.join([self.
|
|
95
|
+
return '/'.join([self._api_base_url(url), url])
|
|
70
96
|
|
|
71
97
|
def _get_headers(self, include_auth: bool = True, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
72
98
|
"""
|
|
@@ -134,6 +160,93 @@ class BootpayCommerceResource:
|
|
|
134
160
|
:param image_paths: 이미지 파일 경로 배열
|
|
135
161
|
:param headers: 요청별 추가 헤더 (공통값을 덮어쓴다)
|
|
136
162
|
"""
|
|
163
|
+
request_headers = self._multipart_headers(headers)
|
|
164
|
+
form_data = self._serialize_form_data(data)
|
|
165
|
+
|
|
166
|
+
# 파일 준비
|
|
167
|
+
# ⚠️ Rails 는 반복된 `images` 를 배열로 받지 않는다. images[0], images[1] ... 로 인덱싱해야 한다.
|
|
168
|
+
files = []
|
|
169
|
+
if image_paths:
|
|
170
|
+
for index, image_path in enumerate(image_paths):
|
|
171
|
+
filename = os.path.basename(image_path)
|
|
172
|
+
files.append((f'images[{index}]', (filename, open(image_path, 'rb'))))
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
response = requests.post(
|
|
176
|
+
self._entrypoints(url),
|
|
177
|
+
headers=request_headers,
|
|
178
|
+
data=form_data,
|
|
179
|
+
files=files if files else None,
|
|
180
|
+
timeout=self.timeout
|
|
181
|
+
)
|
|
182
|
+
return response.json()
|
|
183
|
+
finally:
|
|
184
|
+
# 파일 핸들 닫기
|
|
185
|
+
for _, file_tuple in files:
|
|
186
|
+
file_tuple[1].close()
|
|
187
|
+
|
|
188
|
+
def post_multipart_file(self, url: str, field: str, file: Any, data: Optional[Dict] = None,
|
|
189
|
+
headers: Optional[Dict[str, str]] = None):
|
|
190
|
+
"""
|
|
191
|
+
단일 파일 필드 multipart/form-data POST 요청
|
|
192
|
+
⚠️ post_multipart 는 Rails 배열 규약(images[0], images[1] ...) 전용이다.
|
|
193
|
+
알림톡 템플릿 이미지처럼 서버가 필드명을 정해 둔 단일 파일 업로드는 이 메서드를 쓴다.
|
|
194
|
+
images[0] 으로 올리면 서버가 파일을 찾지 못한다.
|
|
195
|
+
:param url: 요청 URL
|
|
196
|
+
:param field: 파일 폼 필드명 (예: 'image')
|
|
197
|
+
:param file: 파일 경로(str) 또는 이미 열린 파일 객체
|
|
198
|
+
:param data: 함께 보낼 폼 데이터
|
|
199
|
+
:param headers: 요청별 추가 헤더 (공통값을 덮어쓴다)
|
|
200
|
+
"""
|
|
201
|
+
request_headers = self._multipart_headers(headers)
|
|
202
|
+
form_data = self._serialize_form_data(data or {})
|
|
203
|
+
|
|
204
|
+
# 경로를 받으면 여기서 열고 반드시 닫는다. 파일 객체를 받으면 호출자 소유이므로 닫지 않는다.
|
|
205
|
+
opened = open(file, 'rb') if isinstance(file, str) else None
|
|
206
|
+
handle = opened if opened is not None else file
|
|
207
|
+
filename = os.path.basename(file if isinstance(file, str) else getattr(file, 'name', field))
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
response = requests.post(
|
|
211
|
+
self._entrypoints(url),
|
|
212
|
+
headers=request_headers,
|
|
213
|
+
data=form_data,
|
|
214
|
+
files={field: (filename, handle)},
|
|
215
|
+
timeout=self.timeout
|
|
216
|
+
)
|
|
217
|
+
return response.json()
|
|
218
|
+
finally:
|
|
219
|
+
if opened is not None:
|
|
220
|
+
opened.close()
|
|
221
|
+
|
|
222
|
+
def get_raw(self, url: str, params: Optional[Dict] = None, headers: Optional[Dict[str, str]] = None):
|
|
223
|
+
"""
|
|
224
|
+
JSON 이 아닌 본문(CSV 등)을 파싱하지 않고 그대로 받는다.
|
|
225
|
+
⚠️ 공용 get 은 응답을 무조건 response.json() 으로 파싱하므로, CSV 를 돌려주는 endpoint
|
|
226
|
+
(알림톡 템플릿 내보내기 format=csv)에서는 성공한 요청이 파싱 예외로 터진다.
|
|
227
|
+
실제로는 200 이라 원인을 찾기 어려워서 원문 경로를 따로 둔다.
|
|
228
|
+
:return: {'body': str, 'content_type': str, 'status': int}
|
|
229
|
+
"""
|
|
230
|
+
# 원문 경로라 Accept 를 */* 로 낮춘다 (요청별로 지정했다면 그 값을 존중한다).
|
|
231
|
+
request_headers = self._get_headers(headers={'Accept': '*/*', **(headers or {})})
|
|
232
|
+
response = requests.get(
|
|
233
|
+
self._entrypoints(url),
|
|
234
|
+
headers=request_headers,
|
|
235
|
+
params=params,
|
|
236
|
+
timeout=self.timeout
|
|
237
|
+
)
|
|
238
|
+
return {
|
|
239
|
+
'body': response.text,
|
|
240
|
+
'content_type': response.headers.get('Content-Type', ''),
|
|
241
|
+
'status': response.status_code
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
def _multipart_headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
245
|
+
"""
|
|
246
|
+
multipart 요청 공통 헤더
|
|
247
|
+
⚠️ Content-Type 은 넣지 않는다 — requests 가 생성한 값(boundary 포함)을 그대로 써야 한다.
|
|
248
|
+
직접 지정하면 boundary 가 사라져 본문이 서버에서 null 로 파싱된다.
|
|
249
|
+
"""
|
|
137
250
|
request_headers = {
|
|
138
251
|
'Accept': 'application/json',
|
|
139
252
|
'Accept-Charset': 'utf-8',
|
|
@@ -147,10 +260,14 @@ class BootpayCommerceResource:
|
|
|
147
260
|
request_headers['Authorization'] = basic_auth
|
|
148
261
|
if headers:
|
|
149
262
|
request_headers.update(headers)
|
|
263
|
+
return request_headers
|
|
150
264
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
265
|
+
def _serialize_form_data(self, data: Dict) -> Dict[str, str]:
|
|
266
|
+
"""
|
|
267
|
+
폼 데이터 직렬화
|
|
268
|
+
⚠️ bool 은 소문자 'true'/'false' 로 보낸다 — str(True) 의 'True'/'False' 는
|
|
269
|
+
Rails 캐스팅 대상이 아니라서 "False" 가 true 로 해석되는 실위험이 있다.
|
|
270
|
+
"""
|
|
154
271
|
form_data = {}
|
|
155
272
|
for key, value in data.items():
|
|
156
273
|
if value is not None:
|
|
@@ -160,28 +277,7 @@ class BootpayCommerceResource:
|
|
|
160
277
|
form_data[key] = json.dumps(value)
|
|
161
278
|
else:
|
|
162
279
|
form_data[key] = str(value)
|
|
163
|
-
|
|
164
|
-
# 파일 준비
|
|
165
|
-
# ⚠️ Rails 는 반복된 `images` 를 배열로 받지 않는다. images[0], images[1] ... 로 인덱싱해야 한다.
|
|
166
|
-
files = []
|
|
167
|
-
if image_paths:
|
|
168
|
-
for index, image_path in enumerate(image_paths):
|
|
169
|
-
filename = os.path.basename(image_path)
|
|
170
|
-
files.append((f'images[{index}]', (filename, open(image_path, 'rb'))))
|
|
171
|
-
|
|
172
|
-
try:
|
|
173
|
-
response = requests.post(
|
|
174
|
-
self._entrypoints(url),
|
|
175
|
-
headers=request_headers,
|
|
176
|
-
data=form_data,
|
|
177
|
-
files=files if files else None,
|
|
178
|
-
timeout=self.timeout
|
|
179
|
-
)
|
|
180
|
-
return response.json()
|
|
181
|
-
finally:
|
|
182
|
-
# 파일 핸들 닫기
|
|
183
|
-
for _, file_tuple in files:
|
|
184
|
-
file_tuple[1].close()
|
|
280
|
+
return form_data
|
|
185
281
|
|
|
186
282
|
def put(self, url: str, data: Optional[Dict] = None, headers: Optional[Dict[str, str]] = None):
|
|
187
283
|
"""PUT 요청"""
|
{bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/__init__.py
RENAMED
|
@@ -16,6 +16,18 @@ from .cart import CartModule
|
|
|
16
16
|
from .order_subscription_request import OrderSubscriptionRequestModule
|
|
17
17
|
from .mall_setting import MallSettingModule
|
|
18
18
|
from .webhook import WebhookModule
|
|
19
|
+
from .faq import FaqModule
|
|
20
|
+
from .notice import NoticeModule
|
|
21
|
+
from .inquiry import InquiryModule
|
|
22
|
+
from .product_qna import ProductQnaModule
|
|
23
|
+
from .product_review import ProductReviewModule
|
|
24
|
+
from .alimtalk_message import AlimtalkMessageModule
|
|
25
|
+
from .alimtalk_official import AlimtalkOfficialModule
|
|
26
|
+
from .alimtalk_optout import AlimtalkOptoutModule
|
|
27
|
+
from .alimtalk_send import AlimtalkSendModule
|
|
28
|
+
from .alimtalk_sender import AlimtalkSenderModule
|
|
29
|
+
from .alimtalk_template import AlimtalkTemplateModule
|
|
30
|
+
from .alimtalk_webhook import AlimtalkWebhookModule
|
|
19
31
|
|
|
20
32
|
__all__ = [
|
|
21
33
|
'UserModule',
|
|
@@ -34,5 +46,17 @@ __all__ = [
|
|
|
34
46
|
'CartModule',
|
|
35
47
|
'OrderSubscriptionRequestModule',
|
|
36
48
|
'MallSettingModule',
|
|
37
|
-
'WebhookModule'
|
|
49
|
+
'WebhookModule',
|
|
50
|
+
'FaqModule',
|
|
51
|
+
'NoticeModule',
|
|
52
|
+
'InquiryModule',
|
|
53
|
+
'ProductQnaModule',
|
|
54
|
+
'ProductReviewModule',
|
|
55
|
+
'AlimtalkMessageModule',
|
|
56
|
+
'AlimtalkOfficialModule',
|
|
57
|
+
'AlimtalkOptoutModule',
|
|
58
|
+
'AlimtalkSendModule',
|
|
59
|
+
'AlimtalkSenderModule',
|
|
60
|
+
'AlimtalkTemplateModule',
|
|
61
|
+
'AlimtalkWebhookModule'
|
|
38
62
|
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Optional, Dict, Any
|
|
2
|
+
from urllib.parse import urlencode
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from ..commerce_resource import BootpayCommerceResource
|
|
6
|
+
|
|
7
|
+
from ..types import (
|
|
8
|
+
AlimtalkMessageListParams,
|
|
9
|
+
AlimtalkMessageStatsParams
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AlimtalkMessageModule:
|
|
14
|
+
"""
|
|
15
|
+
알림톡 발송내역·집계 모듈 (GET /alimtalk/messages 계열)
|
|
16
|
+
|
|
17
|
+
**유료** 알림톡만 조회된다 (무료 커머스 알림톡은 포함되지 않는다).
|
|
18
|
+
상태는 벤더 결과 동기화로 확정되므로 접수 직후에는 requested 로 보인다.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, bootpay: 'BootpayCommerceResource'):
|
|
22
|
+
self._bootpay = bootpay
|
|
23
|
+
|
|
24
|
+
def list(self, params: Optional[AlimtalkMessageListParams] = None):
|
|
25
|
+
"""
|
|
26
|
+
발송내역 목록 조회
|
|
27
|
+
GET /alimtalk/messages
|
|
28
|
+
⚠️ 기간 기본값은 최근 30일이고 최대 조회 폭은 92일이다 — 초과분은 거부하지 않고 시작일을 당겨 잘라낸다.
|
|
29
|
+
실제 적용된 구간은 응답의 period 로 확인한다.
|
|
30
|
+
:param params: 조회 파라미터 (status: requested·success·failed·canceled)
|
|
31
|
+
:return: {'list': [...], 'count': int, 'page': int, 'per': int, 'period': {'from':, 'to':}}
|
|
32
|
+
"""
|
|
33
|
+
query = urlencode(self._query(params))
|
|
34
|
+
return self._bootpay.get(
|
|
35
|
+
f'alimtalk/messages{"?" + query if query else ""}',
|
|
36
|
+
headers=self._alimtalk_headers()
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def stats(self, params: Optional[AlimtalkMessageStatsParams] = None):
|
|
40
|
+
"""
|
|
41
|
+
기간 집계 조회
|
|
42
|
+
GET /alimtalk/messages/stats
|
|
43
|
+
일자별 집계 원장에서 읽으므로 응답이 빠르다.
|
|
44
|
+
⚠️ billing.unit_price_source 가 'default' 면 **잠정 단가**다 (확정 청구액이 아니다).
|
|
45
|
+
⚠️ billable_count 는 성공 − 폴백이다 — 폴백분은 LMS 단가로 따로 계산된다.
|
|
46
|
+
:param params: 조회 기간
|
|
47
|
+
:return: {'period':, 'totals': {...}, 'daily': [...], 'billing': {...}}
|
|
48
|
+
"""
|
|
49
|
+
query = urlencode(self._query(params))
|
|
50
|
+
return self._bootpay.get(
|
|
51
|
+
f'alimtalk/messages/stats{"?" + query if query else ""}',
|
|
52
|
+
headers=self._alimtalk_headers()
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def detail(self, receipt_id: str):
|
|
56
|
+
"""
|
|
57
|
+
단건 발송 결과 조회
|
|
58
|
+
GET /alimtalk/messages/{receipt_id}
|
|
59
|
+
실패 사유는 error_code·error_message 에 담긴다.
|
|
60
|
+
fallback_type 은 폴백이 꺼진 건이면 None, 켜진 건이면 LMS 다.
|
|
61
|
+
다른 프로젝트의 건이거나 없으면 404(3025).
|
|
62
|
+
:param receipt_id: 발송 접수 ID
|
|
63
|
+
:return: 발송 상세
|
|
64
|
+
"""
|
|
65
|
+
return self._bootpay.get(
|
|
66
|
+
f'alimtalk/messages/{receipt_id}',
|
|
67
|
+
headers=self._alimtalk_headers()
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _query(self, params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
71
|
+
"""
|
|
72
|
+
None 을 제거하고 bool 을 소문자 'true'/'false' 로 직렬화한다.
|
|
73
|
+
⚠️ urlencode 는 True 를 'True' 로 쓴다 — Rails 의 boolean 캐스팅은 'False' 를 참으로 읽으므로
|
|
74
|
+
그대로 보내면 false 가 true 로 뒤집힌다.
|
|
75
|
+
"""
|
|
76
|
+
query = {}
|
|
77
|
+
for key, value in (params or {}).items():
|
|
78
|
+
if value is None:
|
|
79
|
+
continue
|
|
80
|
+
query[key] = ('true' if value else 'false') if isinstance(value, bool) else value
|
|
81
|
+
return query
|
|
82
|
+
|
|
83
|
+
def _alimtalk_headers(self) -> Dict[str, str]:
|
|
84
|
+
"""
|
|
85
|
+
알림톡 API 요청 헤더
|
|
86
|
+
★Idempotency-Key 를 싣지 않는다★ 알림톡 API 는 이 헤더를 읽지 않는다
|
|
87
|
+
(멱등은 발송의 ref_id 로만 성립한다).
|
|
88
|
+
★BOOTPAY-ROLE 은 항상 user★ 알림톡 스코프 키가 전부 user:alimtalk_* 다.
|
|
89
|
+
"""
|
|
90
|
+
return {'BOOTPAY-ROLE': 'user'}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Optional, Dict, Any
|
|
2
|
+
from urllib.parse import urlencode
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from ..commerce_resource import BootpayCommerceResource
|
|
6
|
+
|
|
7
|
+
from ..types import (
|
|
8
|
+
AlimtalkOfficialListParams,
|
|
9
|
+
AlimtalkOfficialRecommendParams
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AlimtalkOfficialModule:
|
|
14
|
+
"""
|
|
15
|
+
부트페이 공식 알림톡 템플릿 카탈로그 모듈 (/alimtalk/official 계열)
|
|
16
|
+
|
|
17
|
+
부트페이가 미리 카카오 승인을 받아 둔 템플릿이라, 그룹키가 등록된 채널이면 **검수 없이 즉시 발송**된다.
|
|
18
|
+
`alimtalk_sender.create` 로 채널을 등록하면 그룹 등록이 함께 끝나므로 따로 채택할 것이 없다.
|
|
19
|
+
(채택 endpoint 는 서버에서도 비활성화되어 SDK 에 두지 않는다)
|
|
20
|
+
|
|
21
|
+
전부 조회 계열이라 부작용이 없다 (자체 DB 만 본다).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, bootpay: 'BootpayCommerceResource'):
|
|
25
|
+
self._bootpay = bootpay
|
|
26
|
+
|
|
27
|
+
def list(self, params: Optional[AlimtalkOfficialListParams] = None):
|
|
28
|
+
"""
|
|
29
|
+
공식 템플릿 검색
|
|
30
|
+
GET /alimtalk/official
|
|
31
|
+
keyword 는 본문·이름·분류를 부분일치(대소문자 무시)로 훑는다.
|
|
32
|
+
⚠️ 서버는 q 를 먼저 보고 없으면 keyword 를 본다 — 정본 키인 q 로 보낸다.
|
|
33
|
+
:param params: 조회 파라미터 (msg_type 은 BA·EX 만 존재한다)
|
|
34
|
+
:return: {'list': [...], 'count': int, 'page': int, 'per': int, 'categories': [...]}
|
|
35
|
+
"""
|
|
36
|
+
params = dict(params or {})
|
|
37
|
+
query_params = {
|
|
38
|
+
'q': params.pop('q', None) or params.pop('keyword', None),
|
|
39
|
+
'category': params.get('category'),
|
|
40
|
+
'msg_type': params.get('msg_type'),
|
|
41
|
+
'page': params.get('page'),
|
|
42
|
+
'per': params.get('per'),
|
|
43
|
+
'ksp_id': params.get('ksp_id')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
query = urlencode(self._compact(query_params))
|
|
47
|
+
return self._bootpay.get(
|
|
48
|
+
f'alimtalk/official{"?" + query if query else ""}',
|
|
49
|
+
headers=self._alimtalk_headers()
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def recommend(self, params: AlimtalkOfficialRecommendParams):
|
|
53
|
+
"""
|
|
54
|
+
보내려는 문구로 공식 템플릿을 추천받는다
|
|
55
|
+
POST /alimtalk/official/recommend
|
|
56
|
+
유사도 score(0~1) 내림차순으로 돌려준다.
|
|
57
|
+
:param params: 추천 파라미터 (text 필수)
|
|
58
|
+
:return: 추천 템플릿 목록
|
|
59
|
+
"""
|
|
60
|
+
return self._bootpay.post(
|
|
61
|
+
'alimtalk/official/recommend',
|
|
62
|
+
self._compact(params),
|
|
63
|
+
headers=self._alimtalk_headers()
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def detail(self, code: str, ksp_id: Optional[str] = None):
|
|
67
|
+
"""
|
|
68
|
+
공식 템플릿 상세 조회
|
|
69
|
+
GET /alimtalk/official/{code}
|
|
70
|
+
:param code: 서버 채번 코드 (슬래시를 포함하지 않는다). 없거나 미노출이면 404(3015).
|
|
71
|
+
:param ksp_id: 주면 그 채널의 변수 예문 사전으로 variable_examples 를 채워 준다 (표시용)
|
|
72
|
+
:return: 공식 템플릿 상세
|
|
73
|
+
"""
|
|
74
|
+
query = urlencode(self._compact({'ksp_id': ksp_id}))
|
|
75
|
+
return self._bootpay.get(
|
|
76
|
+
f'alimtalk/official/{code}{"?" + query if query else ""}',
|
|
77
|
+
headers=self._alimtalk_headers()
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _compact(self, params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
81
|
+
"""None 값을 제거한다."""
|
|
82
|
+
return {k: v for k, v in (params or {}).items() if v is not None}
|
|
83
|
+
|
|
84
|
+
def _alimtalk_headers(self) -> Dict[str, str]:
|
|
85
|
+
"""
|
|
86
|
+
알림톡 API 요청 헤더
|
|
87
|
+
★Idempotency-Key 를 싣지 않는다★ 알림톡 API 는 이 헤더를 읽지 않는다.
|
|
88
|
+
★BOOTPAY-ROLE 은 항상 user★ 알림톡 스코프 키가 전부 user:alimtalk_* 다.
|
|
89
|
+
"""
|
|
90
|
+
return {'BOOTPAY-ROLE': 'user'}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Optional, Dict, Any
|
|
2
|
+
from urllib.parse import urlencode
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from ..commerce_resource import BootpayCommerceResource
|
|
6
|
+
|
|
7
|
+
from ..types import (
|
|
8
|
+
AlimtalkOptoutListParams,
|
|
9
|
+
AlimtalkOptoutCreateParams,
|
|
10
|
+
AlimtalkOptoutCheckParams
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AlimtalkOptoutModule:
|
|
15
|
+
"""
|
|
16
|
+
알림톡 수신거부 모듈 (/alimtalk/optouts 계열, 가맹점 CRM 수신거부 동기화용)
|
|
17
|
+
|
|
18
|
+
발송 판정과 **같은 기준**으로 다룬다 — 부트페이 전역(global) + 내 프로젝트.
|
|
19
|
+
⚠️ 전역 건은 **조회는 되지만 해제할 수 없다** (releasable: False).
|
|
20
|
+
이걸 노출하지 않으면 "화면엔 수신거부가 아닌데 발송은 3021 로 막히는" 상태가 된다.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, bootpay: 'BootpayCommerceResource'):
|
|
24
|
+
self._bootpay = bootpay
|
|
25
|
+
|
|
26
|
+
def list(self, params: Optional[AlimtalkOptoutListParams] = None):
|
|
27
|
+
"""
|
|
28
|
+
수신거부 목록 조회
|
|
29
|
+
GET /alimtalk/optouts
|
|
30
|
+
phone 은 숫자만 남겨 **부분일치**로 찾는다 (정확 매칭이 아니다). 50건 단위로 페이징된다.
|
|
31
|
+
:param params: 조회 파라미터
|
|
32
|
+
:return: {'list': [{'id':, 'phone':, 'scope':, 'global':, 'releasable':, 'source':,
|
|
33
|
+
'reason':, 'opted_out_at':, 'created_at':}], 'count': int, 'page': int}
|
|
34
|
+
"""
|
|
35
|
+
query = urlencode(self._compact(params))
|
|
36
|
+
return self._bootpay.get(
|
|
37
|
+
f'alimtalk/optouts{"?" + query if query else ""}',
|
|
38
|
+
headers=self._alimtalk_headers()
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def create(self, params: AlimtalkOptoutCreateParams):
|
|
42
|
+
"""
|
|
43
|
+
수신거부 등록
|
|
44
|
+
POST /alimtalk/optouts
|
|
45
|
+
내 프로젝트 스코프로 등록된다 (source: api). 같은 번호를 다시 등록해도 멱등이다.
|
|
46
|
+
:param params: 등록 파라미터 (phone 필수)
|
|
47
|
+
:return: 등록된 수신거부
|
|
48
|
+
"""
|
|
49
|
+
return self._bootpay.post(
|
|
50
|
+
'alimtalk/optouts',
|
|
51
|
+
self._compact(params),
|
|
52
|
+
headers=self._alimtalk_headers()
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def check(self, params: AlimtalkOptoutCheckParams):
|
|
56
|
+
"""
|
|
57
|
+
발송 전 수신거부 사전 확인
|
|
58
|
+
POST /alimtalk/optouts/check
|
|
59
|
+
발송 판정과 **같은 축**으로 대조하므로, 벌크에서 skipped 로 낭비될 건을 미리 뺄 수 있다.
|
|
60
|
+
단건(phone)·다건(phones) 모두 받는다.
|
|
61
|
+
⚠️ 1회 최대 1,000건이고 넘으면 -48 이다 (중복은 서버가 제거).
|
|
62
|
+
:param params: 확인 파라미터
|
|
63
|
+
:return: {'list': [{'phone':, 'opted_out':, 'global':, 'releasable':, 'opted_out_at':}],
|
|
64
|
+
'count': int, 'opted_out_count': int}
|
|
65
|
+
"""
|
|
66
|
+
return self._bootpay.post(
|
|
67
|
+
'alimtalk/optouts/check',
|
|
68
|
+
self._compact(params),
|
|
69
|
+
headers=self._alimtalk_headers()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def release(self, phone: str):
|
|
73
|
+
"""
|
|
74
|
+
수신거부 해제
|
|
75
|
+
DELETE /alimtalk/optouts/{phone}
|
|
76
|
+
내 프로젝트 스코프 건만 해제되며 멱등이다 (없어도 성공).
|
|
77
|
+
⚠️ 전역 차단은 해제되지 않고 global_blocked: True 로 알려 준다 —
|
|
78
|
+
"지웠는데 여전히 막히는" 상태를 응답으로 드러내기 위함이다.
|
|
79
|
+
:param phone: 해제할 수신번호
|
|
80
|
+
:return: {'phone':, 'released':, 'global_blocked':}
|
|
81
|
+
"""
|
|
82
|
+
return self._bootpay.delete(
|
|
83
|
+
f'alimtalk/optouts/{phone}',
|
|
84
|
+
headers=self._alimtalk_headers()
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _compact(self, params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
88
|
+
"""None 값을 제거한다."""
|
|
89
|
+
return {k: v for k, v in (params or {}).items() if v is not None}
|
|
90
|
+
|
|
91
|
+
def _alimtalk_headers(self) -> Dict[str, str]:
|
|
92
|
+
"""
|
|
93
|
+
알림톡 API 요청 헤더
|
|
94
|
+
★Idempotency-Key 를 싣지 않는다★ 알림톡 API 는 이 헤더를 읽지 않는다.
|
|
95
|
+
★BOOTPAY-ROLE 은 항상 user★ 알림톡 스코프 키가 전부 user:alimtalk_* 다.
|
|
96
|
+
"""
|
|
97
|
+
return {'BOOTPAY-ROLE': 'user'}
|