Repository navigation
Expand file tree
/
Copy pathPurchase.php
More file actions
317 lines (270 loc) · 11.4 KB
/
Copy pathPurchase.php
File metadata and controls
317 lines (270 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
use App\Payment\CoinProduct;
use App\Payment\PayAccess;
/**
* 코인 결제 시작. `app.<도메인>` 에서만 열린다.
*
* POST /purchase payments(status='created') 1행 + payment_events 1행
*
* CORS 가 없다. /conversion 과 다른 점이 여기다 — 이 경로는 브라우저의
* 크로스오리진 요청이 아니라 **같은 오리진(app.)의 폼 제출이거나 서버 간
* 호출**이다. CorsPolicy 를 붙이면 A-3·B-1 의 CORS 측정에 성격이 다른
* 트래픽이 섞인다 → 계획 12장 결정 1 과 같은 이유
*
* ──────────────────────────────────────────────────────────
* **인증이 없다.** 본문의 user_uid 를 그대로 믿는다.
*
* `/signup` 이 미구현이라 인증 기반 자체가 없다. 감수한 것이 아니라
* **미구현**이다. 악용 범위는 이렇다 —
*
* ✓ 누구나 임의의 user_uid 로 payments 행(status='created')을 만들 수 있다
* ✗ 그러나 전환도 코인도 만들 수 없다. 그 둘은 captured 웹훅에서만
* 발화하고, 웹훅은 HMAC 서명을 요구한다
*
* 즉 **서명이 GA4 오염을 막는 실질 방어선**이다. 스텁 PG 라 실제 청구도
* 없다 → 계획 12장 결정 5
*
* **09-19 오후부터 이 방어선에 문이 하나 있다** — 카드 없는 시연 버튼
* (`/pay/stub/confirm`)이 서버 대신 서명한다. 받는 결제는 "그 버튼이 방금 만든
* demo- 결제" 뿐이라 기존 결제는 못 건드리지만, **누구나 새 시연 결제를 captured
* 로 만들 수 있고 그 전환은 매체로 간다**(사용자 결정) → docs/plan-multi-pg.md 6장
*
* **이 근거는 `pg=stub` 에서만 성립한다(09-19).** 이니시스 테스트 MID 는
* 실승인이다. 그래서 `pg≠stub` 은 입장권 쿠키(PayAccess)를 요구한다. 입장권은
* 안내 화면의 버튼으로 누구나 받는다(09-19 오후) — 인증이 아니라, 크롤러와
* "모르고 누르기" 를 막는 한 번의 명시적 동작이다
* → docs/plan-multi-pg.md C4
* ──────────────────────────────────────────────────────────
*
* 금액은 **서버 상품표와 대조한다**. 클라이언트가 금액을 정하면 그건
* 입력 오류가 아니라 결제 조작이다 → src/Payment/CoinProduct
*/
class Purchase extends MY_Controller
{
/** payments.idempotency_key 는 VARCHAR(64) 다. */
const IDEM_MAX_LENGTH = 64;
public function __construct()
{
parent::__construct();
$this->requireHost('app');
$this->load->library('visitor'); // 생성자에서 visit_model 을 함께 올린다
$this->load->model('payment_model');
}
/**
* 결제를 일으킨 방문.
*
* **쿠키가 있을 때만 찾는다.** `Visitor::current()` 는 방문이 없으면
* 새로 만드는데, 여기서 만들면 `landing_path = /purchase` 인 유입 없는
* 방문이 생겨 어트리뷰션 보존율의 분모를 오염시킨다. Conversion.php 가
* 같은 이유로 같은 선택을 했다.
*
* 없으면 NULL 이고, 그때는 가입 접점으로 떨어진다
* → Payment_model::attribution()
*/
private function currentVisitId()
{
$name = getenv('COOKIE_VID_NAME') ?: 'ab_vid';
$cookie = $this->input->cookie($name, TRUE);
if ( ! is_string($cookie) OR preg_match('/\A[0-9a-f]{32}\z/', $cookie) !== 1)
{
return NULL;
}
return $this->visit_model->findByUid($cookie);
}
public function index()
{
if (strtoupper($this->server('REQUEST_METHOD')) !== 'POST')
{
$this->problem(405, 'method-not-allowed', 'POST 만 받습니다.');
}
$body = $this->body();
$product = CoinProduct::find($body['product'] ?? NULL);
if ($product === NULL)
{
$this->problem(422, 'invalid-request',
'product 는 '.implode(' | ', CoinProduct::codes()).' 중 하나여야 합니다.',
array('field' => 'product'));
}
if ( ! $product->matches(self::integer($body['amount_minor'] ?? NULL), self::text($body['currency'] ?? NULL)))
{
/*
* 기대값을 응답에 담는다.
*
* 가격은 어차피 공개된 값이고, 담지 않으면 호출자가 422 를 받고도
* 무엇으로 고쳐야 할지 모른다. 반대로 담지 말아야 할 것은
* 서명·창 크기처럼 방어의 크기를 알려 주는 값이다.
*/
$this->problem(422, 'invalid-request',
'amount_minor·currency 가 상품표와 다릅니다. 금액은 서버가 정합니다.',
array(
'field' => 'amount_minor',
'expected_amount_minor' => $product->amountMinor,
'expected_currency' => $product->currency,
));
}
$pg = $this->pgFor(strtolower(self::text($body['pg'] ?? NULL)), $product);
$idempotencyKey = self::text($body['idempotency_key'] ?? NULL);
if ($idempotencyKey === '')
{
$this->problem(422, 'invalid-request',
'idempotency_key 는 필수입니다. 같은 결제 시도면 반드시 같은 값이어야 합니다.',
array('field' => 'idempotency_key'));
}
/*
* 길이를 여기서 막는다. 컬럼이 VARCHAR(64) 라 넘치면 잘리고,
* 잘린 키끼리 겹치면 **서로 다른 결제가 같은 결제로 처리된다.**
* (stricton=TRUE 라 실제로는 MySQL 이 오류를 내지만, 그 오류는
* db_debug 때문에 HTML 에러 화면으로 나간다 → 계획 11장 ②)
*/
if (strlen($idempotencyKey) > self::IDEM_MAX_LENGTH)
{
$this->problem(422, 'invalid-request',
'idempotency_key 는 '.self::IDEM_MAX_LENGTH.'바이트를 넘을 수 없습니다.',
array('field' => 'idempotency_key'));
}
$userId = $this->payment_model->findUserIdByUid(strtolower(self::text($body['user_uid'] ?? NULL)));
if ($userId === NULL)
{
/*
* 404 다. 422 가 아니다 — 형식이 틀린 것과 그런 회원이 없는 것은
* 호출자가 할 일이 다르다(고쳐서 재요청 vs 가입부터).
* Conversion.php 의 visit-not-found 와 같은 판단이다.
*/
$this->problem(404, 'user-not-found', '그런 회원이 없습니다. cli/seed user 로 만드세요.');
}
$result = $this->payment_model->createIfAbsent(array(
'user_id' => $userId,
'amount_minor' => $product->amountMinor,
'currency' => $product->currency,
'idempotency_key' => $idempotencyKey,
'product' => $product->code,
'pg' => $pg,
// 결제를 일으킨 방문. 쿠키가 없으면 NULL 이고, 그때만 가입 접점으로 떨어진다.
'visit_id' => $this->currentVisitId(),
// 브라우저가 있는 마지막 순간이다. 웹훅에는 없다 → clientContext()
'client_context' => $this->browserContext($this->server('HTTP_REFERER')),
));
if ($result['duplicated'])
{
$this->respondDuplicate($idempotencyKey);
return;
}
log_message('info', sprintf('purchase new uid=%s product=%s user=%d',
$result['uid_hex'], $product->code, $userId));
$this->json(201, array(
'payment_uid' => $result['uid_hex'],
'status' => 'created',
'duplicated' => FALSE,
'trace_id' => $this->trace_id,
));
}
// ────────────────────────────────────────────────────────
/**
* 어느 PG 로 결제하는가. 없으면 스텁 — D-3 측정 스크립트가 그대로 돈다.
*
* 실PG 는 세 가지를 본다: 이름이 맞는가(422) · 입장권이 있는가(403) ·
* 그 PG 가 지금 이 통화를 받는가(503·422). 결제 행을 만들기 **전에** 본다 —
* 받을 수 없는 결제의 행을 남기면 오래된 결제 보고에 영원히 걸린다.
*/
private function pgFor($pg, CoinProduct $product)
{
if ($pg === '' OR $pg === 'stub')
{
return 'stub';
}
$this->load->library('gateways');
if ( ! $this->gateways->isReal($pg))
{
$this->problem(422, 'invalid-request', 'pg 는 stub | '.implode(' | ', Gateways::REAL).' 중 하나여야 합니다.', array('field' => 'pg'));
}
if ( ! PayAccess::allows($this->input->cookie(PayAccess::COOKIE, FALSE), (string) (getenv('PAY_DEMO_TOKEN') ?: ''), time()))
{
$this->problem(403, 'pay-locked', '실결제 경로는 입장권이 필요합니다. 안내 화면(/tour)이나 /pay 에서 받으세요.');
}
$gateway = $this->gateways->get($pg);
if ($gateway === NULL)
{
$this->output->set_header('Retry-After: 600');
$this->problem(503, 'pg-unavailable', $pg.' 설정이 없어 결제할 수 없습니다.');
}
if ( ! $gateway->supports($product->currency))
{
$this->problem(422, 'invalid-request', $pg.' 는 '.$product->currency.' 를 받지 않습니다.', array('field' => 'pg'));
}
return $pg;
}
/**
* 같은 idempotency_key 로 다시 들어온 요청.
*
* **200 이다.** 호출자가 받아야 할 것은 "이미 처리됐다 + 그때의
* payment_uid" 이고, 그것만 있으면 재시도 루프를 끝낼 수 있다.
* 사용자가 결제 버튼을 두 번 누르는 것은 정상 동작이다 → api-spec 4장
*
* 현재 status 를 함께 준다. 첫 요청 뒤 웹훅이 이미 왔을 수 있어서
* 'created' 라고 단정하면 틀린 값을 주게 된다.
*/
private function respondDuplicate($idempotencyKey)
{
$existing = $this->payment_model->findByIdempotencyKey($idempotencyKey);
if ($existing === NULL)
{
/*
* UNIQUE 에는 걸렸는데 행이 없다.
*
* INSERT IGNORE 가 0 을 돌려줬다는 것은 충돌한 행이 커밋돼
* 있었다는 뜻이다(커밋 전이면 잠금에서 기다린다). 그러고도 못
* 찾으면 걸린 것이 uq_idem 이 아니라 uq_payment_uid 라는 말이고,
* 그건 UUIDv7 이 겹쳤다는 뜻이라 버그다.
* Conversion.php 와 같은 자리, 같은 판단이다.
*/
log_message('error', 'purchase: 중복 판정인데 기존 결제를 못 찾았다. key='.$idempotencyKey);
$this->problem(409, 'payment-conflict', '중복으로 판정됐지만 기존 결제를 찾지 못했습니다.');
}
log_message('info', 'purchase dup uid='.$existing['uid_hex'].' key='.$idempotencyKey);
$this->json(200, array(
'payment_uid' => $existing['uid_hex'],
'status' => $existing['status'],
'duplicated' => TRUE,
'trace_id' => $this->trace_id,
));
}
/**
* 본문 파싱.
*
* Content-Type 을 보지 않는다. Conversion.php 와 같은 이유다 —
* 헤더 하나 빠뜨렸다고 415 로 돌려보내면 결제 시도 한 건이 사라진다.
*/
private function body()
{
$raw = $this->input->raw_input_stream;
if ( ! is_string($raw) OR $raw === '')
{
return array();
}
$parsed = json_decode($raw, TRUE);
return is_array($parsed) ? $parsed : array();
}
/** 배열·객체가 오면 빈 문자열이 되어 필수·화이트리스트 검사에 걸린다. */
private static function text($raw)
{
return (is_string($raw) OR is_int($raw)) ? trim((string) $raw) : '';
}
/** 폼에서 온 "9900" 도 받는다. 소수부가 있으면 NULL — 상품표와 어긋난다. */
private static function integer($raw)
{
if (is_int($raw))
{
return $raw;
}
if (is_string($raw) && preg_match('/\A-?\d{1,18}\z/', trim($raw)) === 1)
{
return (int) trim($raw);
}
if (is_float($raw) && $raw === floor($raw) && abs($raw) < 9.0e18)
{
return (int) $raw;
}
return NULL;
}
}