Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php
T
HeuJung 494e581973 fix(security): KVE-2026 신고 4건 조치 — 관리자 API 권한 게이트 + 금전 처리 경쟁 조건
KISA 신고 4건(KVE-2026-1886/1887/1893/1894)을 검증하고, 미수정 2건과
동종 결함 전수 조사에서 나온 결함을 함께 조치했다.

권한 게이트 (KVE-2026-1893)

- NHN KCP 관리자 주문 연동 7경로가 admin 보유 여부만 보고 세부 권한을 보지 않아,
 업무 권한 없는 관리자가 주문번호·결제정보·수령인 연락처를 조회하고 에스크로
 배송등록까지 할 수 있었다. 조회는 orders.read, 등록은 orders.update, 설정성
 경로는 settings.read 로 게이트해 다른 PG 연동과 강도를 맞췄다.
- 마케팅 채널 저장은 코어 플러그인 설정과 같은 저장소를 덮어쓰는 우회 경로라
 core.plugins.update 를 부착했다.

첨부 해시 노출 (KVE-2026-1894 잔재)

- 비밀글의 썸네일 URL 이 목록·상세 응답에 그대로 실려 첨부 해시가 노출됐다.
 이미지 서빙은 이미 차단돼 있었으나 식별값 자체가 나갔다. 첨부 목록과 같은
 게이트를 써서 값만 가리고 필드는 유지한다.

경쟁 조건 (KVE-2026-1886 + 전수 조사)

- 쿠폰 차감을 조회 후 갱신에서 조건부 UPDATE 로 바꿔 1회 제한 쿠폰의 동시
 사용을 막고, 선점당한 주문은 409 로 되돌린다.
- 라이브 병렬 재현에서 그 롤백이 동작하지 않는 것을 확인했다. Action 훅 기본값이
 큐 래핑 + afterCommit 이라 금전 처리가 호출자 커밋 뒤에 실행되고 있었다.
 쿠폰·적립금 차감/복원 5개 구독에 sync 를 선언했고, 회귀 테스트는 손 등록이
 아니라 실제 등록 경로를 태워 고정했다.
- 동시 부분취소의 취소 누적 컬럼 lost update, 적립 lot 의 read-modify-write,
 주문옵션당 적립 lot 중복 생성을 각각 행 잠금·컬럼 연산·유니크 제약으로 막았다.
 기설치본의 중복 lot 은 인덱스 생성 전에 금액을 합산해 한 줄로 통합한다.

규정·문서

- sync 판정 기준을 hooks.md 에 명문화하고 /AGENTS.md Listener 표와
 coverage manifest 에 반영했다. 같은 증상을 다시 만났을 때의 진단 경로를
 트러블슈팅 사례로 남겼다.
2026-08-22 01:19:36 +09:00

388 lines
19 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Traits;
use App\Extension\HookManager;
use App\Helpers\ResponseHelper;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException;
use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException;
use Modules\Sirsoft\Ecommerce\Exceptions\InsufficientStockException;
use Modules\Sirsoft\Ecommerce\Exceptions\MileageValidationException;
use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Exceptions\UnsupportedPaymentCurrencyException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\CreateOrderRequest;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
/**
* 주문 생성 공통 흐름 Trait
*
* 회원/비회원 주문 생성을 단일 endpoint(POST user/orders → Public\OrderController::store)
* 에서 처리하는 공통 흐름을 한곳에 둡니다. 임시주문 조회 → 재고 검증 → 주문 생성 →
* PG 필요 여부 판단 → 임시주문 삭제 → 응답 조립 → 예외 처리까지 공통이며,
* 회원/비회원이 갈리는 부분(응답 Resource, 회원 전용 후처리)만 호출 측 콜백으로 위임합니다.
* (회원 컨텍스트는 Auth::id() 로 판정 — userId 가 null 이면 비회원)
*
* 이 Trait을 사용하는 컨트롤러는 다음 프로퍼티를 생성자 주입으로 보유해야 합니다.
* - $tempOrderService (TempOrderService)
* - $stockService (StockService)
* - $orderProcessingService (OrderProcessingService)
*/
trait HandlesOrderCreation
{
/**
* 주문 생성 공통 흐름을 수행합니다.
*
* @param CreateOrderRequest $request 검증된 주문 생성 요청
* @param int|null $userId 회원 ID (비회원은 null)
* @param callable $buildResponseData 응답 데이터 빌더. 시그니처: fn (Order $order, bool $requiresPg, string $pgProvider): array
* @param callable|null $afterCreate 주문 생성 직후 후처리 콜백 (회원 전용 배송지 자동저장 등). 시그니처: fn (Order $order, bool $requiresPg): void
* @param string $logEndpoint API 사용량 로그 엔드포인트 식별자
* @return JsonResponse 생성된 주문 정보를 포함한 JSON 응답
*/
protected function processOrderCreation(
CreateOrderRequest $request,
?int $userId,
callable $buildResponseData,
?callable $afterCreate = null,
string $logEndpoint = 'order.store'
): JsonResponse {
try {
$this->logApiUsage($logEndpoint);
$cartKey = $request->header('X-Cart-Key');
// 임시 주문 조회 (회원: user_id 기준, 비회원: cart_key 기준)
$tempOrder = $this->tempOrderService->getTempOrder($userId, $cartKey);
// 임시 주문 없거나 만료된 경우
if (! $tempOrder) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.temp_order_not_found',
404
);
}
// 재고 검증
$items = $this->buildStockValidationItems($tempOrder);
$this->stockService->validateStock($items);
// 훅: 결제 진입 전 (사용자 본인인증(IDV) 정책 가드 지점 — checkout_verification purpose).
// EnforceIdentityPolicyListener 가 'sirsoft-ecommerce.checkout.before_payment' 정책이
// 활성이고 grace 만료 시 IdentityVerificationRequiredException(428) 을 throw 한다.
// 이 예외는 \Error 를 상속하므로 아래 catch (Exception) 에 걸리지 않고 전파되어 428 이 유지된다.
// 주문 레코드 생성(createFromTempOrder) 직전이므로 428 로 막혀도 주문 부산물이 생기지 않는다.
HookManager::doAction('sirsoft-ecommerce.checkout.before_payment', $tempOrder, $userId);
// 주문 생성 (회원/비회원 공통 — TempOrder.user_id 가 주문 user_id 로 그대로 반영)
$order = $this->orderProcessingService->createFromTempOrder(
tempOrder: $tempOrder,
ordererInfo: $request->getOrdererInfo(),
shippingInfo: $request->getShippingInfo(),
paymentMethod: $request->input('payment_method'),
expectedTotalAmount: (float) $request->input('expected_total_amount'),
shippingMemo: $request->input('shipping_memo'),
depositorName: $request->input('depositor_name'),
dbankInfo: $request->getDbankInfo(),
guestLookupPassword: $request->getGuestLookupPassword(),
cashReceiptInfo: $request->getCashReceiptInfo(),
refundBankInfo: $request->getRefundBankInfo()
);
$order->load(['options', 'payment', 'shippingAddress']);
// PG 결제 필요 여부 판단 — 확장 결제수단(간편결제)도 카탈로그 선언으로 올바르게 판정된다.
// enum 으로 판정하면 확장 ID 가 null → PG 불필요로 오인되어 관리자 알림이 오발송되고
// TempOrder 가 즉시 삭제되어 재결제가 막힌다(#475).
$pgProvider = $this->orderProcessingService->determinePgProvider(
$order->payment->paymentMethodId()
);
$requiresPg = $order->payment->needsPgProvider()
&& ! in_array($pgProvider, ['manual', 'internal', 'none'])
// 결제할 금액이 0원이면(전액 마일리지/예치금 등 비현금 충당) PG 호출 불필요 —
// 주문 생성 시점에 이미 결제완료 확정됨. 결제수단 선택과 무관.
&& (int) $order->total_due_amount > 0;
// 임시 주문 삭제: non-PG 만 즉시 삭제, PG 결제는 completePayment() 시점에 삭제
if (! $requiresPg) {
$this->tempOrderService->deleteTempOrder($userId, $cartKey);
}
// 생성 직후 후처리 (회원 전용 배송지 자동저장 등) — 호출 측 위임
if ($afterCreate !== null) {
$afterCreate($order, $requiresPg);
}
// 응답 데이터 조립 — 회원/비회원 응답 Resource 차이는 호출 측 위임
$responseData = $buildResponseData($order, $requiresPg, $pgProvider);
return ResponseHelper::success('sirsoft-ecommerce::messages.order.created', $responseData, 201);
} catch (PaymentAmountMismatchException $e) {
return ResponseHelper::error(
__('sirsoft-ecommerce::exceptions.payment_amount_mismatch', [
'expected' => ecommerce_format_price($e->getExpectedAmount()),
'actual' => ecommerce_format_price($e->getActualAmount()),
]),
422
);
} catch (UnsupportedPaymentCurrencyException $e) {
// 결제 통화로 청구 불가(환율 미설정/0 또는 환산액 0) — 명확한 422 차단
return ResponseHelper::error(
'sirsoft-ecommerce::exceptions.unsupported_payment_currency',
422,
['code' => 'unsupported_payment_currency', 'currency' => $e->getCurrency()],
['currency' => $e->getCurrency()]
);
} catch (InsufficientStockException $e) {
return ResponseHelper::error(
'sirsoft-ecommerce::exceptions.order_create_failed',
422,
[
'detail' => $e->getMessage(),
'insufficient_items' => $e->getInsufficientItems(),
]
);
} catch (CartUnavailableException $e) {
// 구매 대상 제한 등으로 구매 불가 상품이 있는 경우 (회원/비회원 공통)
$messageKey = $e->hasRestrictionIssue()
? 'sirsoft-ecommerce::exceptions.purchase_not_allowed'
: 'sirsoft-ecommerce::exceptions.cart_unavailable';
return ResponseHelper::error(__($messageKey), 422, [
'code' => 'cart_unavailable',
'unavailable_items' => $e->getUnavailableItems(),
'has_stock_issue' => $e->hasStockIssue(),
'has_status_issue' => $e->hasStatusIssue(),
'has_restriction_issue' => $e->hasRestrictionIssue(),
]);
} catch (CouponAlreadyUsedException $e) {
// 주문 확정 시점에 다른 주문이 같은 쿠폰을 선점했다 — 주문 트랜잭션은 롤백된 상태다.
// generic 500 이 아닌 409 로 알려 사용자가 쿠폰 소진 없이 재시도할 수 있게 한다.
Log::warning('Order create: coupon already taken by another order', [
'coupon_issue_id' => $e->getCouponIssueId(),
]);
$messageKey = $e->getMessageKey();
return ResponseHelper::error(
$messageKey,
409,
['code' => 'coupon_already_used', 'coupon_issue_id' => $e->getCouponIssueId()],
$e->getMessageParams()
);
} catch (MileageValidationException $e) {
// 마일리지 사용 정책 위반(한도/단위/최소사용액/잔액) — generic 500 이 아닌 422 명시 차단.
// 임시주문 생성 이후 설정이 바뀌었거나 임시주문이 조작된 경우 여기로 떨어진다.
Log::warning('Order create: mileage usage policy violation', [
'message' => $e->getMessage(),
]);
return ResponseHelper::error(
'sirsoft-ecommerce::exceptions.order_create_failed',
422,
['code' => 'mileage_usage_not_allowed', 'detail' => $e->getMessage()]
);
} catch (OrderProcessingException $e) {
// 주문 확정 재계산 검증 실패(쿠폰 만료/min_amount/per_user_limit/not_combinable 등)
// generic 500 이 아닌 422 로 하드 차단 — 서버 우회 방지 (U14/MP06)
Log::warning('Order create: calculation validation failed', [
'message' => $e->getMessage(),
]);
return ResponseHelper::error(
'sirsoft-ecommerce::exceptions.order_calculation_validation_failed',
422,
['code' => 'order_calculation_validation_failed']
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.order.create_failed',
500
);
}
}
/**
* 주문 생성 응답에 포함할 기본 응답 데이터를 구성합니다.
*
* 회원/비회원 컨트롤러가 응답 Resource(OrderResource / GuestOrderResource)를
* 주입해 공통 메타(redirect_url, requires_pg_payment, PG 데이터)와 합칩니다.
*
* @param Order $order 생성된 주문
* @param bool $requiresPg PG 결제 필요 여부
* @param string $pgProvider PG 공급자 식별자
* @param JsonResource $orderResource 주문 응답 리소스
* @return array 응답 데이터
*/
protected function buildOrderResponseData(Order $order, bool $requiresPg, string $pgProvider, $orderResource): array
{
$responseData = [
'order' => $orderResource,
// 상점 주소는 운영자 설정이다 — 기본값 리터럴을 내려보내면 주소를 바꾼 상점에서
// 결제를 마친 손님이 존재하지 않는 화면으로 이동한다 (공개 #85).
'redirect_url' => ShopPathResolver::path("orders/{$order->order_number}/complete"),
'requires_pg_payment' => $requiresPg,
];
if ($requiresPg) {
$responseData['pg_provider'] = "sirsoft-{$pgProvider}";
$responseData['pg_payment_data'] = $this->buildPgPaymentData($order);
// provider-agnostic 결제 진입 — provider 레지스트리가 선언한 프론트 결제 진입
// 핸들러 풀네임을 응답에 그대로 내려 템플릿이 dispatch 한다. provider 가 핸들러를
// 선언하지 않으면 키를 설정하지 않아(템플릿 PG 분기 미발화) non-PG fallback 으로
// 안전 강하한다.
$paymentHandler = $this->resolvePgPaymentHandler($pgProvider);
if ($paymentHandler !== null) {
$responseData['pg_payment_handler'] = $paymentHandler;
}
}
return $responseData;
}
/**
* PG provider 의 프론트 결제 진입 핸들러 풀네임을 레지스트리에서 조회합니다.
*
* PG 플러그인이 `sirsoft-ecommerce.payment.registered_pg_providers` 필터 훅에 등록한
* provider 엔트리의 `payment_handler` 키를 반환합니다. 미선언 시 null.
*
* @param string $pgProvider PG provider id (예: 'kginicis')
* @return string|null 프론트 결제 진입 핸들러 풀네임 또는 null
*/
protected function resolvePgPaymentHandler(string $pgProvider): ?string
{
$providers = app(EcommerceSettingsService::class)->getRegisteredPgProviders();
foreach ($providers as $provider) {
if (($provider['id'] ?? null) === $pgProvider) {
$handler = $provider['payment_handler'] ?? null;
return is_string($handler) && $handler !== '' ? $handler : null;
}
}
return null;
}
/**
* 재고 검증용 아이템 목록 생성
*
* @param TempOrder $tempOrder 임시 주문
* @return array 재고 검증용 아이템 배열
*/
protected function buildStockValidationItems(TempOrder $tempOrder): array
{
$items = [];
$calculationResult = $tempOrder->calculation_result ?? [];
foreach ($calculationResult['items'] ?? [] as $item) {
$items[] = [
'product_option_id' => $item['productOptionId'] ?? $item['product_option_id'],
'quantity' => $item['quantity'],
];
}
return $items;
}
/**
* PG 결제용 데이터 생성
*
* 주문 생성 API 응답에 포함될 PG 결제 요청 데이터를 빌드합니다.
*
* @param Order $order 주문
* @return array PG 결제 요청 데이터
*/
protected function buildPgPaymentData(Order $order): array
{
// 주문명 생성 (로컬라이즈된 첫 번째 상품명 + 외 N건)
$options = $order->options;
$locale = app()->getLocale();
$firstName = $options->first()?->product_name;
$orderName = is_array($firstName)
? ($firstName[$locale] ?? $firstName[config('app.fallback_locale', 'ko')] ?? reset($firstName) ?: '')
: ($firstName ?? '');
if ($options->count() > 1) {
$orderName .= ' 외 '.($options->count() - 1).'건';
}
// 주문자 정보 (배송지 주소에서 가져옴)
$shippingAddress = $order->shippingAddress;
// PG 청구 금액/통화 = base total_due_amount 를 주문 스냅샷 환율로 결제 통화 환산.
// amount 는 PG 가 요구하는 최소 화폐단위 정수(KRW 7058 / USD $6→600 / JPY 정수).
// 미지원 통화(환율 0/미설정)는 resolveSnapshotPaymentCharge 가 InvalidArgumentException 으로 차단.
$charge = app(CurrencyConversionService::class)
->resolveSnapshotPaymentCharge((float) $order->total_due_amount, $order->currency_snapshot ?? []);
return [
'order_number' => $order->order_number,
'order_name' => $orderName,
'amount' => $charge['minor_unit_amount'],
'currency' => $charge['currency'],
'customer_name' => $shippingAddress?->orderer_name,
'customer_email' => $shippingAddress?->orderer_email,
'customer_phone' => preg_replace('/[^0-9]/', '', $shippingAddress?->orderer_phone ?? ''),
'customer_key' => $order->user_id ? "user_{$order->user_id}" : null,
// 선택된 결제수단 ID (확장 수단이면 확장 ID 그대로 — 예: 'kginicis_lpay').
// PG 플러그인의 결제 진입 핸들러가 이 값으로 결제창의 결제수단을 결정한다
// (예: kginicis_lpay → gopaymethod=LPAY). 서버가 확장 ID 를 1급 시민으로
// 저장하게 되면서 프론트 인터셉터가 원본 수단을 따로 전달할 필요가 없어졌다(#475).
'payment_method' => $order->payment?->paymentMethodId(),
// 에스크로 결제(가상계좌·계좌이체) 시 필수인 상품 상세 배열. PG 가 사용 여부를
// 프론트에서 결정하므로 provider-agnostic 하게 항상 조립한다 (비에스크로는 무시).
'escrow_products' => $this->buildEscrowProducts($order, $locale),
];
}
/**
* 에스크로 결제용 상품 상세 배열을 구성합니다.
*
* 토스 SDK 의 escrowProducts 파라미터 형식 {id, name, code, unitPrice, quantity} 에 맞춘다.
* unitPrice 는 개당가(합계 아님)이며, name 은 현재 로케일로 로컬라이즈한다.
* 에스크로는 국내(KRW) 전용이므로 unitPrice 는 base(KRW) 정수를 그대로 쓴다.
*
* @param Order $order 주문 (options 로드됨)
* @param string $locale 현재 로케일
* @return array<int, array{id:string, name:string, code:string, unitPrice:int, quantity:int}>
*/
protected function buildEscrowProducts(Order $order, string $locale): array
{
$fallback = config('app.fallback_locale', 'ko');
return $order->options->map(function ($option) use ($locale, $fallback) {
$name = $option->product_name;
$localizedName = is_array($name)
? ($name[$locale] ?? $name[$fallback] ?? reset($name) ?: '')
: ($name ?? '');
return [
'id' => (string) $option->product_option_id,
'name' => $localizedName,
'code' => (string) $option->product_option_id,
'unitPrice' => (int) round((float) $option->unit_price),
'quantity' => (int) $option->quantity,
];
})->values()->all();
}
}