bootpay 1.2.0 → 3.0.0
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.
- checksums.yaml +4 -4
- data/.gitignore +8 -9
- data/.rspec +2 -0
- data/CHANGELOG.md +63 -21
- data/CODE_OF_CONDUCT.md +1 -1
- data/Gemfile +2 -4
- data/LICENSE.txt +1 -1
- data/README.md +309 -196
- data/Rakefile +3 -3
- data/bin/console +1 -1
- data/bootpay.gemspec +23 -20
- data/lib/bootpay/bootpay-rest-client.rb +51 -0
- data/lib/bootpay/concern/authenticate.rb +69 -0
- data/lib/bootpay/concern/cash_receipt.rb +83 -0
- data/lib/bootpay/concern/easy.rb +18 -0
- data/lib/bootpay/concern/escrow.rb +25 -0
- data/lib/bootpay/concern/payment.rb +126 -0
- data/lib/bootpay/concern/reseller.rb +97 -0
- data/lib/bootpay/concern/rest.rb +56 -0
- data/lib/bootpay/concern/sdk.rb +52 -0
- data/lib/bootpay/concern/seller.rb +15 -0
- data/lib/bootpay/concern/service.rb +15 -0
- data/lib/bootpay/concern/subscription.rb +211 -0
- data/lib/bootpay/concern/token.rb +33 -0
- data/lib/bootpay/concern/user_token.rb +23 -0
- data/lib/bootpay/concern/wallet.rb +20 -0
- data/lib/bootpay/concern/webhook.rb +18 -0
- data/lib/bootpay/concern.rb +35 -0
- data/lib/bootpay-backend-ruby.rb +6 -0
- data/lib/bootpay.rb +10 -66
- data/lib/bootpay_storage/bootpay-storage-rest-client.rb +46 -0
- data/lib/bootpay_storage/concern/csv.rb +18 -0
- data/lib/bootpay_storage/concern/image.rb +30 -0
- data/lib/bootpay_storage/concern/rest.rb +176 -0
- data/lib/bootpay_storage/concern/token.rb +9 -0
- data/lib/bootpay_storage/concern.rb +12 -0
- data/lib/bootpay_storage/response.rb +14 -0
- data/lib/bootpay_store/bootpay-store-rest-client.rb +47 -0
- data/lib/bootpay_store/concern/alimtalk_message.rb +67 -0
- data/lib/bootpay_store/concern/alimtalk_official.rb +63 -0
- data/lib/bootpay_store/concern/alimtalk_optout.rb +72 -0
- data/lib/bootpay_store/concern/alimtalk_send.rb +83 -0
- data/lib/bootpay_store/concern/alimtalk_sender.rb +105 -0
- data/lib/bootpay_store/concern/alimtalk_template.rb +215 -0
- data/lib/bootpay_store/concern/alimtalk_webhook.rb +84 -0
- data/lib/bootpay_store/concern/invoice.rb +102 -0
- data/lib/bootpay_store/concern/mall_setting.rb +385 -0
- data/lib/bootpay_store/concern/order.rb +74 -0
- data/lib/bootpay_store/concern/order_subscription.rb +391 -0
- data/lib/bootpay_store/concern/payment.rb +119 -0
- data/lib/bootpay_store/concern/product.rb +175 -0
- data/lib/bootpay_store/concern/rest.rb +128 -0
- data/lib/bootpay_store/concern/store.rb +31 -0
- data/lib/bootpay_store/concern/supervisor.rb +211 -0
- data/lib/bootpay_store/concern/token.rb +24 -0
- data/lib/bootpay_store/concern/user.rb +325 -0
- data/lib/bootpay_store/concern/user_group.rb +221 -0
- data/lib/bootpay_store/concern/webhook.rb +17 -0
- data/lib/bootpay_store/concern.rb +45 -0
- data/lib/bootpay_store/response.rb +14 -0
- data/lib/version.rb +9 -0
- metadata +77 -49
- data/.DS_Store +0 -0
- data/.env.example +0 -23
- data/lib/.DS_Store +0 -0
- data/lib/bootpay/.DS_Store +0 -0
- data/lib/bootpay/authentication.rb +0 -61
- data/lib/bootpay/automatic_transfer.rb +0 -56
- data/lib/bootpay/billing.rb +0 -157
- data/lib/bootpay/cancel.rb +0 -29
- data/lib/bootpay/cash_receipt.rb +0 -77
- data/lib/bootpay/commerce/cart.rb +0 -20
- data/lib/bootpay/commerce/category.rb +0 -39
- data/lib/bootpay/commerce/commerce_resource.rb +0 -196
- data/lib/bootpay/commerce/coupon.rb +0 -41
- data/lib/bootpay/commerce/invoice.rb +0 -46
- data/lib/bootpay/commerce/order.rb +0 -61
- data/lib/bootpay/commerce/order_cancel.rb +0 -52
- data/lib/bootpay/commerce/order_subscription.rb +0 -117
- data/lib/bootpay/commerce/order_subscription_adjustment.rb +0 -29
- data/lib/bootpay/commerce/order_subscription_bill.rb +0 -50
- data/lib/bootpay/commerce/order_subscription_request.rb +0 -60
- data/lib/bootpay/commerce/point.rb +0 -36
- data/lib/bootpay/commerce/product.rb +0 -63
- data/lib/bootpay/commerce/store.rb +0 -21
- data/lib/bootpay/commerce/user.rb +0 -75
- data/lib/bootpay/commerce/user_group.rb +0 -70
- data/lib/bootpay/easy.rb +0 -25
- data/lib/bootpay/escrow.rb +0 -24
- data/lib/bootpay/link.rb +0 -60
- data/lib/bootpay/naverpay.rb +0 -17
- data/lib/bootpay/payment_resource.rb +0 -31
- data/lib/bootpay/reseller.rb +0 -12
- data/lib/bootpay/rest.rb +0 -46
- data/lib/bootpay/submit.rb +0 -20
- data/lib/bootpay/token.rb +0 -33
- data/lib/bootpay/verification.rb +0 -55
- data/lib/bootpay/version.rb +0 -25
- data/lib/bootpay/wallet.rb +0 -56
- data/lib/bootpay_commerce.rb +0 -152
- /data/lib/{response.rb → bootpay/response.rb} +0 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
module BootpayStorage::Concern::Rest
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# HTTP Request 기본 Method
|
|
8
|
+
# Comment by Gosomi
|
|
9
|
+
# Date: 2021-05-21
|
|
10
|
+
def request(method: :post, uri:, payload: {}, headers: {}, params: nil)
|
|
11
|
+
response = HTTP.headers(
|
|
12
|
+
{
|
|
13
|
+
Authorization: "Bearer #{@token}",
|
|
14
|
+
content_type: 'application/json',
|
|
15
|
+
accept: 'application/json',
|
|
16
|
+
bootpay_api_version: @api_version,
|
|
17
|
+
bootpay_sdk_version: Bootpay::V2_VERSION,
|
|
18
|
+
bootpay_sdk_type: '300'
|
|
19
|
+
}.merge!(headers).compact
|
|
20
|
+
).send(
|
|
21
|
+
method.to_sym,
|
|
22
|
+
[BootpayStorage::RestClient::API[@mode.to_sym], uri].join('/'),
|
|
23
|
+
json: payload,
|
|
24
|
+
params: params
|
|
25
|
+
)
|
|
26
|
+
BootpayStorage::Response.new(
|
|
27
|
+
response.status.to_i == 200,
|
|
28
|
+
JSON.parse(response.body.to_s, symbolize_names: true)
|
|
29
|
+
)
|
|
30
|
+
rescue Exception => e
|
|
31
|
+
BootpayStorage::Response.new(
|
|
32
|
+
false,
|
|
33
|
+
message: "부트페이 API 서버와의 통신이 실패하였습니다. 오류 메세지: #{e.message}",
|
|
34
|
+
backtrace: e.backtrace.join("\n")
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Multipart 파일 전송 Method
|
|
39
|
+
# Comment by Gosomi
|
|
40
|
+
# Date: 2025-03-25
|
|
41
|
+
def upload(uri:, images:, headers: {}, params: nil)
|
|
42
|
+
# 이미지 데이터를 배열로 받음
|
|
43
|
+
files = images.each_with_index.map do |data, index|
|
|
44
|
+
filename = "image_#{Time.now.to_i}_#{index}.jpg"
|
|
45
|
+
HTTP::FormData::File.new(data, filename: filename)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# HTTP 요청
|
|
49
|
+
response = HTTP.headers(
|
|
50
|
+
{
|
|
51
|
+
Authorization: "Bearer #{@token}",
|
|
52
|
+
accept: 'application/json',
|
|
53
|
+
bootpay_api_version: @api_version,
|
|
54
|
+
bootpay_sdk_version: Bootpay::V2_VERSION,
|
|
55
|
+
bootpay_sdk_type: '300'
|
|
56
|
+
}.merge!(headers).compact
|
|
57
|
+
).post(
|
|
58
|
+
[BootpayStorage::RestClient::API[@mode.to_sym], uri].join('/'),
|
|
59
|
+
form: { images: files },
|
|
60
|
+
params: params
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# JSON 파싱 시도
|
|
64
|
+
parsed_response = begin
|
|
65
|
+
JSON.parse(response.body.to_s, symbolize_names: true)
|
|
66
|
+
rescue JSON::ParserError => e
|
|
67
|
+
{ error: "응답 파싱 실패: #{e.message}", body: response.body.to_s }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# 응답 처리
|
|
71
|
+
BootpayStorage::Response.new(
|
|
72
|
+
response.status.to_i == 200,
|
|
73
|
+
parsed_response
|
|
74
|
+
)
|
|
75
|
+
rescue Exception => e
|
|
76
|
+
BootpayStorage::Response.new(
|
|
77
|
+
false,
|
|
78
|
+
message: "파일 업로드 실패: #{e.message}",
|
|
79
|
+
backtrace: e.backtrace.join("\n")
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# csv Multipart 파일 전송 Method
|
|
84
|
+
# Comment by ehowlsla
|
|
85
|
+
# Date: 2025-07-17
|
|
86
|
+
def csv_upload(uri:, files:, headers: {}, params: nil)
|
|
87
|
+
# 이미지 데이터를 배열로 받음
|
|
88
|
+
files = files.each_with_index.map do |data, index|
|
|
89
|
+
filename = "csv_#{Time.now.to_i}_#{index}.csv"
|
|
90
|
+
HTTP::FormData::File.new(data, filename: filename)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# HTTP 요청
|
|
94
|
+
response = HTTP.headers(
|
|
95
|
+
{
|
|
96
|
+
Authorization: "Bearer #{@token}",
|
|
97
|
+
accept: 'application/json',
|
|
98
|
+
bootpay_api_version: @api_version,
|
|
99
|
+
bootpay_sdk_version: Bootpay::V2_VERSION,
|
|
100
|
+
bootpay_sdk_type: '300'
|
|
101
|
+
}.merge!(headers).compact
|
|
102
|
+
).post(
|
|
103
|
+
[BootpayStorage::RestClient::API[@mode.to_sym], uri].join('/'),
|
|
104
|
+
form: { images: files },
|
|
105
|
+
params: params
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# JSON 파싱 시도
|
|
109
|
+
parsed_response = begin
|
|
110
|
+
JSON.parse(response.body.to_s, symbolize_names: true)
|
|
111
|
+
rescue JSON::ParserError => e
|
|
112
|
+
{ error: "응답 파싱 실패: #{e.message}", body: response.body.to_s }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# 응답 처리
|
|
116
|
+
BootpayStorage::Response.new(
|
|
117
|
+
response.status.to_i == 200,
|
|
118
|
+
parsed_response
|
|
119
|
+
)
|
|
120
|
+
rescue Exception => e
|
|
121
|
+
BootpayStorage::Response.new(
|
|
122
|
+
false,
|
|
123
|
+
message: "파일 업로드 실패: #{e.message}",
|
|
124
|
+
backtrace: e.backtrace.join("\n")
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# def upload(uri:, image_data:, image_name:, headers: {}, params: nil)
|
|
130
|
+
#
|
|
131
|
+
# # puts [BootpayStorage::RestClient::API[@mode.to_sym], uri].join('/')
|
|
132
|
+
#
|
|
133
|
+
# # 파일 객체 생성
|
|
134
|
+
# file = HTTP::FormData::File.new(image_data, filename: image_name)
|
|
135
|
+
#
|
|
136
|
+
# # HTTP 요청
|
|
137
|
+
# response = HTTP.headers(
|
|
138
|
+
# {
|
|
139
|
+
# Authorization: "Bearer #{@token}",
|
|
140
|
+
# accept: 'application/json',
|
|
141
|
+
# bootpay_api_version: @api_version,
|
|
142
|
+
# bootpay_sdk_version: Bootpay::V2_VERSION,
|
|
143
|
+
# bootpay_sdk_type: '300'
|
|
144
|
+
# }.merge!(headers).compact
|
|
145
|
+
# ).post(
|
|
146
|
+
# [BootpayStorage::RestClient::API[@mode.to_sym], uri].join('/'),
|
|
147
|
+
# form: { images: [file] },
|
|
148
|
+
# params: params
|
|
149
|
+
# )
|
|
150
|
+
#
|
|
151
|
+
# # 응답 상태와 바디 출력
|
|
152
|
+
# puts "Response Status: #{response.status}"
|
|
153
|
+
# puts "Response Body: #{response.body.to_s}"
|
|
154
|
+
#
|
|
155
|
+
# # JSON 파싱 시도
|
|
156
|
+
# parsed_response = begin
|
|
157
|
+
# JSON.parse(response.body.to_s, symbolize_names: true)
|
|
158
|
+
# rescue JSON::ParserError => e
|
|
159
|
+
# { error: "응답 파싱 실패: #{e.message}", body: response.body.to_s }
|
|
160
|
+
# end
|
|
161
|
+
#
|
|
162
|
+
# # 응답 처리
|
|
163
|
+
# BootpayStorage::Response.new(
|
|
164
|
+
# response.status.to_i == 200,
|
|
165
|
+
# parsed_response
|
|
166
|
+
# )
|
|
167
|
+
# rescue Exception => e
|
|
168
|
+
# BootpayStorage::Response.new(
|
|
169
|
+
# false,
|
|
170
|
+
# message: "파일 업로드 실패: #{e.message}",
|
|
171
|
+
# backtrace: e.backtrace.join("\n")
|
|
172
|
+
# )
|
|
173
|
+
# end
|
|
174
|
+
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/all'
|
|
4
|
+
require 'http'
|
|
5
|
+
require 'base64'
|
|
6
|
+
require_relative 'response'
|
|
7
|
+
require_relative '../version'
|
|
8
|
+
require_relative 'concern'
|
|
9
|
+
|
|
10
|
+
module BootpayStore
|
|
11
|
+
class RestClient
|
|
12
|
+
include Concern
|
|
13
|
+
|
|
14
|
+
API =
|
|
15
|
+
{
|
|
16
|
+
development: 'https://dev-api.bootapi.com/v1',
|
|
17
|
+
stage: 'https://stage-api.bootapi.com/v1',
|
|
18
|
+
production: 'https://api.bootapi.com/v1'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
SDK_VERSION = '5.0.0'
|
|
22
|
+
|
|
23
|
+
def initialize(client_key: nil, private_key: nil, server_key: nil, secret_key: nil, mode: 'production')
|
|
24
|
+
@client_key = server_key.presence || client_key
|
|
25
|
+
@secret_key = private_key.presence || secret_key
|
|
26
|
+
@mode = mode.presence || 'production'
|
|
27
|
+
@token = nil
|
|
28
|
+
@api_version = SDK_VERSION
|
|
29
|
+
raise ArgumentError, "개발환경 mode는 development, stage, production 중에서 선택이 가능합니다." if API[@mode.to_sym].blank?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# API URL을 변경
|
|
33
|
+
# Comment by GOSOMI
|
|
34
|
+
# @date: 2023-05-26
|
|
35
|
+
def set_api_url(url)
|
|
36
|
+
API[@mode.to_sym] = url
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# API 버전을 설정한다
|
|
40
|
+
# Comment by Gosomi
|
|
41
|
+
# Date: 2022-07-29
|
|
42
|
+
def set_api_version(version)
|
|
43
|
+
raise ArgumentError, 'API Version은 4.0.0 이상만 설정이 가능합니다.' if version < '4.0.0'
|
|
44
|
+
@api_version = version
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module BootpayStore::Concern::AlimtalkMessage
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
# 알림톡 발송내역·집계 — GET /v1/alimtalk/messages 계열
|
|
5
|
+
#
|
|
6
|
+
# **유료** 알림톡만 조회된다(무료 커머스 알림톡은 포함되지 않는다).
|
|
7
|
+
# 상태는 벤더 결과 동기화로 확정되므로 접수 직후에는 requested 로 보인다.
|
|
8
|
+
#
|
|
9
|
+
# @comment_by Claude (alfred)
|
|
10
|
+
# @date: 26-08-27
|
|
11
|
+
included do
|
|
12
|
+
# 발송내역 목록을 조회한다 (GET /v1/alimtalk/messages)
|
|
13
|
+
# status: requested·success·failed·canceled
|
|
14
|
+
# to: 수신번호(하이픈 무관, 정확 매칭) / ref_id: 발송 시 넘긴 멱등키
|
|
15
|
+
# ⚠️ 기간 기본값은 최근 30일이고 최대 조회 폭은 92일이다 — 초과분은 거부하지 않고 시작일을 당겨 잘라낸다.
|
|
16
|
+
# 실제 적용된 구간은 응답의 period 로 확인한다.
|
|
17
|
+
# 응답: { list: [...], count:, page:, per:, period: { from:, to: } }
|
|
18
|
+
def alimtalk_message_list(template_code: nil, status: nil, ref_id: nil, to: nil,
|
|
19
|
+
s_at: nil, e_at: nil, page: nil, limit: nil)
|
|
20
|
+
request(
|
|
21
|
+
uri: 'alimtalk/messages',
|
|
22
|
+
method: :get,
|
|
23
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
24
|
+
params: {
|
|
25
|
+
template_code: template_code,
|
|
26
|
+
status: status,
|
|
27
|
+
ref_id: ref_id,
|
|
28
|
+
to: to,
|
|
29
|
+
s_at: s_at,
|
|
30
|
+
e_at: e_at,
|
|
31
|
+
page: page,
|
|
32
|
+
limit: limit # 서버 기본 20, 최대 100
|
|
33
|
+
}.compact
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# 기간 집계를 조회한다 (GET /v1/alimtalk/messages/stats)
|
|
38
|
+
# 일자별 집계 원장에서 읽으므로 응답이 빠르다.
|
|
39
|
+
# 응답: { period:, totals: { sent, success, failed, fallback, opted_out_hit, rejected, canceled, success_rate },
|
|
40
|
+
# daily: [...], billing: { billable_count, unit_price, fallback_count, ..., amount } }
|
|
41
|
+
# ⚠️ billing.unit_price_source 가 'default' 면 **잠정 단가**다(확정 청구액이 아니다).
|
|
42
|
+
# ⚠️ billable_count 는 성공 − 폴백이다 — 폴백분은 LMS 단가로 따로 계산된다.
|
|
43
|
+
def alimtalk_message_stats(s_at: nil, e_at: nil)
|
|
44
|
+
request(
|
|
45
|
+
uri: 'alimtalk/messages/stats',
|
|
46
|
+
method: :get,
|
|
47
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
48
|
+
params: {
|
|
49
|
+
s_at: s_at,
|
|
50
|
+
e_at: e_at
|
|
51
|
+
}.compact
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 단건 발송 결과를 조회한다 (GET /v1/alimtalk/messages/:receipt_id)
|
|
56
|
+
# 실패 사유는 error_code·error_message 에 담긴다.
|
|
57
|
+
# fallback_type 은 폴백이 꺼진 건이면 null, 켜진 건이면 LMS 다.
|
|
58
|
+
# 다른 프로젝트의 건이거나 없으면 404(3025).
|
|
59
|
+
def alimtalk_message_detail(receipt_id:)
|
|
60
|
+
request(
|
|
61
|
+
uri: "alimtalk/messages/#{receipt_id}",
|
|
62
|
+
method: :get,
|
|
63
|
+
headers: { 'Bootpay-Role' => 'user' }
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module BootpayStore::Concern::AlimtalkOfficial
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
# 부트페이 공식 알림톡 템플릿 카탈로그 — GET/POST /v1/alimtalk/official 계열
|
|
5
|
+
#
|
|
6
|
+
# 부트페이가 미리 카카오 승인을 받아 둔 템플릿이라, 그룹키가 등록된 채널이면 **검수 없이 즉시 발송**된다.
|
|
7
|
+
# `alimtalk_sender_create` 로 채널을 등록하면 그룹 등록이 함께 끝나므로 따로 채택할 것이 없다.
|
|
8
|
+
#
|
|
9
|
+
# 전부 조회 계열이라 부작용이 없다(자체 DB 만 본다).
|
|
10
|
+
# @date: 26-08-27 채택(alimtalk_official_adopt) 제거 — 서버에서도 엔드포인트를 비활성화했다.
|
|
11
|
+
#
|
|
12
|
+
# @comment_by Claude (alfred)
|
|
13
|
+
# @date: 26-08-27
|
|
14
|
+
included do
|
|
15
|
+
# 공식 템플릿을 검색한다 (GET /v1/alimtalk/official)
|
|
16
|
+
# keyword 는 본문·이름·분류를 부분일치(대소문자 무시)로 훑는다.
|
|
17
|
+
# msg_type 은 BA(기본형)·EX(부가정보형)만 존재한다 — 그룹 템플릿이라 AD/MI 는 쓸 수 없다.
|
|
18
|
+
# ksp_id 를 주면 그 채널의 변수 예문 사전으로 variable_examples 를 채워 준다(표시용).
|
|
19
|
+
# 응답: { list: [...], count:, page:, per:, categories: [...] }
|
|
20
|
+
def alimtalk_official_list(keyword: nil, category: nil, msg_type: nil, page: nil, per: nil, ksp_id: nil)
|
|
21
|
+
request(
|
|
22
|
+
uri: 'alimtalk/official',
|
|
23
|
+
method: :get,
|
|
24
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
25
|
+
params: {
|
|
26
|
+
q: keyword, # 서버는 q 를 먼저 보고 없으면 keyword 를 본다 — 정본 키인 q 로 보낸다
|
|
27
|
+
category: category,
|
|
28
|
+
msg_type: msg_type,
|
|
29
|
+
page: page,
|
|
30
|
+
per: per, # 서버 기본 20, 최대 100 으로 clamp
|
|
31
|
+
ksp_id: ksp_id
|
|
32
|
+
}.compact
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 보내려는 문구로 공식 템플릿을 추천받는다 (POST /v1/alimtalk/official/recommend)
|
|
37
|
+
# 유사도 score(0~1) 내림차순으로 돌려준다.
|
|
38
|
+
def alimtalk_official_recommend(text:, category: nil, limit: nil, ksp_id: nil)
|
|
39
|
+
request(
|
|
40
|
+
uri: 'alimtalk/official/recommend',
|
|
41
|
+
method: :post,
|
|
42
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
43
|
+
payload: {
|
|
44
|
+
text: text,
|
|
45
|
+
category: category,
|
|
46
|
+
limit: limit, # 서버 기본 5
|
|
47
|
+
ksp_id: ksp_id
|
|
48
|
+
}.compact
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 공식 템플릿 상세를 조회한다 (GET /v1/alimtalk/official/:code)
|
|
53
|
+
# code 는 서버 채번 코드(슬래시를 포함하지 않는다). 없거나 미노출이면 404(3015).
|
|
54
|
+
def alimtalk_official_detail(code:, ksp_id: nil)
|
|
55
|
+
request(
|
|
56
|
+
uri: "alimtalk/official/#{code}",
|
|
57
|
+
method: :get,
|
|
58
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
59
|
+
params: { ksp_id: ksp_id }.compact
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module BootpayStore::Concern::AlimtalkOptout
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
# 알림톡 수신거부 — /v1/alimtalk/optouts 계열 (가맹점 CRM 수신거부 동기화용)
|
|
5
|
+
#
|
|
6
|
+
# 발송 판정과 **같은 기준**으로 다룬다 — 부트페이 전역(global) + 내 프로젝트.
|
|
7
|
+
# ⚠️ 전역 건은 **조회는 되지만 해제할 수 없다**(releasable: false).
|
|
8
|
+
# 이걸 노출하지 않으면 "화면엔 수신거부가 아닌데 발송은 3021 로 막히는" 상태가 된다.
|
|
9
|
+
#
|
|
10
|
+
# @comment_by Claude (alfred)
|
|
11
|
+
# @date: 26-08-27
|
|
12
|
+
included do
|
|
13
|
+
# 수신거부 목록을 조회한다 (GET /v1/alimtalk/optouts)
|
|
14
|
+
# phone 은 숫자만 남겨 **부분일치**로 찾는다(정확 매칭이 아니다). 50건 단위로 페이징된다.
|
|
15
|
+
# 응답: { list: [{ id:, phone:, scope:, global:, releasable:, source:, reason:, opted_out_at:, created_at: }],
|
|
16
|
+
# count:, page: }
|
|
17
|
+
def alimtalk_optout_list(phone: nil, page: nil)
|
|
18
|
+
request(
|
|
19
|
+
uri: 'alimtalk/optouts',
|
|
20
|
+
method: :get,
|
|
21
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
22
|
+
params: {
|
|
23
|
+
phone: phone,
|
|
24
|
+
page: page
|
|
25
|
+
}.compact
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 수신거부를 등록한다 (POST /v1/alimtalk/optouts)
|
|
30
|
+
# 내 프로젝트 스코프로 등록된다(source: api). 같은 번호를 다시 등록해도 멱등이다.
|
|
31
|
+
def alimtalk_optout_create(phone:, reason: nil)
|
|
32
|
+
request(
|
|
33
|
+
uri: 'alimtalk/optouts',
|
|
34
|
+
method: :post,
|
|
35
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
36
|
+
payload: {
|
|
37
|
+
phone: phone,
|
|
38
|
+
reason: reason
|
|
39
|
+
}.compact
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# 발송 전에 수신거부를 사전 확인한다 (POST /v1/alimtalk/optouts/check)
|
|
44
|
+
# 발송 판정과 **같은 축**으로 대조하므로, 벌크에서 skipped 로 낭비될 건을 미리 뺄 수 있다.
|
|
45
|
+
# 단건(phone)·다건(phones) 모두 받는다. ⚠️ 1회 최대 1,000건이고 넘으면 -48 이다(중복은 서버가 제거).
|
|
46
|
+
# 응답: { list: [{ phone:, opted_out:, global:, releasable:, opted_out_at: }], count:, opted_out_count: }
|
|
47
|
+
def alimtalk_optout_check(phones: nil, phone: nil)
|
|
48
|
+
request(
|
|
49
|
+
uri: 'alimtalk/optouts/check',
|
|
50
|
+
method: :post,
|
|
51
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
52
|
+
payload: {
|
|
53
|
+
phones: phones,
|
|
54
|
+
phone: phone
|
|
55
|
+
}.compact
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 수신거부를 해제한다 (DELETE /v1/alimtalk/optouts/:phone)
|
|
60
|
+
# 내 프로젝트 스코프 건만 해제되며 멱등이다(없어도 성공).
|
|
61
|
+
# ⚠️ 전역 차단은 해제되지 않고 global_blocked: true 로 알려 준다 —
|
|
62
|
+
# "지웠는데 여전히 막히는" 상태를 응답으로 드러내기 위함이다.
|
|
63
|
+
# 응답: { phone:, released:, global_blocked: }
|
|
64
|
+
def alimtalk_optout_release(phone:)
|
|
65
|
+
request(
|
|
66
|
+
uri: "alimtalk/optouts/#{phone}",
|
|
67
|
+
method: :delete,
|
|
68
|
+
headers: { 'Bootpay-Role' => 'user' }
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module BootpayStore::Concern::AlimtalkSend
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
# 알림톡 발송 — POST /v1/alimtalk/send · /send/bulk · DELETE /send/:receipt_id
|
|
5
|
+
#
|
|
6
|
+
# ⚠️ **실제로 카카오톡이 발송되고 과금된다. 샌드박스가 없다.**
|
|
7
|
+
#
|
|
8
|
+
# 처리 순서: 멱등 확인 → 템플릿·채널 해석 → 발송권한 → 지갑 자격 → 발송제어 → 폴백 확정(발신번호 확보)
|
|
9
|
+
# → 수신거부 대조 → 변수 치환·규격검증 → 접수(READY) → 워커 전송
|
|
10
|
+
#
|
|
11
|
+
# - **멱등**: 같은 (프로젝트, ref_id) 로 재요청하면 기존 receipt 를 그대로 돌려준다. 실패한 건만 재발송된다.
|
|
12
|
+
# - **필수 변수**: 템플릿 응답의 required_variables 를 모두 채워야 한다. 하나라도 비면 3017 로 거부된다.
|
|
13
|
+
# ⚠️ 다만 실제로 치환되어 나가는 건 본문·강조 타이틀·버튼 링크뿐이다 — 보조문구와 아이템리스트형
|
|
14
|
+
# 요소는 발송 페이로드에 자리가 없어 카카오가 등록된 템플릿 문구 그대로 렌더한다.
|
|
15
|
+
# - **채널**: sender_key(공개키)로 지정한다. 생략하면 프로젝트 연동 채널로 해석하며,
|
|
16
|
+
# 연동 채널이 둘 이상일 때만 필수다(ksp_id 는 내부 문서 id 라 발송 API 에 쓰지 않는다).
|
|
17
|
+
#
|
|
18
|
+
# @comment_by Claude (alfred)
|
|
19
|
+
# @date: 26-08-27
|
|
20
|
+
included do
|
|
21
|
+
# 단건 발송 (POST /v1/alimtalk/send)
|
|
22
|
+
# variables: { company_name: '부트페이몰', user_name: '홍길동' } 형태의 치환값
|
|
23
|
+
# ref_id: 가맹점 발송 식별자 — **멱등 키**로 쓰인다
|
|
24
|
+
# reserved_at: 예약 발송 시각(ISO8601). 생략하면 즉시 발송
|
|
25
|
+
# fallback: 알림톡 실패 시 문자(LMS) 대체발송 여부.
|
|
26
|
+
# ⚠️ **미지정(nil)과 false 는 다르다** — nil 이면 프로젝트 기본값을 따르고, false 는 명시적으로 끈다.
|
|
27
|
+
# 켜면 발신번호가 등록돼 있어야 하며 없으면 3030 으로 거부된다. 대체 문자에는 수신거부 링크가 자동 포함된다.
|
|
28
|
+
# 응답: { receipt_id:, ref_id:, to:, status: } — 접수 직후 status 는 requested
|
|
29
|
+
def alimtalk_send(template_code:, to:, variables: nil, ref_id: nil, fallback: nil,
|
|
30
|
+
reserved_at: nil, sender_key: nil, user_id: nil)
|
|
31
|
+
request(
|
|
32
|
+
uri: 'alimtalk/send',
|
|
33
|
+
method: :post,
|
|
34
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
35
|
+
payload: {
|
|
36
|
+
template_code: template_code,
|
|
37
|
+
to: to,
|
|
38
|
+
variables: variables,
|
|
39
|
+
ref_id: ref_id,
|
|
40
|
+
fallback: fallback, # compact 은 nil 만 걷어내므로 false 는 그대로 전달된다
|
|
41
|
+
reserved_at: reserved_at,
|
|
42
|
+
sender_key: sender_key,
|
|
43
|
+
user_id: user_id
|
|
44
|
+
}.compact
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 벌크 발송 (POST /v1/alimtalk/send/bulk) — 1요청 = N수신자
|
|
49
|
+
# recipients: [{ to: '01012345678', ref_id: 'bulk-0001', variables: { ... } }, ...]
|
|
50
|
+
# ⚠️ 수신자 수만큼 실제 발송되고 과금된다.
|
|
51
|
+
# - 쿼터를 넘으면 요청 시점에 **전체 거부**된다(3022) — 일부만 나가지 않는다.
|
|
52
|
+
# - 개별 수신자의 실패는 건별 rejected 로 표시되고 나머지는 정상 발송된다.
|
|
53
|
+
# - 수신거부 번호는 skipped 이며 **과금되지 않고 발송 기록도 만들지 않는다**.
|
|
54
|
+
# - fallback 은 요청 단위로 한 번만 판정한다 — 발신번호가 없으면 요청 전체가 3030 으로 거부된다.
|
|
55
|
+
# 응답: { count:, requested:, skipped:, rejected:, receipts: [...] }
|
|
56
|
+
def alimtalk_send_bulk(template_code:, recipients:, fallback: nil, reserved_at: nil,
|
|
57
|
+
sender_key: nil, user_id: nil)
|
|
58
|
+
request(
|
|
59
|
+
uri: 'alimtalk/send/bulk',
|
|
60
|
+
method: :post,
|
|
61
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
62
|
+
payload: {
|
|
63
|
+
template_code: template_code,
|
|
64
|
+
recipients: recipients,
|
|
65
|
+
fallback: fallback,
|
|
66
|
+
reserved_at: reserved_at,
|
|
67
|
+
sender_key: sender_key,
|
|
68
|
+
user_id: user_id
|
|
69
|
+
}.compact
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 예약 발송을 취소한다 (DELETE /v1/alimtalk/send/:receipt_id)
|
|
74
|
+
# 접수(READY) 상태의 예약 건만 취소할 수 있다 — 이미 전송에 들어갔으면 3023 이다.
|
|
75
|
+
def alimtalk_send_cancel(receipt_id:)
|
|
76
|
+
request(
|
|
77
|
+
uri: "alimtalk/send/#{receipt_id}",
|
|
78
|
+
method: :delete,
|
|
79
|
+
headers: { 'Bootpay-Role' => 'user' }
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module BootpayStore::Concern::AlimtalkSender
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
# 알림톡 발신프로필(카카오채널) 생명주기 — GET /v1/alimtalk/categories · /senders 계열
|
|
5
|
+
#
|
|
6
|
+
# 카테고리 조회 → OTP 발송 → 발신프로필 등록 → 목록/상세 → 연동 해지 순으로 쓴다.
|
|
7
|
+
# 등록이 끝나면 서버가 그룹키 등록까지 자동으로 하므로, 공식 템플릿은 별도 채택 없이 바로 발송된다.
|
|
8
|
+
#
|
|
9
|
+
# ⚠️ 실제 부작용: `alimtalk_sender_otp` 는 채널 관리자 휴대폰으로 **문자를 실제 발송**하고,
|
|
10
|
+
# `alimtalk_sender_create` 는 카카오에 발신프로필을 **실제 등록**한다. 샌드박스가 없다.
|
|
11
|
+
#
|
|
12
|
+
# ★Idempotency-Key 를 싣지 않는다★ 알림톡 API 는 이 헤더를 읽지 않는다(멱등은 발송의 ref_id 로만 성립).
|
|
13
|
+
# invoice/product 처럼 무조건 붙이면 서버가 주지 않는 보장을 주는 것처럼 보인다.
|
|
14
|
+
# ★Bootpay-Role 은 항상 user★ 알림톡 스코프 키가 전부 `user:alimtalk_*` 다.
|
|
15
|
+
#
|
|
16
|
+
# @comment_by Claude (alfred)
|
|
17
|
+
# @date: 26-08-27
|
|
18
|
+
included do
|
|
19
|
+
# 카카오 카테고리 목록을 조회한다 (GET /v1/alimtalk/categories)
|
|
20
|
+
# 발신프로필 등록 시 필요한 category_code 후보다. 벤더 응답을 그대로 프록시한다.
|
|
21
|
+
def alimtalk_categories
|
|
22
|
+
request(
|
|
23
|
+
uri: 'alimtalk/categories',
|
|
24
|
+
method: :get,
|
|
25
|
+
headers: { 'Bootpay-Role' => 'user' }
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 채널 관리자폰으로 OTP 를 발송한다 (POST /v1/alimtalk/senders/otp)
|
|
30
|
+
# ⚠️ 실제로 문자가 나간다. 여기서 받은 인증번호를 alimtalk_sender_create 의 otp 로 넘긴다.
|
|
31
|
+
def alimtalk_sender_otp(yellow_id:, phone:)
|
|
32
|
+
request(
|
|
33
|
+
uri: 'alimtalk/senders/otp',
|
|
34
|
+
method: :post,
|
|
35
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
36
|
+
payload: {
|
|
37
|
+
yellow_id: yellow_id,
|
|
38
|
+
phone: phone
|
|
39
|
+
}.compact
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# 발신프로필을 등록한다 (POST /v1/alimtalk/senders)
|
|
44
|
+
# ⚠️ 카카오에 발신프로필이 실제 등록된다. 같은 yellow_id 를 다시 등록하면 기존 프로필을 재사용한다(dedup).
|
|
45
|
+
# 등록 성공 시 그룹키 등록까지 서버가 수행하므로 공식 카탈로그 전체를 바로 발송할 수 있다.
|
|
46
|
+
def alimtalk_sender_create(otp:, yellow_id:, phone:, category_code:)
|
|
47
|
+
request(
|
|
48
|
+
uri: 'alimtalk/senders',
|
|
49
|
+
method: :post,
|
|
50
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
51
|
+
payload: {
|
|
52
|
+
otp: otp,
|
|
53
|
+
yellow_id: yellow_id,
|
|
54
|
+
phone: phone,
|
|
55
|
+
category_code: category_code
|
|
56
|
+
}.compact
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 연동한 채널 목록을 조회한다 (GET /v1/alimtalk/senders)
|
|
61
|
+
# 자체 DB 만 조회하며 벤더를 호출하지 않는다. 응답은 { list: [...], count: N }.
|
|
62
|
+
def alimtalk_sender_list
|
|
63
|
+
request(
|
|
64
|
+
uri: 'alimtalk/senders',
|
|
65
|
+
method: :get,
|
|
66
|
+
headers: { 'Bootpay-Role' => 'user' }
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# 채널 상세를 조회한다 (GET /v1/alimtalk/senders/:id)
|
|
71
|
+
# sync: true 면 벤더에서 채널 상태를 다시 읽어 반영한다(느리다). 미지정이면 자체 DB 만 본다.
|
|
72
|
+
# ⚠️ 미연동/미존재 채널은 404, 다른 프로젝트의 채널은 403 으로 오며 둘 다 error_code 는 3024 다.
|
|
73
|
+
def alimtalk_sender_detail(ksp_id:, sync: nil)
|
|
74
|
+
request(
|
|
75
|
+
uri: "alimtalk/senders/#{ksp_id}",
|
|
76
|
+
method: :get,
|
|
77
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
78
|
+
params: { sync: sync }.compact
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 채널 연동을 해지한다 (DELETE /v1/alimtalk/senders/:id)
|
|
83
|
+
# 이 프로젝트와의 연동만 끊는다 — 채널 모델과 템플릿은 보존된다. 성공 시 본문은 null 이다.
|
|
84
|
+
def alimtalk_sender_release(ksp_id:)
|
|
85
|
+
request(
|
|
86
|
+
uri: "alimtalk/senders/#{ksp_id}",
|
|
87
|
+
method: :delete,
|
|
88
|
+
headers: { 'Bootpay-Role' => 'user' }
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# 채널 변수 예문 사전을 갱신한다 (PUT /v1/alimtalk/senders/:id/variable_examples)
|
|
93
|
+
# 템플릿 미리보기에서 #{user_name} 대신 '홍길동' 처럼 읽히게 하는 **표시용** 값이다.
|
|
94
|
+
# ⚠️ 발송값이 아니다 — 벤더로 전송되지 않으므로 검수 상태와 무관하다. 보낸 키만 덮어쓴다(부분 갱신).
|
|
95
|
+
# examples: { user_name: '홍길동', company_name: '부트페이몰' } — 키에 '.' 이나 선행 '$' 는 쓸 수 없다.
|
|
96
|
+
def alimtalk_sender_variable_examples(ksp_id:, examples:)
|
|
97
|
+
request(
|
|
98
|
+
uri: "alimtalk/senders/#{ksp_id}/variable_examples",
|
|
99
|
+
method: :put,
|
|
100
|
+
headers: { 'Bootpay-Role' => 'user' },
|
|
101
|
+
payload: { examples: examples }.compact
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|