fix(core,board,ecommerce,plugins): API 문서 미채움 마커 전수 채움 + 미해석 lang 키 정정 + IDV 조회 시도횟수 비노출

이슈 본연: API 레퍼런스의 미채움 마커 5종(실측 제외/TODO/필드 없음/대표 에러 없음)
1,194건을 코드에서 읽어 전수 채우고(0건), api:docgen 재생성이 사람이 채운 내용을
손실·열화시키던 멱등성 결함 4종(CRLF, 표 파이프 이스케이프, 에러 서술 보존, 중복
라우트명 키)을 근본 수정. --check 를 실측 제외와 드리프트가 구분되도록 재정의.
ParameterDescriber/ResourceFieldDescriber 에 leaf 폴백·SEO 스코프·공통 사전 확장.

파생 결함(문서 실측 중 발견): ResponseHelper 기본 메시지 키 6종이 존재하지 않는
messages.* 를 가리켜 응답 message 가 번역문 대신 키 문자열로 노출되던 문제를 common.*
으로 정정하고, 코어 7 + gdpr/marketing/pay_kginicis/verification/ecommerce 확장의
호출부와 누락 lang 키(게시판 10키 + 이커머스 category_images 4키 포함)를 ko/en/ja
전수 정정. 중복 그룹 키 2건은 기존 키로 호출부 통합.

보안: GET /api/identity/challenges/{id} 는 권한 가드 없는 공개 폴링 엔드포인트인데
Service::getStatus 가 attempts/max_attempts 를 응답에 담아 남은 시도 횟수를 추론할 수
있었다. 두 필드를 제거하고, 챌린지 화면(admin_basic/basic)의 서버 attempts 참조를
제거(남은 횟수 UI 는 query fallback + verify 실패 시 로컬 증가로 유지).

부수 정리(변경셋에 들어온 board 컨트롤러의 audit 사전 결함): FormRequest 4개 신설로
base Request 주입 제거, CommentController 의 Board 직접 호출을 BoardService 위임으로
전환, PHPDoc @return 보강. board 스위트에서 발견한 stale test 3건(seed SSoT 불일치,
admin 계정 user 역할 누락)도 같은 세션에서 정정.

버전 정렬(공개 release 기준): 코어 7.0.3→7.0.4, sirsoft-board 1.0.1→1.0.2,
verification_kginicis 1.0.0→1.0.1, admin_basic 1.0.1→1.0.4.
This commit is contained in:
HeuJung committed 2026-08-06 14:16:40 +09:00
1 parent b07d6c8953
commit 6c8381f164
155 files changed
+17192 -1829

No files matched your search

@@ -39,17 +39,44 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 성공 시 `ResponseHelper` envelope 없이 이미지 바이너리를 그대로 스트리밍합니다 (`Symfony\Component\HttpFoundation\StreamedResponse`). 실패(404) 시에만 표준 JSON envelope 를 반환합니다._
성공 응답의 관측 가능한 표면은 본문(이미지 바이너리)과 다음 헤더입니다 (`ImageServeService::serve()` 가 `StorageInterface::response()` 에 전달).
| 헤더 | 타입 | 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| Content-Type | string | `image/jpeg` | 업로드 레코드의 `mime_type` 값. 허용 MIME: `image/jpeg`, `image/png`, `image/gif`, `image/webp` |
| Cache-Control | string | `public, max-age=31536000` | 공개 콘텐츠 이미지의 장기 캐싱 (1년) |
| Content-Disposition | string | `inline; filename="photo.jpg"` | 업로드 시 원본 파일명(`original_name`)을 파일명으로 사용 |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
성공 시 (HTTP 200) — JSON 이 아닌 이미지 바이너리 본문:
```http
HTTP/1.1 200 OK
Content-Type: image/jpeg
Cache-Control: public, max-age=31536000
Content-Disposition: inline; filename="photo.jpg"
<binary image data>
```
실패 시 (HTTP 404) — 표준 JSON envelope:
```json
{
"success": false,
"message": "이미지를 찾을 수 없습니다.",
"data": null
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 404 | Not Found | `hash` 가 라우트 제약(`[a-f0-9]{12}`)에 맞지 않아 라우트 미매칭 / `ImageServeService::findByHash()` 가 업로드 레코드를 찾지 못함 / 레코드는 있으나 스토리지에 실제 파일이 없어 `serve()` 가 null 반환 (`messages.image.not_found`) |
<!-- @generated:end -->
@@ -48,18 +48,34 @@ Content-Type: application/octet-stream
**응답 필드** (`data` 내부)
<!-- 실측 제외: side-effectful-write — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 표준 envelope(`success`/`message`/`data`)를 사용하지 않습니다. CKEditor5 SimpleUploadAdapter 규격상 성공 시 HTTP 201 + 최상위 `url` 키만 반환합니다 (`data` 래핑 없음)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| url | string | `/api/plugins/sirsoft-ckeditor5/images/a1b2c3d4e5f6` | 업로드된 이미지의 서빙 URL. 모델 접근자 `download_url` 이 생성하며 `/api/plugins/sirsoft-ckeditor5/images/{hash}` (hash = 12자리 hex) 형식이다. CKEditor 가 이 값을 그대로 `<img src>` 에 삽입한다. |
실패 시에는 최상위 `error.message` 만 반환합니다 (아래 에러 응답 표 참조).
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| error.message | string | `이미지 파일만 업로드할 수 있습니다.` | 검증/권한/서버 오류 사유 (다국어 처리된 단일 메시지). CKEditor 가 에디터 UI 의 알림 문구로 표시한다. |
**응답 예시**
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
```json
{
"url": "/api/plugins/sirsoft-ckeditor5/images/a1b2c3d4e5f6"
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 403 | Forbidden | query 파라미터 `permission` 이 지정됐고 현재 사용자가 해당 권한을 갖지 못한 경우 — `{"error":{"message":"이미지 업로드 권한이 없습니다."}}` |
| 422 | Unprocessable Entity | 검증 규칙 위반 (`upload` 누락 / 파일 아님 / 이미지 아님 / 허용 MIME 아님 / 용량 초과). 응답은 Laravel 기본 `errors` 형식이 아니라 CKEditor 규격 `{"error":{"message":"<첫 번째 오류 메시지>"}}` |
| 500 | Internal Server Error | 스토리지 저장 또는 업로드 기록 생성 중 예외 발생 — `{"error":{"message":"이미지 업로드에 실패했습니다."}}` |
<!-- @generated:end -->
@@ -57,6 +57,10 @@
## [1.0.0] - 2026-07-01
### Fixed
- 동의 저장·철회·정책 버전 발행 등을 처리한 뒤 표시되는 안내 문구가 번역된 문장 대신 내부 식별자가 그대로 보이던 문제를 수정했습니다. 이제 사용 중인 언어에 맞는 안내 문구가 표시됩니다.
### Changed
- 동의 기록 관리 / 마이페이지 개인정보 탭의 콘텐츠 카드 / 세로 정렬 컨테이너 외형을 sirsoft-admin_basic 표준 시맨틱 (.admin-card / .row-stack 등) 과 정합 — 다른 화면과 같은 결로 통일.
@@ -65,7 +65,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"data": [],
"pagination": {
@@ -53,15 +53,38 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| user_id | integer\|null | `6` | 동의를 저장한 회원의 식별자. 게스트 호출이면 `null` 입니다 |
| session_id | string\|null | `9f1c1c1e-4b2a-4d0e-9d3f-1a2b3c4d5e6f` | 게스트 호출 시 사용된 세션 식별자(`gdpr_session` 쿠키 값 또는 신규 발급 UUID). 회원 호출이면 `null` 입니다 |
| consents | object | `{"cookie_necessary":true,"cookie_analytics":false}` | 이번 요청으로 저장된 카테고리별 동의 값(요청 본문의 `consents` 를 그대로 반향). 키는 `cookie_` 접두사가 붙은 consent_key, 값은 boolean 입니다 |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "동의가 저장되었습니다.",
"data": {
"user_id": null,
"session_id": "9f1c1c1e-4b2a-4d0e-9d3f-1a2b3c4d5e6f",
"consents": {
"cookie_necessary": true,
"cookie_functional": true,
"cookie_analytics": false,
"cookie_marketing": false
}
}
}
```
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 422 | Validation Error | `consents` 누락/비배열/빈 배열, 값이 boolean 이 아님, `source` 가 `banner\|preference_center\|register\|mypage` 외의 값, 카탈로그에 없는 consent_key 전달(`유효하지 않은 동의 항목입니다.`), 필수 카테고리를 `false` 로 전달(`필수 항목은 철회할 수 없습니다.`) |
<!-- @generated:end -->
@@ -108,7 +131,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"has_consented": false,
"consents": [],
@@ -121,7 +144,7 @@ HTTP/1.1 200
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
_에러 응답 없음 — 공개 조회 엔드포인트입니다. 요청 파라미터가 없어 검증 실패(422)가 발생하지 않고, 컨트롤러/Service 가 도메인 예외를 던지지 않습니다. 미인증 호출도 게스트로 정상 처리되어 401 도 발생하지 않습니다._
<!-- @generated:end -->
@@ -149,17 +172,30 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| consent_key | string | `cookie_analytics` | 이번 호출로 동의(`true`)가 부여된 동의 항목 키. 요청 본문의 `consent_key` 를 그대로 반향하며, 프론트가 해당 행만 갱신하는 데 사용합니다 |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "동의가 갱신되었습니다.",
"data": {
"consent_key": "cookie_analytics"
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 422 | Validation Error | `consent_key` 누락/문자열 아님/50자 초과, 또는 쿠키 카테고리 카탈로그에 없는 키(`유효하지 않은 동의 항목입니다.`) |
<!-- @generated:end -->
@@ -202,7 +238,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"histories": []
}
@@ -259,7 +295,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"user_id": 6,
"needs_renewal": false,
@@ -424,17 +460,30 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| consent_key | string | `cookie_marketing` | 이번 호출로 철회(`false`)된 동의 항목 키. 요청 본문의 `consent_key` 를 그대로 반향하며, 프론트가 해당 행만 갱신하는 데 사용합니다 |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "동의가 철회되었습니다.",
"data": {
"consent_key": "cookie_marketing"
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 422 | Validation Error | `consent_key` 누락/문자열 아님/50자 초과, 카탈로그에 없는 키(`유효하지 않은 동의 항목입니다.`), 또는 필수(strictly necessary) 카테고리 철회 시도(`필수 항목은 철회할 수 없습니다.`) |
<!-- @generated:end -->
@@ -58,7 +58,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"data": [
{
@@ -201,7 +201,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"data": {
"id": 1,
@@ -268,7 +268,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"data": {
"id": 1,
@@ -53,7 +53,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"settings": {
"privacy_policy_slug": "privacy",
@@ -151,7 +151,7 @@ HTTP/1.1 200
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| privacy_policy_slug | body | string | 아니오 | max 100 | 개인정보 처리방침 페이지 slug(소문자·숫자·하이픈만). 비면 처리방침 링크가 비활성화되며 배너/마이페이지의 방침 링크 대상이 됩니다 |
| legal_entity_name | body | string | 아니오 | max 200 | legal entity 이름 (식별자) |
| legal_entity_name | body | string | 아니오 | max 200 | 법인명 / 운영 주체. 쿠키 배너와 마이페이지에 표시되는 사이트 운영 주체 이름입니다 (예: `(주)회사명`) |
| data_storage_location | body | string | 아니오 | max 200 | 데이터 저장 위치 표기(GDPR Art.13(1)(f)/PIPA 국가 단위 안내용). IP/CIDR·클라우드 리전 코드 등 보안 민감 식별자는 검증에서 차단됩니다 |
| banner_enabled | body | boolean | 아니오 | — | 쿠키 배너와 자동 차단 엔진의 통합 활성 토글. `true`일 때만 배너 노출과 도메인 기반 차단이 동작합니다 |
| banner_position | body | string | 아니오 | `bottom_bar`, `bottom_left_popup`, `bottom_right_popup`, `centered_modal` | 쿠키 배너 노출 위치(하단 바/좌하단 팝업/우하단 팝업/중앙 모달) |
@@ -182,11 +182,71 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| settings | object | `{"privacy_policy_slug":"privacy","banner_enabled":true,…}` | 저장 직후 다시 로드한 GDPR 플러그인 설정 전체 객체(GET 응답의 `settings` 와 동일 형태). 저장 결과를 관리자 폼에 되반영하는 데 사용합니다. `cookie_categories` 는 DB 에 JSON 문자열로 저장되지만 응답에서는 디코드되어 배열로 노출됩니다 |
| settings.privacy_policy_slug | string | `privacy` | 저장된 개인정보 처리방침 페이지 slug |
| settings.legal_entity_name | string | `(주)회사명` | 저장된 법인명 / 운영 주체 |
| settings.data_storage_location | string | `대한민국` | 저장된 데이터 저장 위치 표기(국가 단위) |
| settings.banner_enabled | boolean | `true` | 저장된 쿠키 배너·자동 차단 통합 활성 토글 |
| settings.banner_position | string | `bottom_bar` | 저장된 배너 노출 위치(`bottom_bar` / `bottom_left_popup` / `bottom_right_popup` / `centered_modal`) |
| settings.cookie_categories | array | `[{"key":"necessary","required":true,"label":{"ko":"필수 쿠키",…}}]` | 저장된 쿠키 카테고리 카탈로그 배열(디코드된 상태). 각 항목은 `key`, `required`, 다국어 `label`/`description` |
| settings.blocked_domains | object | `{"functional":["*.crisp.chat"],"analytics":[],"marketing":[]}` | 저장된 카테고리별 차단 도메인 패턴(키→도메인 배열, necessary 제외) |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "설정이 저장되었습니다.",
"data": {
"settings": {
"privacy_policy_slug": "privacy",
"legal_entity_name": "(주)회사명",
"data_storage_location": "대한민국",
"banner_enabled": true,
"banner_position": "bottom_bar",
"cookie_categories": [
{
"key": "necessary",
"required": true,
"label": {
"ko": "필수 쿠키",
"en": "Strictly Necessary"
},
"description": {
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation. Cannot be disabled."
}
},
{
"key": "analytics",
"required": false,
"label": {
"ko": "분석 쿠키",
"en": "Analytics"
},
"description": {
"ko": "방문자가 사이트를 어떻게 이용하는지 익명으로 측정합니다.",
"en": "Used to anonymously measure how visitors use the site."
}
}
],
"blocked_domains": {
"analytics": [
"google-analytics.com"
]
}
}
}
}
```
**참고** 위 표에 정의되지 않은 요청 필드는 검증 대상에서 제외되며(`FormRequest::validated()` 결과만 사용), 저장 로직에 전달되지 않습니다.
@@ -247,7 +307,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"cookie_policy_version": "1",
"privacy_policy_slug": "privacy",
@@ -378,7 +438,7 @@ HTTP/1.1 200
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
_에러 없음 (공개 조회 — 요청 파라미터가 없고 컨트롤러/서비스가 도메인 예외를 던지지 않습니다. 설정 미저장 시에도 기본값으로 200 응답합니다)._
<!-- @generated:end -->
@@ -50,7 +50,7 @@ class GdprAdminConsentLogController extends AdminBaseController
{
$paginator = $this->consentLogService->paginateForAdmin($request->filters(), $request->perPage());
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'data' => GdprConsentLogResource::collection($paginator->items()),
'pagination' => [
'current_page' => $paginator->currentPage(),
@@ -51,7 +51,7 @@ class GdprAdminPolicyVersionController extends AdminBaseController
{
$paginator = $this->service->paginate($request->perPage());
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'data' => GdprPolicyVersionResource::collection($paginator->items()),
'pagination' => [
'current_page' => $paginator->currentPage(),
@@ -101,7 +101,7 @@ class GdprAdminPolicyVersionController extends AdminBaseController
{
$current = $this->service->getCurrent(loadCreatedBy: true);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'data' => $current !== null ? new GdprPolicyVersionResource($current) : null,
]);
}
@@ -127,7 +127,7 @@ class GdprAdminPolicyVersionController extends AdminBaseController
return ResponseHelper::error('sirsoft-gdpr::messages.settings.policy_version.not_found', 404);
}
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'data' => new GdprPolicyVersionDetailResource($policyVersion),
]);
}
@@ -51,7 +51,7 @@ class GdprAdminSettingsController extends AdminBaseController
$settings = $this->normalizeJsonFields(is_array($settings) ? $settings : []);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'settings' => $settings,
]);
}
@@ -112,7 +112,7 @@ class GdprCookieConsentController extends PublicBaseController
$needsRenewal = ! $hasConsented
&& $this->consentService->hasAnyConsentHistory($userId, $sessionId);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'has_consented' => $hasConsented,
'consents' => $consents,
'needs_renewal' => $needsRenewal,
@@ -72,7 +72,7 @@ class GdprSettingsController extends PublicBaseController
'default_blocked_domains_preview' => Plugin::DEFAULT_BLOCKED_DOMAINS_CATALOG,
];
return ResponseHelper::success('messages.success', $data);
return ResponseHelper::success('common.success', $data);
}
/**
@@ -49,7 +49,7 @@ class GdprConsentController extends AuthBaseController
// 상태별로 액션(철회/다시 동의/동의/필수 안내) 을 분기.
$consents = $this->consentService->getMyConsentMatrix($user->id);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'user_id' => $user->id,
'needs_renewal' => $this->consentService->needsRenewal($user->id),
'current_policy_version' => $this->consentService->getCurrentPolicyVersion(),
@@ -71,7 +71,7 @@ class GdprConsentController extends AuthBaseController
$histories = $this->consentService->getHistories($user->id);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'histories' => GdprUserConsentHistoryResource::collection($histories),
]);
}
@@ -12,6 +12,10 @@
## [1.0.0] - 2026-07-01
### Fixed
- 마케팅 설정을 저장한 뒤 표시되는 안내 문구가 번역된 문장 대신 내부 식별자가 그대로 보이던 문제를 수정했습니다. 이제 사용 중인 언어에 맞는 안내 문구가 표시됩니다.
### Added
- 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
@@ -38,17 +38,49 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드. `data.channels` 는 저장 후 확정된 채널 배열(시스템 채널 병합 결과)이며, 요청 body 의 `channels` 와 동일한 구조를 그대로 되돌려준다._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| channels | array | `[{...}]` | 저장된 최종 채널 목록 (누락된 시스템 채널이 앞에 병합됨) |
| channels[].key | string | `email_subscription` | 채널 식별자 (소문자·숫자·밑줄) |
| channels[].label | object | `{"ko":"광고성 이메일 수신","en":"Email Marketing"}` | 로케일별 채널 라벨 |
| channels[].label_key | string | `sirsoft-marketing::channels.email_subscription.label` | 시스템 채널이 병합될 때만 포함되는 언어팩 라벨 키 |
| channels[].page_slug | string \| null | `""` | 연결 약관 페이지 slug (없으면 빈 문자열/`null`) |
| channels[].enabled | boolean | `true` | 채널 노출 여부 |
| channels[].is_system | boolean | `true` | 시스템 채널 여부 (삭제·플래그 변경 불가) |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "채널이 저장되었습니다.",
"data": {
"channels": [
{
"key": "email_subscription",
"label_key": "sirsoft-marketing::channels.email_subscription.label",
"label": {
"ko": "광고성 이메일 수신",
"en": "Email Marketing"
},
"page_slug": "",
"enabled": true,
"is_system": true
}
]
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 관리자 권한이 없는 사용자가 호출한 경우 (`AdminBaseController`) |
| 422 | 동의 이력 존재 | 삭제 대상 채널에 동의한 회원이 있는 경우 — "채널(:key)에 동의한 회원이 :count명 있어 삭제할 수 없습니다." |
<!-- @generated:end -->
@@ -79,7 +111,7 @@ Authorization: Bearer {YOUR_TOKEN}
"success": true,
"data": {
"channels": [
{ "key": "email_subscription", "label": { "ko": "광고성 이메일 수신", "en": "Marketing email" }, "page_slug": null, "enabled": true, "is_system": true }
{ "key": "email_subscription", "label": { "ko": "광고성 이메일 수신", "en": "Email Marketing" }, "page_slug": "", "enabled": true, "is_system": true }
]
},
"message": "채널이 저장되었습니다.",
@@ -63,7 +63,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"marketing_consent_enabled": true,
"marketing_consent_terms_slug": "marketing-terms",
@@ -93,7 +93,7 @@ HTTP/1.1 200
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
_에러 없음 (공개 조회 — 인증·검증·도메인 예외 경로가 없다. 요청 파라미터가 없어 422 가 발생하지 않고, `PublicBaseController` 라 401/403 도 없으며, 설정 미존재 시에도 기본값으로 200 을 반환한다)._
<!-- @generated:end -->
@@ -84,6 +84,6 @@ class MarketingSettingsController extends PublicBaseController
];
}, $channels);
return ResponseHelper::success('messages.success', $data);
return ResponseHelper::success('common.success', $data);
}
}
@@ -22,7 +22,7 @@
### Fixed
- 일본 편의점결제(CVS) 관련 안내 메시지가 번역되지 않고 내부 코드 그대로 표시되던 문제를 수정했습니다. 주문을 찾을 수 없거나 입금 통보 테스트·입금기한 만료 처리를 할 때 정상적인 한국어(영어) 안내가 표시됩니다.
- 안내 메시지가 번역되지 않고 내부 코드 그대로 표시되던 문제를 수정했습니다. 일본 편의점결제(CVS) 입금 통보 테스트·입금기한 만료 처리뿐 아니라 에스크로 배송 등록·구매확인 거부, 거래 조회, 정산 마감 등 이 플러그인의 모든 처리 결과에서 사용 중인 언어에 맞는 안내가 표시됩니다.
- 일본(CBT) 설정을 운영 모드로 전환할 때 상점 정보나 서명키가 비어 있어도 저장되던 문제를 수정했습니다. 필수값이 비었거나 샘플값이 그대로면 저장이 중단되고 안내 메시지가 표시됩니다.
- 이 플러그인이 설치된 사이트에서 관리자 주문 목록의 '일부취소' 표시와 적립예정 포인트·마일리지 사용 금액, 주문상품의 추가옵션 요약이 보이지 않던 문제를 수정했습니다. 결제 정보 칸에 테스트결제 표시를 덧붙이는 과정에서 목록 표 구성 전체를 옛 버전으로 되돌려 놓고 있었습니다.
- 관리자 결제수단 설정에서 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제를 수정했습니다. 해당 결제수단의 PG사 목록이 항상 비어 있어 기본 PG사만 사용할 수 있었고, 결제수단별로 다른 PG사를 지정하는 설정이 화면에서 불가능했습니다.
@@ -136,7 +136,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"product_id": 4,
"product_code": "CBT-TEST-20260708063238",
@@ -55,7 +55,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"APIDOC-KGINICIS-000001": true
}
@@ -140,7 +140,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"is_cbt_cvs": true,
"order_number": "APIDOC-KGINICIS-000001",
@@ -211,11 +211,81 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (만료 처리 후 갱신된 CBT 편의점 운영 요약 — `GET .../cbt-cvs` 의 `data` 와 동일 구조)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| is_cbt_cvs | boolean | `true` | CBT 편의점(CVS) 결제 여부 |
| order_number | string | `APIDOC-KGINICIS-000001` | 주문번호 |
| order_status | string | `pending_payment` | 주문상태 (OrderStatusEnum) |
| payment_status | string | `expired` | 결제 상태값(PaymentStatusEnum). 만료 처리 성공 시 `expired` 로 전이한다. |
| tid | string | `INIJPGCARDapidocsmpl0000000001` | KG 이니시스 거래 ID(transaction_id) |
| amount | integer | `5000` | 청구 금액 (`payment_meta.cvs_amount` 우선, 없으면 결제 승인액 또는 주문 총 청구액) |
| currency | string | `JPY` | 결제 통화 코드 |
| cbt_mid | string | `apidocmid1` | CBT 일본 가맹점 ID |
| cbt_sid | string | `apidocsid1` | CBT 결제 세션/상점 식별자(SID) |
| is_test_mode | boolean | `true` | 테스트 모드 결제 여부 |
| convenience | string | `seven_eleven` | 구매자가 선택한 일본 편의점 식별값 |
| conf_no | string | `1234567890` | 편의점 입금용 확인번호 |
| receipt_no | string | `0987654321` | 편의점 입금용 접수번호 |
| payment_term | string | `20260710235959` | 편의점 입금 마감 일시(YmdHis 압축 문자열) |
| payment_term_formatted | string | `2026-07-10 23:59:59` | `payment_term` 의 표시용 포맷 문자열 |
| is_expired_by_time | boolean | `false` | 입금 마감 경과 여부. 만료 처리 후에는 `payment_status` 가 `expired` 이므로 `false` 로 계산된다. |
| cvs_status | string | `expired` | CVS 입금 상태. 만료 처리 시 `expired` 로 기록된다. |
| last_notify_at | string | `` | 마지막 입금 통보(NOTI) 수신 일시 |
| last_notify_result | string | `` | 마지막 통보 처리 결과 (`confirmed`/`ignored`/`failed`) |
| last_notify_reason | string | `` | 마지막 통보 처리 사유 |
| notify_history | array | `[]` | 최근 입금 통보 이력(최대 10건) |
| notify_url | string | `https://api.example.com/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify` | 입금 통보(NOTI) 수신 URL |
| can_simulate_notify | boolean | `false` | 통보 시뮬레이션 가능 여부 (테스트 모드 + 입금대기). 만료 후에는 `false`. |
| can_mark_expired | boolean | `false` | 만료 처리 가능 여부. 이미 만료됐으므로 `false`. |
| last_recheck_at | string | `` | 마지막 로컬 상태 재확인 일시 |
| last_recheck_result | string | `` | 마지막 로컬 상태 재확인 결과 (`local_status_checked`) |
| expired_at | string | `2026-07-08T06:40:12+00:00` | 만료 처리 시각(`payment_meta.cvs_expired_at`, ISO8601) |
| expiry_reason | string | `payment_term_elapsed` | 만료 처리 사유. 입금 기한 경과 만료는 `payment_term_elapsed`. |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "sirsoft-pay_kginicis::messages.cbt_cvs.expire_success",
"data": {
"is_cbt_cvs": true,
"order_number": "APIDOC-KGINICIS-000001",
"order_status": "pending_payment",
"payment_status": "expired",
"tid": "INIJPGCARDapidocsmpl0000000001",
"amount": 5000,
"currency": "JPY",
"cbt_mid": "apidocmid1",
"cbt_sid": "apidocsid1",
"is_test_mode": true,
"convenience": "seven_eleven",
"conf_no": "1234567890",
"receipt_no": "0987654321",
"payment_term": "20260710235959",
"payment_term_formatted": "2026-07-10 23:59:59",
"is_expired_by_time": false,
"cvs_status": "expired",
"last_notify_at": "",
"last_notify_result": "",
"last_notify_reason": "",
"notify_history": [],
"notify_url": "https://api.example.com/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify",
"can_simulate_notify": false,
"can_mark_expired": false,
"last_recheck_at": "",
"last_recheck_result": "",
"expired_at": "2026-07-08T06:40:12+00:00",
"expiry_reason": "payment_term_elapsed"
}
}
```
**에러 응답**
@@ -224,6 +294,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable | CBT 편의점 결제가 아닌 경우(`messages.cbt_cvs.not_cvs` — "CBT 편의점결제 주문이 아닙니다.") 또는 만료 조건 미충족(`messages.cbt_cvs.not_expirable` — "입금 마감일시가 지난 입금대기 CBT 편의점결제 주문만 만료 처리할 수 있습니다.") |
<!-- @generated:end -->
@@ -367,11 +438,96 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (시뮬레이션 통보 처리 후 갱신된 CBT 편의점 운영 요약 — `GET .../cbt-cvs` 의 `data` 와 동일 구조)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| is_cbt_cvs | boolean | `true` | CBT 편의점(CVS) 결제 여부 |
| order_number | string | `APIDOC-KGINICIS-000001` | 주문번호 |
| order_status | string | `processing` | 주문상태 (OrderStatusEnum). 결제 확정 시 주문 처리 흐름에 따라 갱신된다. |
| payment_status | string | `paid` | 결제 상태값(PaymentStatusEnum). 시뮬레이션 통보가 정상 처리되면 `paid`(입금완료)로 전이한다. |
| tid | string | `INIJPGCARDapidocsmpl0000000001` | KG 이니시스 거래 ID(transaction_id) |
| amount | integer | `5000` | 청구 금액 (`payment_meta.cvs_amount` 우선, 없으면 결제 통화 환산 청구액) |
| currency | string | `JPY` | 결제 통화 코드. 합성 통보는 항상 `currencyCd=JPY` 로 전송된다. |
| cbt_mid | string | `apidocmid1` | CBT 일본 가맹점 ID |
| cbt_sid | string | `apidocsid1` | CBT 결제 세션/상점 식별자(SID) |
| is_test_mode | boolean | `true` | 테스트 모드 결제 여부. 이 엔드포인트는 `true` 인 경우에만 실행된다. |
| convenience | string | `seven_eleven` | 구매자가 선택한 일본 편의점 식별값 |
| conf_no | string | `1234567890` | 편의점 입금용 확인번호 |
| receipt_no | string | `0987654321` | 편의점 입금용 접수번호 |
| payment_term | string | `20260710235959` | 편의점 입금 마감 일시(YmdHis 압축 문자열) |
| payment_term_formatted | string | `2026-07-10 23:59:59` | `payment_term` 의 표시용 포맷 문자열 |
| is_expired_by_time | boolean | `false` | 입금 마감 경과 여부. 입금완료(`paid`) 후에는 `false`. |
| cvs_status | string | `paid` | CVS 입금 상태. 입금 확정 시 `paid` 로 기록된다. |
| last_notify_at | string | `2026-07-08T06:35:02+00:00` | 마지막 입금 통보 수신 일시(ISO8601). 시뮬레이션 시각이 기록된다. |
| last_notify_result | string | `confirmed` | 마지막 통보 처리 결과 (`confirmed`/`ignored`/`failed`) |
| last_notify_reason | string | `deposit_confirmed` | 마지막 통보 처리 사유. 입금 확정은 `deposit_confirmed`. |
| notify_history | array | `[{"received_at":"2026-07-08T06:35:02+00:00","source":"admin_simulation","remote_ip":"127.0.0.1","result":"confirmed","reason":"deposit_confirmed","tid":"INIJPGCARDapidocsmpl0000000001","order_id":"APIDOC-KGINICIS-000001","mid":"apidocmid1","status":"00","amount":5000,"currency":"JPY","sid":"apidocsid1"}]` | 최근 입금 통보 이력(최대 10건). 관리자 시뮬레이션은 `source=admin_simulation` 으로 기록된다. |
| notify_url | string | `https://api.example.com/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify` | 입금 통보(NOTI) 수신 URL |
| can_simulate_notify | boolean | `false` | 통보 시뮬레이션 가능 여부(테스트 모드 + 입금대기). 입금완료 후에는 `false`. |
| can_mark_expired | boolean | `false` | 만료 처리 가능 여부. 입금완료 후에는 `false`. |
| last_recheck_at | string | `` | 마지막 로컬 상태 재확인 일시 |
| last_recheck_result | string | `` | 마지막 로컬 상태 재확인 결과 |
| expired_at | string | `` | 만료 처리 시각 |
| expiry_reason | string | `` | 만료 처리 사유 |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "sirsoft-pay_kginicis::messages.cbt_cvs.simulate_success",
"data": {
"is_cbt_cvs": true,
"order_number": "APIDOC-KGINICIS-000001",
"order_status": "processing",
"payment_status": "paid",
"tid": "INIJPGCARDapidocsmpl0000000001",
"amount": 5000,
"currency": "JPY",
"cbt_mid": "apidocmid1",
"cbt_sid": "apidocsid1",
"is_test_mode": true,
"convenience": "seven_eleven",
"conf_no": "1234567890",
"receipt_no": "0987654321",
"payment_term": "20260710235959",
"payment_term_formatted": "2026-07-10 23:59:59",
"is_expired_by_time": false,
"cvs_status": "paid",
"last_notify_at": "2026-07-08T06:35:02+00:00",
"last_notify_result": "confirmed",
"last_notify_reason": "deposit_confirmed",
"notify_history": [
{
"received_at": "2026-07-08T06:35:02+00:00",
"source": "admin_simulation",
"remote_ip": "127.0.0.1",
"result": "confirmed",
"reason": "deposit_confirmed",
"tid": "INIJPGCARDapidocsmpl0000000001",
"order_id": "APIDOC-KGINICIS-000001",
"mid": "apidocmid1",
"status": "00",
"amount": 5000,
"currency": "JPY",
"sid": "apidocsid1"
}
],
"notify_url": "https://api.example.com/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify",
"can_simulate_notify": false,
"can_mark_expired": false,
"last_recheck_at": "",
"last_recheck_result": "",
"expired_at": "",
"expiry_reason": ""
}
}
```
**에러 응답**
@@ -380,6 +536,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable | CBT 편의점 결제 아님(`messages.cbt_cvs.not_cvs`) / 테스트 모드 아님(`messages.cbt_cvs.not_test_mode` — "테스트 모드의 CBT 편의점결제 주문에서만 입금 통보 테스트를 실행할 수 있습니다.") / 입금대기 상태 아님(`messages.cbt_cvs.not_waiting_deposit`) / 합성 통보 처리 실패(`messages.cbt_cvs.simulate_failed`) |
<!-- @generated:end -->
@@ -410,7 +567,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드. 주문에 CBT 조정 레코드가 없으면 `data` 는 `null` 이다._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| status | string | `manual_refund_required` | 조정 상태. `auto_refunded`(자동환불 완료) · `manual_refund_required`(수동 환불 필요) · `refund_retrying`(재시도 선점 중) 중 하나. |
| manual_action_required | boolean | `true` | 수동 환불 조치 필요 여부. `status === manual_refund_required` 에서 파생된다. |
| can_retry | boolean | `true` | 관리자 환불 재시도 가능 여부. `manual_action_required` 이면서 `tid` 가 비어있지 않을 때 `true`. |
| tid | string | `INIJPGCARDapidocsmpl0000000001` | 환불 대상 CBT 결제의 KG 이니시스 거래 ID. |
| amount | integer | `5000` | 환불 대상 금액. |
| reason | string | `주문 취소 자동환불` | 자동환불 시도 사유(환불 요청 시 전달된 사유 문자열). |
| refund_error | string \| null | `CBT refund API timeout` | 마지막 환불 시도에서 발생한 오류 메시지. 성공 시 `null`. |
| refund_result | object \| null | `{"resultCode":"00","resultMsg":"정상처리","tid":"INIJPGCARDapidocsmpl0000000001"}` | 환불 성공 시 저장된 PG 응답(정제 필드: `resultCode`·`resultMsg`·`tid`·`cancelDate`·`cancelTime`·`prtcCode`). |
| refund_result_code | string | `00` | `refund_result.resultCode`(또는 `code`)에서 파생된 결과 코드. 값이 없으면 빈 문자열. |
| refund_result_msg | string | `정상처리` | `refund_result.resultMsg`(또는 `message`)에서 파생된 결과 메시지. 값이 없으면 빈 문자열. |
| retry_count | integer | `1` | 관리자 재시도 선점 횟수(재시도 요청마다 1씩 증가). |
| last_retry_at | string \| null | `2026-07-08T06:32:39+00:00` | 마지막 재시도 시각(ISO8601). |
| last_retry_error | string \| null | `null` | 마지막 재시도에서 발생한 오류 메시지. 성공 시 `null`. |
| is_test_mode | boolean | `true` | 원 결제가 테스트 모드였는지 여부. 재시도 시 사용할 자격증명 모드를 결정한다. |
| cbt_mid | string | `apidocmid1` | 원 결제에 사용된 CBT 일본 가맹점 ID. 재시도 시 동일 MID 자격증명을 사용한다. |
| created_at | string | `2026-07-08T06:30:01+00:00` | 조정 레코드 최초 생성 시각(ISO8601). |
| updated_at | string | `2026-07-08T06:32:39+00:00` | 조정 레코드 마지막 갱신 시각(ISO8601). |
**응답 예시**
@@ -421,7 +598,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": null
}
```
@@ -463,11 +640,65 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (재시도 성공 후 갱신된 CBT 조정 레코드 — `GET .../cbt-reconciliation` 의 `data` 와 동일 구조)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| status | string | `auto_refunded` | 조정 상태. 재시도 성공 시 `auto_refunded` 로 갱신된다. |
| manual_action_required | boolean | `false` | 수동 환불 조치 필요 여부. 성공 후에는 `false`. |
| can_retry | boolean | `false` | 재시도 가능 여부. 성공 후에는 `false`. |
| tid | string | `INIJPGCARDapidocsmpl0000000001` | 환불한 CBT 결제의 KG 이니시스 거래 ID. |
| amount | integer | `5000` | 환불 금액. |
| reason | string | `주문 취소 자동환불` | 최초 자동환불 시도 사유(레코드에 보존된 값). |
| refund_error | null | `null` | 재시도 성공 시 `null` 로 초기화된다. |
| refund_result | object | `{"resultCode":"00","resultMsg":"정상처리","tid":"INIJPGCARDapidocsmpl0000000001","cancelDate":"20260708","cancelTime":"063239"}` | 환불 API 응답의 정제 결과(`resultCode`·`resultMsg`·`tid`·`cancelDate`·`cancelTime`·`prtcCode` 만 저장). |
| refund_result_code | string | `00` | `refund_result.resultCode` 에서 파생된 결과 코드. |
| refund_result_msg | string | `정상처리` | `refund_result.resultMsg` 에서 파생된 결과 메시지. |
| retry_count | integer | `1` | 재시도 선점 시 1 증가한 누적 재시도 횟수. |
| last_retry_at | string | `2026-07-08T06:32:39+00:00` | 이번 재시도 시각(ISO8601). |
| last_retry_error | null | `null` | 재시도 성공 시 `null`. |
| is_test_mode | boolean | `true` | 원 결제의 테스트 모드 여부(재시도에 사용한 자격증명 모드). |
| cbt_mid | string | `apidocmid1` | 재시도에 사용한 CBT 일본 가맹점 ID. |
| created_at | string | `2026-07-08T06:30:01+00:00` | 조정 레코드 최초 생성 시각. |
| updated_at | string | `2026-07-08T06:32:39+00:00` | 조정 레코드 갱신 시각. |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "sirsoft-pay_kginicis::messages.cbt_reconciliation.retry_success",
"data": {
"status": "auto_refunded",
"manual_action_required": false,
"can_retry": false,
"tid": "INIJPGCARDapidocsmpl0000000001",
"amount": 5000,
"reason": "주문 취소 자동환불",
"refund_error": null,
"refund_result": {
"resultCode": "00",
"resultMsg": "정상처리",
"tid": "INIJPGCARDapidocsmpl0000000001",
"cancelDate": "20260708",
"cancelTime": "063239"
},
"refund_result_code": "00",
"refund_result_msg": "정상처리",
"retry_count": 1,
"last_retry_at": "2026-07-08T06:32:39+00:00",
"last_retry_error": null,
"is_test_mode": true,
"cbt_mid": "apidocmid1",
"created_at": "2026-07-08T06:30:01+00:00",
"updated_at": "2026-07-08T06:32:39+00:00"
}
}
```
**에러 응답**
@@ -476,6 +707,8 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable | 재시도 선점 실패 — 조정 레코드가 없거나 `can_retry` 가 아닌 경우 (`messages.cbt_reconciliation.not_retryable` — "재시도 가능한 CBT 환불 대기 건이 아닙니다.") |
| 502 | Bad Gateway | 환불 API 호출이 예외로 실패한 경우 (`messages.cbt_reconciliation.retry_failed` — "CBT 환불 재시도에 실패했습니다."). 레코드는 `manual_refund_required` 로 되돌아가고 오류가 기록된다. |
<!-- @generated:end -->
@@ -528,7 +761,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"has_escrow_payment": true,
"tid": "INIJPGCARDapidocsmpl0000000001",
@@ -599,11 +832,33 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| result_code | string | `00` | KG 이니시스 배송등록 응답 코드. `00` 만 성공으로 처리된다(그 외는 502). |
| result_msg | string | `정상처리` | KG 이니시스 응답 메시지. 응답에 메시지가 없으면 `OK`. |
| invoice | string | `1234567890123` | 등록된 운송장번호(요청값 그대로). |
| ex_name | string | `CJ대한통운` | 등록된 택배사명(요청 `ex_code` 를 공식 코드표로 변환한 이름). |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "성공적으로 처리되었습니다.",
"data": {
"result_code": "00",
"result_msg": "정상처리",
"invoice": "1234567890123",
"ex_name": "CJ대한통운"
}
}
```
**에러 응답**
@@ -611,7 +866,10 @@ Authorization: Bearer {YOUR_TOKEN}
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 404 | Not Found | 해당 주문에 KG 이니시스 에스크로 결제(`is_escrow = true` + TID 존재)가 없는 경우 |
| 422 | Unprocessable | `invoice` 미입력 또는 `ex_code` 가 공식 택배사 코드표에 없는 경우 (errors: `invoice` / `ex_code`) |
| 500 | Server Error | 배송등록 호출 중 예외 발생 (errors.message = 예외 메시지) |
| 502 | Bad Gateway | KG 이니시스 배송등록 응답의 `resultCode` 가 `00` 이 아닌 경우 (errors.message = PG `resultMsg`) |
<!-- @generated:end -->
@@ -642,11 +900,29 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| result_code | string | `00` | KG 이니시스 구매거절확인(INIAPI `type=Dncf`) 응답 코드. `00` 만 성공으로 처리된다(그 외는 502). |
| result_msg | string | `정상처리` | KG 이니시스 응답 메시지. 응답에 메시지가 없으면 `OK`. |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "성공적으로 처리되었습니다.",
"data": {
"result_code": "00",
"result_msg": "정상처리"
}
}
```
**에러 응답**
@@ -654,7 +930,10 @@ Authorization: Bearer {YOUR_TOKEN}
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 404 | Not Found | 해당 주문에 KG 이니시스 에스크로 결제(`is_escrow = true` + TID 존재)가 없는 경우 |
| 422 | Unprocessable | 이미 구매거절확인 이력(`payment_meta.escrow_deny_confirm`)이 있는 경우 (errors.message = "이미 구매거절확인이 완료되었습니다.") |
| 500 | Server Error | 구매거절확인 호출 중 예외 발생 (errors.message = 예외 메시지) |
| 502 | Bad Gateway | KG 이니시스 구매거절확인 응답의 `resultCode` 가 `00` 이 아닌 경우 (errors.message = PG `resultMsg`) |
<!-- @generated:end -->
@@ -745,7 +1024,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": {
"resultCode": "0000",
"resultMsg": "정상처리",
@@ -844,7 +1123,25 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 `ResponseHelper` 봉투를 쓰지 않고 **응답 본문 최상위(root)에 필드를 직접** 반환한다 (`data` 래핑 없음). 결제 종류에 따라 두 형태로 나뉜다: CBT(일본결제)면 `receipt_type=cbt_confirmation`, 일반 국내결제면 `receipt_type=inicis_receipt`._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| receipt_type | string | `cbt_confirmation` | 영수증 종류. CBT 결제는 `cbt_confirmation`(결제확인서), 일반 결제는 `inicis_receipt`(KG 이니시스 매출전표). |
| receipt_url | string \| null | `https://iniweb.inicis.com/DefaultWebApp/mall/cr/cm/mCmReceipt_head.jsp?noTid={TID}&noMethod=1` | KG 이니시스 매출전표 조회 URL. CBT 결제는 매출전표가 없어 `null`. |
| receipt_label | string | `결제확인` | 영수증 버튼 라벨. 일반 결제는 `영수증`, CBT 는 `결제확인`(CVS 입금대기 시 `입금정보`). |
| receipt_view_label | string | `결제확인서 보기` | 영수증 열람 링크 라벨. 일반 결제는 `영수증 조회`, CBT 는 `결제확인서 보기`(CVS 입금대기 시 `편의점 입금정보 보기`). |
| receipt_title | string | `KG 이니시스 CBT 결제확인서` | CBT 전용 — 결제확인서 제목. CVS 입금대기 시 `KG 이니시스 CBT 편의점 입금정보`. 일반 결제 응답에는 없음. |
| receipt_notice | string | `일본 CBT 결제는 한국 KG 이니시스 매출전표 조회와 별도로 결제 승인 정보를 표시합니다.` | CBT 전용 — 안내 문구. CVS 입금대기 시 편의점 입금 마감 안내 문구. 일반 결제 응답에는 없음. |
| receipt_fields | array | `[{"label":"주문번호","value":"APIDOC-KGINICIS-000001"}]` | CBT 전용 — 결제확인서 표시 항목 목록(`{label, value}`). 값이 빈 항목은 제외된다. 공통 항목: 주문번호·결제수단·거래번호·결제금액(CVS 입금대기 시 `입금예정금액`)·입금 상태·승인일시. 결제수단별 추가: CARD → 카드 승인번호·할부개월, PAYPAY → 승인번호, CVS → 편의점 코드·편의점 확인번호·편의점 접수번호·입금 마감일시. 일반 결제 응답에는 없음. |
| is_test_mode | boolean | `true` | 플러그인 설정(`is_test_mode`) 기준 테스트 모드 여부. |
| payment_method_label | string | `일본 편의점결제` | 결제수단 라벨. CBT 는 `신용카드 (일본 CBT)`/`일본 편의점결제`/`PayPay`/`LINE Pay`, 일반 결제는 `신용카드`·`가상계좌`·`계좌이체`·`무통장입금`·`휴대폰결제` 등. |
| payment_method_display_label | string | `일본 편의점결제` | 화면 표시용 결제수단 라벨. 간편결제면 `{간편결제사} ({기본 결제수단})` 형태로 조합된다. |
| cbt_pay_method | string | `CVS` | CBT 전용 — CBT 결제수단 코드(`CARD`/`CVS`/`PAYPAY`/`LINEPAY`). 일반 결제 응답에는 없음. |
| payment_status | string | `paid` | CBT 전용 — 결제 상태값(PaymentStatusEnum: `waiting_deposit`·`paid`·`cancelled`·`failed` 등). 일반 결제 응답에는 없음. |
| selected_payment_method | string \| null | `null` | 결제 시 사용자가 선택한 결제수단 식별값(`payment_meta.selected_payment_method`). 없으면 `null`. |
| embedded_pg_provider | string \| null | `null` | 간편결제 등 결제창에 내장된 실제 PG 제공사 식별값(예: `payco`, `tosspay`). 일반 결제면 `null`. |
| embedded_pg_provider_label | string \| null | `null` | `embedded_pg_provider` 의 사람이 읽는 라벨(예: `페이코`, `토스페이`). 없으면 `null`. |
**응답 예시**
@@ -37,11 +37,24 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| checkout_token | string | `eyJvaWQiOiJPUkQyMDI2MDcxNDAwMSIsInByaWNlIjoxMDAwMH0.5f1c…` | 후속 hash-data 요청에 그대로 실어 보내는 단기 체크아웃 토큰. 주문번호·금액·구매자(이메일/전화)·요청 IP·User-Agent 를 HMAC-SHA256 으로 봉인한 서명값이다. |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"data": {
"checkout_token": "eyJvaWQiOiJPUkQyMDI2MDcxNDAwMSIsInByaWNlIjoxMDAwMH0.5f1c…"
}
}
```
_이 엔드포인트는 `ResponseHelper` 봉투를 쓰지 않고 `response()->json()` 으로 `success`·`data` 만 반환합니다 (`message` 없음)._
**에러 응답**
@@ -79,11 +92,24 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| hash_data | string | `a1b2c3d4e5f6…` | 일본 CBT 결제창이 KG 이니시스에 전송할 위변조 방지 해시(P_HASHDATA). 일본 가맹점 MID·타임스탬프·금액·주문번호로 생성된다. |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"data": {
"hash_data": "a1b2c3d4e5f6…"
}
}
```
_이 엔드포인트는 `ResponseHelper` 봉투를 쓰지 않고 `response()->json()` 으로 `success`·`data` 만 반환합니다 (`message` 없음)._
**에러 응답**
@@ -138,17 +164,33 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| status | string | `recorded` \| `ignored` | 닫힘 보고 처리 결과. `recorded` = 주문을 `USER_CANCEL` 로 실패 처리하고 취소 이력을 남김. `ignored` = 결제 성공 콜백과의 경쟁 등으로 처리하지 않고 무시. |
| reason | string | `order_not_payable` \| `payment_already_paid` | `status: ignored` 일 때만 포함되는 무시 사유. 주문이 이미 결제 가능 상태가 아니거나(`order_not_payable`), 결제가 이미 완료됨(`payment_already_paid`). |
**응답 예시**
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "성공적으로 처리되었습니다.",
"data": {
"status": "recorded"
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 403 | Forbidden | 요청의 구매자 정보(`buyer_email` / `buyer_phone`)가 주문의 구매자와 일치하지 않는 경우 |
| 404 | Not Found | `oid` 에 해당하는 주문이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지), 주문 통화가 KRW 가 아님, 금액이 주문 청구액과 불일치 |
| 429 | Too Many Requests | 동일 IP·`oid` 조합에서 분당 20회를 초과해 요청한 경우 |
<!-- @generated:end -->
@@ -192,17 +234,34 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| chkfake | string | `9f86d081884c7d65…` | 모바일 결제창이 KG 이니시스에 전송할 위변조 방지 해시(P_CHKFAKE). 주문번호·금액·타임스탬프로 생성된다. |
| mobile_payment_url | string | `https://mobile.inicis.com/smart/payment/` | 모바일 결제창 진입 URL. 테스트/운영 모드 설정에 따라 달라진다. |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```json
{
"data": {
"chkfake": "9f86d081884c7d65…",
"mobile_payment_url": "https://mobile.inicis.com/smart/payment/"
}
}
```
_이 엔드포인트는 `ResponseHelper` 봉투를 쓰지 않고 `response()->json()` 으로 `data` 만 반환합니다 (성공 시 `success`·`message` 없음)._
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 403 | Forbidden | 요청의 구매자 정보(`buyer_email` / `buyer_phone`)가 주문의 구매자와 일치하지 않는 경우 |
| 404 | Not Found | `oid` 에 해당하는 주문이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지), 타임스탬프 만료·형식 오류, 주문이 결제 가능 상태가 아님, 주문 통화가 KRW 가 아님, 금액 불일치, 모바일 결제 자격증명 미설정 |
| 429 | Too Many Requests | 동일 IP·`oid` 조합에서 분당 20회를 초과해 요청한 경우 |
<!-- @generated:end -->
@@ -246,17 +305,36 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| signature | string | `4d2f8a9c1b7e…` | PC 표준결제창에 전달할 위변조 방지 서명. 주문번호·금액·타임스탬프로 생성된다. |
| verification | string | `7c1a3e5b9d0f…` | 표준결제창 검증값. signature 와 함께 결제창 호출 파라미터로 전달된다. |
| mKey | string | `e3b0c44298fc1c14…` | 가맹점 키 해시(mKey). KG 이니시스 표준결제창이 가맹점 식별에 사용한다. |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```json
{
"data": {
"signature": "4d2f8a9c1b7e…",
"verification": "7c1a3e5b9d0f…",
"mKey": "e3b0c44298fc1c14…"
}
}
```
_이 엔드포인트는 `ResponseHelper` 봉투를 쓰지 않고 `response()->json()` 으로 `data` 만 반환합니다 (성공 시 `success`·`message` 없음)._
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 403 | Forbidden | 요청의 구매자 정보(`buyer_email` / `buyer_phone`)가 주문의 구매자와 일치하지 않는 경우 |
| 404 | Not Found | `oid` 에 해당하는 주문이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지), 타임스탬프 만료·형식 오류, 주문이 결제 가능 상태가 아님, 주문 통화가 KRW 가 아님, 금액 불일치, 표준결제 자격증명 미설정 |
| 429 | Too Many Requests | 동일 IP·`oid` 조합에서 분당 20회를 초과해 요청한 경우 |
<!-- @generated:end -->
@@ -310,11 +388,27 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-302 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저 리다이렉트(302)로만 응답하며, 결과는 리다이렉트 URL 과 그 쿼리스트링으로 전달됩니다._
| 리다이렉트 대상 | 조건 | 쿼리 파라미터 |
| --- | --- | --- |
| 성공 URL (`redirect_success_url`, 기본 `/shop/orders/{orderId}/complete`) | 서버 승인 성공(결제완료 또는 가상계좌 발급), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
| 실패 URL (`redirect_fail_url`, 기본 `/shop/checkout`) | 인증 실패·검증 실패·승인 실패 | `error` (`invalid_params` \| `missing_fields` \| `auth_url_invalid` \| `order_not_found` \| `amount_mismatch` 등), `message` (PG 결과 메시지), `orderId` |
| 실패 URL (쿼리 없음) | 사용자가 결제창을 닫은 취소(`2001` / `0021` / `0022` / 빈값, 또는 결과 메시지에 '취소'·'사용자' 포함) | 없음 (조용한 복귀) |
**응답 예시**
<!-- 실측 제외: http-302 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 302 Found
Location: https://example.com/shop/orders/ORD20260714001/complete
```
실패 시:
```http
HTTP/1.1 302 Found
Location: https://example.com/shop/checkout?error=amount_mismatch&orderId=ORD20260714001
```
**에러 응답**
@@ -334,7 +428,7 @@ PC 표준결제창(KRW)의 인증 결과를 브라우저 POST 로 수신해 서
### GET /plugins/sirsoft-pay_kginicis/payment/cbt/callback
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.cbt.callback -->
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.cbt.callback::get -->
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.cbt.callback`
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\CbtCallbackController@handle`
- **인증/권한**: 공개 (인증 불필요)
@@ -363,11 +457,27 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저 리다이렉트(302)로만 응답하며, 결과는 리다이렉트 URL 과 그 쿼리스트링으로 전달됩니다._
| 리다이렉트 대상 | 조건 | 쿼리 파라미터 |
| --- | --- | --- |
| 성공 URL (`redirect_success_url`, 기본 `/shop/orders/{orderId}/complete`) | CBT 서버 승인 성공(결제완료 또는 편의점 입금대기 등록), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
| 실패 URL (`redirect_fail_url`, 기본 `/shop/checkout`) | 인증 실패·MID 불일치·검증 실패·승인 실패 | `error` (`invalid_params` \| `mid_mismatch` \| `order_not_found` \| `cbt_failed` 등), `message` (PG 결과 메시지), `orderId` |
| 실패 URL (쿼리 없음) | 사용자 취소(취소 코드 또는 일본어·한국어 취소 문구) | 없음 (조용한 복귀) |
**응답 예시**
<!-- 실측 제외: http-200 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 302 Found
Location: https://example.com/shop/orders/ORD20260714001/complete
```
실패 시:
```http
HTTP/1.1 302 Found
Location: https://example.com/shop/checkout?error=mid_mismatch&orderId=ORD20260714001
```
**에러 응답**
@@ -387,7 +497,7 @@ Accept: application/json
### POST /plugins/sirsoft-pay_kginicis/payment/cbt/callback
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.cbt.callback -->
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.cbt.callback::post -->
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.cbt.callback`
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\CbtCallbackController@handle`
- **인증/권한**: 공개 (인증 불필요)
@@ -396,15 +506,15 @@ Accept: application/json
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| sid | body | string | 아니오 | max 255 | <!-- TODO: 용도 --> |
| resultCode | body | string | 아니오 | max 100 | 결제 인증 결과 코드 (`0000` = 성공). `2001`/`0021`/`0022`/빈값은 사용자 취소로 간주해 에러 없이 체크아웃으로 복귀한다. |
| resultMsg | body | string | 아니오 | max 500 | 결제 인증 결과 메시지. 실패 시 실패 URL 의 `message` 파라미터로 전달되며 '취소'/'사용자' 포함 여부로 사용자 취소를 판별한다. |
| orderID | body | string | 아니오 | max 100 | <!-- TODO: 용도 --> |
| orderId | body | string | 아니오 | max 100 | <!-- TODO: 용도 --> |
| oid | body | string | 아니오 | max 100 | 결제 대상 주문의 주문번호. 서버가 주문을 조회해 결제 가능 상태·통화·금액을 검증하고 이 값을 서명 생성 입력으로 사용한다. |
| mid | body | string | 아니오 | max 30 | <!-- TODO: 용도 --> |
| paymethod | body | string | 아니오 | max 50 | <!-- TODO: 용도 --> |
| selectedPaymentMethod | body | string | 아니오 | max 50 | <!-- TODO: 용도 --> |
| sid | body | string | 아니오 | max 255 | CBT 인증 세션 ID. 서버가 `/cbtapprove` 최종 승인 요청에 MID 와 함께 전송하는 승인 키이며, 결제 메타에 `cbt_sid` 로 저장된다. |
| resultCode | body | string | 아니오 | max 100 | CBT 인증 결과 코드 (`OK`/`00`/`0000` = 성공). `2001`/`0021`/`0022` 또는 취소 문구 포함 시 사용자 취소로 간주한다. |
| resultMsg | body | string | 아니오 | max 500 | CBT 인증 결과 메시지. 사용자 취소 판별(일본어/한국어 취소 문구)과 실패 리다이렉트 메시지에 사용된다. |
| orderID | body | string | 아니오 | max 100 | 주문번호 (1순위 키). `orderId` → `oid` 순으로 폴백해 주문을 조회한다. |
| orderId | body | string | 아니오 | max 100 | 주문번호 (2순위 폴백 키). |
| oid | body | string | 아니오 | max 100 | 주문번호 (3순위 폴백 키). |
| mid | body | string | 아니오 | max 30 | 일본 CBT 가맹점 MID. 설정된 일본 MID 와 불일치하면 결제를 중단한다(위조 콜백 차단). |
| paymethod | body | string | 아니오 | max 50 | CBT 결제수단 (CARD / PAYPAY / CVS). 승인 응답의 결제수단과 대조하며, CVS 는 편의점 입금대기 처리로 분기한다. |
| selectedPaymentMethod | body | string | 아니오 | max 50 | 체크아웃에서 사용자가 선택한 결제수단 식별값(`card` / `kginicis_japan_paypay` / `kginicis_japan_cvs`). 기대 결제수단 검증과 결제 메타 기록에 사용된다. |
**요청 예시**
@@ -429,11 +539,27 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-302 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저 리다이렉트(302)로만 응답하며, 결과는 리다이렉트 URL 과 그 쿼리스트링으로 전달됩니다 (GET 경로와 동일)._
| 리다이렉트 대상 | 조건 | 쿼리 파라미터 |
| --- | --- | --- |
| 성공 URL (`redirect_success_url`, 기본 `/shop/orders/{orderId}/complete`) | CBT 서버 승인 성공(결제완료 또는 편의점 입금대기 등록), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
| 실패 URL (`redirect_fail_url`, 기본 `/shop/checkout`) | 인증 실패·MID 불일치·검증 실패·승인 실패 | `error` (`invalid_params` \| `mid_mismatch` \| `order_not_found` \| `cbt_failed` 등), `message` (PG 결과 메시지), `orderId` |
| 실패 URL (쿼리 없음) | 사용자 취소(취소 코드 또는 일본어·한국어 취소 문구) | 없음 (조용한 복귀) |
**응답 예시**
<!-- 실측 제외: http-302 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 302 Found
Location: https://example.com/shop/orders/ORD20260714001/complete
```
실패 시:
```http
HTTP/1.1 302 Found
Location: https://example.com/shop/checkout?error=cbt_failed&orderId=ORD20260714001
```
**에러 응답**
@@ -507,16 +633,29 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON 을 반환하지 않습니다 (`data` 없음). 응답은 `Content-Type: text/plain` 의 평문 본문 한 줄이며, HTTP 상태코드는 항상 200 입니다._
| 본문 | 의미 | 발생 조건 |
| --- | --- | --- |
| `OK` | 통보 수신 확인 | 검증 통과 후 입금 처리 완료, 또는 처리 불필요(비-성공 `status` 통보 / 이미 결제완료된 중복 통보)로 무시 |
| `FAIL` | 통보 거부 | 주문·결제행 미존재, TID/MID/SID 불일치, 통화가 `JPY` 아님, 금액 불일치, 결제 상태가 입금대기 아님, 결제수단이 CVS 아님 |
`FAIL` 을 반환하면 KG 이니시스가 동일 통보를 재시도합니다.
**응답 예시**
<!-- 실측 제외: http-200 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200 OK
Content-Type: text/plain
OK
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 403 | Forbidden | KG 이니시스 공식 발송 IP 화이트리스트에 없는 IP 에서 요청한 경우 (로컬·테스트 환경 제외) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
<!-- @generated:end -->
@@ -550,15 +689,30 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON 을 반환하지 않습니다 (`data` 없음). 응답은 `Content-Type: text/html; charset=UTF-8` · `Cache-Control: no-store` 의 HTML 페이지(HTTP 200)이며, KG 이니시스 결제창 close 스크립트를 로드하고 부모/opener 창에 `{ source: 'sirsoft-pay_kginicis', type: 'payment-window-closed', reason: 'inicis-close-url' }` 를 `postMessage` 로 전달합니다. 로드되는 close 스크립트 주소는 플러그인 설정 `is_test_mode` 에 따라 `stgstdpay.inicis.com`(테스트) 또는 `stdpay.inicis.com`(운영) 으로 분기합니다._
**응답 예시**
<!-- 실측 제외: http-200 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>KG 이니시스 결제창 닫기</title>
<script>/* 부모·opener 창에 payment-window-closed postMessage 전송 */</script>
<script src="https://stgstdpay.inicis.com/stdjs/INIStdPay_close.js" charset="UTF-8"></script>
</head>
<body></body>
</html>
```
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
_에러 응답이 정의되어 있지 않습니다. 인증·요청 파라미터 없이 정적 HTML(200)만 반환하므로 도메인 특이 에러가 발생하지 않습니다._
<!-- @generated:end -->
@@ -589,15 +743,20 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-401 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON 을 반환하지 않습니다 (`data` 없음). 응답은 `Content-Type: text/html; charset=UTF-8` 의 HTML(HTTP 200)이며, 본문은 팝업을 닫는 스크립트(`window.close()`) 한 줄뿐입니다. 결제나 구매결정 상태를 변경하지 않습니다._
**응답 예시**
<!-- 실측 제외: http-401 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
<script>window.close();</script>
```
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
_에러 응답이 정의되어 있지 않습니다. 인증·요청 파라미터 없이 팝업 닫기 스크립트(HTML 200)만 반환하므로 도메인 특이 에러가 발생하지 않습니다._
<!-- @generated:end -->
@@ -626,15 +785,25 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-302 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저 리다이렉트(302)로만 응답합니다._
| 리다이렉트 대상 | 조건 |
| --- | --- |
| `/mypage/orders/{orderNumber}` | `P_ESCROW_TID` 로 KG 이니시스 에스크로 결제행을 찾아 구매결정 결과(`P_STATUS` `00` = 구매확정, 그 외 = 구매거절)를 결제 메타 `escrow_confirm` 에 기록한 경우 |
| `/mypage/orders` | 거래번호가 비었거나 매칭되는 에스크로 결제가 없어 아무것도 기록하지 않은 경우 |
_KG 이니시스가 POST 로 보내는 필드(라우트 FormRequest 로 선언되지 않아 요청 파라미터 표에는 나타나지 않음): `P_STATUS`(구매결정 결과 코드), `P_ESCROW_TID`(에스크로 거래번호), `P_CL_STATUS`(결제 상태 구분), `P_RMESG1`(결과 메시지)._
**응답 예시**
<!-- 실측 제외: http-302 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 302 Found
Location: https://example.com/mypage/orders/ORD20260714001
```
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
_에러 응답이 정의되어 있지 않습니다. 거래번호가 비었거나 매칭되는 에스크로 결제가 없어도 예외를 던지지 않고 주문 목록(`/mypage/orders`)으로 리다이렉트합니다._
<!-- @generated:end -->
@@ -665,15 +834,22 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON 을 반환하지 않습니다 (`data` 없음). 응답은 `Content-Type: text/html; charset=UTF-8` 의 HTML(HTTP 200)이며, 부모 창을 새로고침하고 팝업을 닫는 스크립트뿐입니다. 구매결정 결과(`ResultCode` `00` = 구매확정, 그 외 = 구매거절)와 확정·거절 일시, 거절 사유는 `tid` 로 찾은 에스크로 결제행의 결제 메타 `escrow_confirm` 에 기록됩니다._
_KG 이니시스가 POST 로 보내는 필드(라우트 FormRequest 로 선언되지 않아 요청 파라미터 표에는 나타나지 않음): `ResultCode`(구매결정 결과 코드), `tid`(에스크로 거래번호), `CNF_Date`/`CNF_Time`(구매확정 일시), `DNY_Date`/`DNY_Time`(구매거절 일시), `DNY_DenyMsg`(구매거절 사유)._
**응답 예시**
<!-- 실측 제외: http-200 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
<script>try{window.opener&&window.opener.location.reload();}catch(e){}window.close();</script>
```
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
_에러 응답이 정의되어 있지 않습니다. 거래번호가 비었거나 매칭되는 에스크로 결제가 없어도 기록만 생략하고 동일한 HTML(200)을 반환합니다._
<!-- @generated:end -->
@@ -694,7 +870,7 @@ PC 에스크로 구매결정 결과를 KG 이니시스 팝업으로부터 수신
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| orderNumber | path | string | 예 | — | 대상 order number의 식별자 |
| orderNumber | path | string | 예 | — | 구매결정할 주문의 주문번호. 로그인 사용자 본인의 KG 이니시스 에스크로 결제(`is_escrow=true`, `transaction_id` 존재)를 찾는 키로 사용된다. |
**요청 예시**
@@ -706,17 +882,50 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON 을 반환하지 않습니다 (`data` 없음). 응답은 `Content-Type: text/html; charset=UTF-8` 의 HTML 페이지(HTTP 200)이며, KG 이니시스 구매결정 창으로 자동 전송되는 폼을 담고 있습니다. 접속 기기의 User-Agent 로 PC/모바일을 판별해 다음과 같이 분기합니다._
| 분기 | 반환 HTML | 폼 전송 대상 | 주요 hidden 필드 |
| --- | --- | --- | --- |
| PC | `INIStdPay_escrow_conf.js` 로드 후 `INIStdPay.pay('escrow_confirm_form')` 자동 실행 | KG 이니시스 PC 구매결정 팝업 (테스트: `stgstdpay.inicis.com`, 운영: `stdpay.inicis.com`) | `mid`, `tid`, `timestamp`, `mKey`, `currency=WON`, `returnUrl`(pc/return), `closeUrl`(escrow-confirm/close) |
| 모바일 | 폼 자동 submit 스크립트 | `https://mobile.inicis.com/smart/payment/` (accept-charset: euc-kr) | `P_INI_PAYMENT=ESCROWCONFIRM`, `P_MID`, `P_ESCROW_TID`, `P_NEXT_URL`(mobile/return) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>에스크로 구매결정</title>
<script src="https://stgstdpay.inicis.com/stdjs/INIStdPay_escrow_conf.js" charset="UTF-8"></script>
<script>window.onload = function() { INIStdPay.pay('escrow_confirm_form'); };</script>
</head>
<body>
<p>에스크로 구매결정 창을 준비 중입니다...</p>
<form id="escrow_confirm_form" method="post">
<input type="hidden" name="version" value="1.0">
<input type="hidden" name="mid" value="INIpayTest">
<input type="hidden" name="tid" value="StdpayCARDINIpayTest20260714…">
<input type="hidden" name="timestamp" value="1768348800000">
<input type="hidden" name="mKey" value="e3b0c44298fc1c14…">
<input type="hidden" name="currency" value="WON">
<input type="hidden" name="returnUrl" value="https://example.com/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/pc/return">
<input type="hidden" name="closeUrl" value="https://example.com/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/close">
<input type="hidden" name="acceptmethod" value="">
</form>
</body>
</html>
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 401 | Unauthorized | 비로그인 상태로 접근한 경우 (라우트에 `auth` 미들웨어 적용 — 요약의 "공개" 표기는 자동 생성 오차) |
| 404 | Not Found | `orderNumber` 에 해당하는 주문이 접속 사용자 본인 소유가 아니거나, 해당 주문에 거래번호가 있는 KG 이니시스 에스크로 결제(`pg_provider=kginicis`, `is_escrow=true`)가 없는 경우 |
<!-- @generated:end -->
@@ -728,7 +937,7 @@ Accept: application/json
### GET /plugins/sirsoft-pay_kginicis/payment/mobile/callback
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.mobile.callback -->
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.mobile.callback::get -->
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.mobile.callback`
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\MobileCallbackController@handle`
- **인증/권한**: 공개 (인증 불필요)
@@ -755,11 +964,27 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저 리다이렉트(302)로만 응답하며, 결과는 리다이렉트 URL 과 그 쿼리스트링으로 전달됩니다._
| 리다이렉트 대상 | 조건 | 쿼리 파라미터 |
| --- | --- | --- |
| 성공 URL (`redirect_success_url`, 기본 `/shop/orders/{orderId}/complete`) | 서버 승인 성공(결제완료 또는 가상계좌 발급), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
| 실패 URL (`redirect_fail_url`, 기본 `/shop/checkout`) | 인증 실패·검증 실패·승인 실패 | `error` (`order_id_missing` \| `missing_fields` \| `req_url_invalid` \| `order_not_found` \| `amount_mismatch` 등), `message` (`P_RMESG1` 결과 메시지), `orderId` |
| 실패 URL (쿼리 없음) | 사용자가 결제창을 닫은 취소(`P_RMESG1` 에 '사용자가 결제를 취소' 등 취소 문구 포함) | 없음 (조용한 복귀) |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 302 Found
Location: https://example.com/shop/orders/ORD20260714001/complete
```
실패 시:
```http
HTTP/1.1 302 Found
Location: https://example.com/shop/checkout?error=req_url_invalid&orderId=ORD20260714001
```
**에러 응답**
@@ -779,7 +1004,7 @@ Accept: application/json
### POST /plugins/sirsoft-pay_kginicis/payment/mobile/callback
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.mobile.callback -->
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.mobile.callback::post -->
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.mobile.callback`
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\MobileCallbackController@handle`
- **인증/권한**: 공개 (인증 불필요)
@@ -788,12 +1013,12 @@ Accept: application/json
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| P_STATUS | body | string | 예 | — | <!-- TODO: 용도 --> |
| P_RMESG1 | body | string | 아니오 | — | <!-- TODO: 용도 --> |
| P_TID | body | string | 아니오 | — | <!-- TODO: 용도 --> |
| P_REQ_URL | body | string | 아니오 | — | <!-- TODO: 용도 --> |
| P_AMT | body | string | 아니오 | — | <!-- TODO: 용도 --> |
| P_OID | body | string | 아니오 | — | <!-- TODO: 용도 --> |
| P_STATUS | body | string | 예 | — | 모바일 결제 인증 결과 코드 (`00` = 성공). 그 외 값은 실패이며 `P_RMESG1` 문구로 사용자 취소 여부를 분기한다. |
| P_RMESG1 | body | string | 아니오 | — | 모바일 결제 인증 결과 메시지. '사용자가 결제를 취소' 등 취소 문구 포함 시 오류 없이 체크아웃으로 복귀한다. |
| P_TID | body | string | 아니오 | — | KG 이니시스 거래번호. 서버 승인 요청(`P_REQ_URL`)에 MID 와 함께 전송된다. |
| P_REQ_URL | body | string | 아니오 | — | 서버 승인 API 요청 URL. `idc_name` 과 함께 화이트리스트 검증(SSRF 방어)을 통과해야 호출된다. |
| P_AMT | body | string | 아니오 | — | 결제 금액. 서버 승인 응답의 `P_AMT` 가 없을 때 결제 완료 금액의 폴백으로 사용된다. |
| P_OID | body | string | 아니오 | — | 주문번호. 없으면 콜백 URL 쿼리스트링의 `orderId` 를 폴백으로 사용한다. |
| idc_name | body | string | 아니오 | — | KG 이니시스 IDC 센터 코드(fc/ks/stg). 승인/망취소 URL 화이트리스트 검증의 기준값. |
**요청 예시**
@@ -817,11 +1042,27 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-302 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저 리다이렉트(302)로만 응답하며, 결과는 리다이렉트 URL 과 그 쿼리스트링으로 전달됩니다 (GET 경로와 동일)._
| 리다이렉트 대상 | 조건 | 쿼리 파라미터 |
| --- | --- | --- |
| 성공 URL (`redirect_success_url`, 기본 `/shop/orders/{orderId}/complete`) | 서버 승인 성공(결제완료 또는 가상계좌 발급), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
| 실패 URL (`redirect_fail_url`, 기본 `/shop/checkout`) | 인증 실패·검증 실패·승인 실패 | `error` (`order_id_missing` \| `missing_fields` \| `req_url_invalid` \| `order_not_found` \| `amount_mismatch` 등), `message` (`P_RMESG1` 결과 메시지), `orderId` |
| 실패 URL (쿼리 없음) | 사용자가 결제창을 닫은 취소(`P_RMESG1` 에 취소 문구 포함) | 없음 (조용한 복귀) |
**응답 예시**
<!-- 실측 제외: http-302 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 302 Found
Location: https://example.com/shop/orders/ORD20260714001/complete
```
실패 시:
```http
HTTP/1.1 302 Found
Location: https://example.com/shop/checkout?error=amount_mismatch&orderId=ORD20260714001
```
**에러 응답**
@@ -903,16 +1144,29 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON 을 반환하지 않습니다 (`data` 없음). 응답은 `Content-Type: text/plain` 의 평문 본문 한 줄이며, HTTP 상태코드는 항상 200 입니다._
| 본문 | 의미 | 발생 조건 |
| --- | --- | --- |
| `OK` | 통보 수신 확인 | 검증 통과 후 입금 처리 완료, 또는 처리 불필요(`P_STATUS` 가 `02` 가 아니거나 `P_TYPE` 이 `VBANK` 가 아닌 통보 / 이미 결제완료된 중복 통보)로 무시 |
| `FAIL` | 통보 거부 | 주문 미존재, 거래번호(TID)·MID·가상계좌번호 불일치, 금액이 결제 통화 기준 청구액과 불일치, 결제가 가상계좌·입금대기 상태가 아님, 처리 중 예외 발생 |
`FAIL` 을 반환하면 KG 이니시스가 동일 통보를 재시도합니다.
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200 OK
Content-Type: text/plain
OK
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 403 | Forbidden | KG 이니시스 공식 발송 IP 화이트리스트에 없는 IP 에서 요청한 경우 (로컬·테스트 환경 제외) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
<!-- @generated:end -->
@@ -1007,16 +1261,29 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON 을 반환하지 않습니다 (`data` 없음). 응답은 `Content-Type: text/plain` 의 평문 본문 한 줄이며, HTTP 상태코드는 항상 200 입니다._
| 본문 | 의미 | 발생 조건 |
| --- | --- | --- |
| `OK` | 통보 수신 확인 | 검증 통과 후 입금 처리 완료, 또는 이미 결제완료된 거래번호의 중복 통보로 무시 |
| `FAIL` | 통보 거부 | 주문(`no_oid`) 미존재, 거래번호(`no_tid`)·MID(`id_merchant`)·가상계좌번호(`no_vacct`) 불일치, 입금 금액(`amt_input`)이 결제 통화 기준 청구액과 불일치, 결제가 가상계좌·입금대기 상태가 아님, 처리 중 예외 발생 |
`FAIL` 을 반환하면 KG 이니시스가 동일 통보를 최대 10회까지 재시도합니다.
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200 OK
Content-Type: text/plain
OK
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 403 | Forbidden | KG 이니시스 공식 발송 IP 화이트리스트에 없는 IP 에서 요청한 경우 (로컬·테스트 환경 제외) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
<!-- @generated:end -->
@@ -38,11 +38,115 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
`data` 는 KG 이니시스 INIAPI v2 `inquiry` 응답(원본 키 그대로)에 화면 표시용 보강 필드(`_` 접두 키)를 덧붙인 객체입니다. 원본 키는 결제수단·거래 상태에 따라 존재 여부가 달라지므로, 화면은 항상 `_` 접두 필드를 기준으로 읽습니다. 보강 필드는 조회 응답 → 로컬 결제 레코드(`ecommerce_order_payments`) fallback → `null` 순으로 채워집니다.
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| resultCode | string | `0000` | KG 이니시스 조회 결과 코드 (원본 응답). CBT 로컬 조회 시에는 `LOCAL_CBT` |
| resultMsg | string | `정상처리` | KG 이니시스 조회 결과 메시지 (원본 응답) |
| tid | string | `StdpayCARDSIRSOFT001202606191358...` | 거래번호(TID) |
| \_is_test_mode | boolean | `true` | 이 거래가 테스트 모드 자격증명으로 결제/조회된 거래인지 여부 |
| \_is_cbt | boolean | `true` | 일본 CBT(국가간 거래) 결제 여부. CBT 응답에만 포함 |
| \_is_local_confirmation | boolean | `true` | KG 이니시스 조회 API 가 아닌 로컬 저장 승인/입금 정보로 구성된 결과인지 여부. CBT 응답에만 포함 |
| \_local_notice | string | `CBT 거래는 한국 INIAPI 거래조회 대상이 아니므로 저장된 승인/입금 확인 정보로 표시됩니다.` | 로컬 확인 결과임을 알리는 안내 문구. CBT 응답에만 포함 |
| \_local_is_escrow | boolean | `false` | 로컬 결제 레코드(`ecommerce_order_payments.is_escrow`) 기준 에스크로 결제 여부 |
| \_pay_method | string | `Card` | KG 이니시스 결제수단 원본 코드 (`Card`/`VBank`/`DirectBank`/`HPP`/`EasyPay`/`CVS` 등) |
| \_base_pay_method_label | string | `신용카드` | 결제수단 코드의 한국어 라벨 (미매핑 시 코드 원문, 없으면 `-`) |
| \_embedded_pg_provider | string \| null | `kakaopay` | 간편결제 등에서 내부 결제사업자 코드 (없으면 `null`) |
| \_embedded_pg_provider_label | string \| null | `카카오페이` | 내부 결제사업자의 한국어 라벨 (없으면 `null`) |
| \_pay_method_label | string | `카카오페이 (신용카드)` | 화면 표시용 최종 결제수단 라벨. 내부 결제사업자가 있으면 `사업자 (기본수단)` 형태 |
| \_auth_code | string \| null | `30001234` | 승인번호 (카드 `applNum` 등) |
| \_auth_date | string \| null | `2026-06-19 13:58:55` | 승인 일시 (`YYYY-MM-DD HH:MM:SS`) |
| \_total_price | string \| null | `39000` | 결제(승인) 금액 |
| \_currency | string | `WON` | 결제 통화. KG 국내 거래는 `WON`, CBT 거래는 `JPY` 등 |
| \_moid | string \| null | `20260619-1358556131` | 주문번호(MOID) |
| \_buyer_name | string \| null | `홍길동` | 구매자 이름 |
| \_buyer_email | string \| null | `buyer@example.com` | 구매자 이메일 |
| \_buyer_tel | string \| null | `01012345678` | 구매자 연락처 |
| \_status | string \| null | `0` | KG 이니시스 거래 상태 값 (원본 `status`) |
| \_cancel_price | string \| null | `39000` | 취소 금액 (취소 이력이 있는 경우) |
| \_cancel_date | string \| null | `2026-06-20 10:12:00` | 취소 일시 |
| \_part_cancel_list | array | `[]` | 부분취소 이력 배열. 각 항목은 `price` / `date` / `msg` / `tid` |
| \_card_name | string \| null | `신한카드` | 카드사명 |
| \_card_num | string \| null | `123456******7890` | 마스킹된 카드번호 |
| \_card_code | string \| null | `03` | 카드사 코드 |
| \_card_quota | string \| null | `일시불` | 할부 개월 표시값 (`0` → `일시불`, 그 외 `N개월`) |
| \_card_interest | string \| null | `0` | 무이자 여부 원본 값 (CBT 응답에서는 항상 `null`) |
| \_vbank_num | string \| null | `56212345678901` | 가상계좌 번호 (CBT 편의점결제는 접수번호) |
| \_vbank_bank_code | string \| null | `88` | 가상계좌 은행 코드 |
| \_vbank_bank_name | string \| null | `신한(통합)은행` | 가상계좌 은행명. 미제공 시 은행 코드로 매핑 |
| \_vbank_holder | string \| null | `홍길동` | 가상계좌 예금주명 |
| \_vbank_expire_date | string \| null | `2026-06-20 08:59:59` | 가상계좌 입금 기한(KST). 로컬 `vbank_due_at` 이 있으면 그 값을 KST 로 변환해 사용 |
| \_vbank_status | string \| null | `1` | 가상계좌 입금 상태 값 |
| \_vbank_paid_at | string \| null | `2026-06-19 17:03:11` | 가상계좌 입금 완료 일시 |
| \_bank_code | string \| null | `04` | 계좌이체 은행 코드 |
| \_bank_name | string \| null | `국민은행` | 계좌이체 은행명. 미제공 시 은행 코드로 매핑 |
| \_bank_acnt_num | string \| null | `12345678901234` | 계좌이체 출금 계좌번호 |
| \_hpp_num | string \| null | `01012345678` | 휴대폰 결제 번호 |
| \_hpp_corp | string \| null | `SKT` | 휴대폰 결제 통신사 |
| \_escrow_status | string \| null | `1` | 에스크로 상태 값 |
| \_escrow_confirm | string \| null | `2026-06-21 09:00:00` | 에스크로 구매확정 일시 |
| \_inquiry_at | string | `2026-06-19 14:02:31` | 이 조회를 수행한 서버 시각 |
| \_cbt_cvs | object \| null | `null` | 일본 편의점결제(CVS) 거래일 때의 상세 상태. `status` / `last_notify_at` / `last_notify_result` / `last_notify_reason` / `last_recheck_at` / `last_recheck_result` / `expired_at` / `expiry_reason` / `notify_history`(최대 10건). CBT 응답에만 포함 |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "성공적으로 처리되었습니다.",
"data": {
"resultCode": "0000",
"resultMsg": "정상처리",
"tid": "StdpayCARDSIRSOFT001202606191358556131",
"_is_test_mode": true,
"_local_is_escrow": false,
"_pay_method": "Card",
"_base_pay_method_label": "신용카드",
"_embedded_pg_provider": null,
"_embedded_pg_provider_label": null,
"_pay_method_label": "신용카드",
"_auth_code": "30001234",
"_auth_date": "2026-06-19 13:58:55",
"_total_price": "39000",
"_currency": "WON",
"_moid": "20260619-1358556131",
"_buyer_name": "홍길동",
"_buyer_email": "buyer@example.com",
"_buyer_tel": "01012345678",
"_status": "0",
"_cancel_price": null,
"_cancel_date": null,
"_part_cancel_list": [],
"_card_name": "신한카드",
"_card_num": "123456******7890",
"_card_code": "03",
"_card_quota": "일시불",
"_card_interest": null,
"_vbank_num": null,
"_vbank_bank_code": null,
"_vbank_bank_name": null,
"_vbank_holder": null,
"_vbank_expire_date": null,
"_vbank_status": null,
"_vbank_paid_at": null,
"_bank_code": null,
"_bank_name": null,
"_bank_acnt_num": null,
"_hpp_num": null,
"_hpp_corp": null,
"_escrow_status": null,
"_escrow_confirm": null,
"_inquiry_at": "2026-06-19 14:02:31"
}
}
```
**에러 응답**
@@ -50,6 +154,8 @@ Authorization: Bearer {YOUR_TOKEN}
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 |
| 422 | Unprocessable Entity | `tid` 가 비어 있는 경우 (`errors.tid` = `TID를 입력하세요.`) |
| 502 | Bad Gateway | KG 이니시스 조회 API 호출 실패(HTTP 오류·타임아웃 등) 시 |
<!-- @generated:end -->
@@ -35,7 +35,7 @@ class AdminCbtCvsOperationsController extends AdminBaseController
);
}
return ResponseHelper::success('messages.success', $summary);
return ResponseHelper::success('common.success', $summary);
}
/**
@@ -28,7 +28,7 @@ class AdminCbtReconciliationController extends AdminBaseController
*/
public function show(string $orderNumber): JsonResponse
{
return ResponseHelper::success('messages.success', $this->reconciliationService->get($orderNumber));
return ResponseHelper::success('common.success', $this->reconciliationService->get($orderNumber));
}
/**
@@ -34,7 +34,7 @@ class AdminCbtTestProductController extends AdminBaseController
{
try {
$timestamp = now()->format('YmdHis');
$productCode = 'CBT-TEST-' . $timestamp;
$productCode = 'CBT-TEST-'.$timestamp;
// ProductService 가 currency_code 를 모듈 default_currency 로 자동 설정.
// 운영 환경의 default_currency 가 JPY 이면 selling_price 가 JPY 로 해석됨.
@@ -42,45 +42,45 @@ class AdminCbtTestProductController extends AdminBaseController
// options 배열은 옵션 없는 상품도 default 1행 필수 — CartService 가 default option 을
// 못 찾으면 "존재하지 않는 옵션입니다" 예외를 던져 장바구니 담기가 실패한다.
$product = $this->productService->create([
'name' => [
'name' => [
'ko' => '[테스트] CBT 일본 결제',
'en' => '[Test] CBT Japan Payment',
'ja' => '[テスト] CBT 日本決済',
],
'product_code' => $productCode,
'sku' => 'KGINICIS-' . $productCode,
'selling_price' => 100, // 100엔 — CBT 테스트 최소 단위
'list_price' => 100,
'product_code' => $productCode,
'sku' => 'KGINICIS-'.$productCode,
'selling_price' => 100, // 100엔 — CBT 테스트 최소 단위
'list_price' => 100,
'stock_quantity' => 9999,
'description' => [
'description' => [
'ko' => 'KG 이니시스 일본 결제(CBT, JPPG) 테스트 전용 상품입니다.',
'en' => 'KG Inicis Japan Payment (CBT, JPPG) test product.',
'ja' => 'KG イニシス 日本決済 (CBT, JPPG) テスト用商品です。',
],
'sales_status' => 'on_sale',
'sales_status' => 'on_sale',
'display_status' => 'visible',
'options' => [
'options' => [
[
'option_code' => 'default',
'option_values' => [],
'option_name' => ['ko' => '기본', 'en' => 'Default', 'ja' => 'デフォルト'],
'stock_quantity' => 9999,
'is_default' => true,
'is_active' => true,
'option_code' => 'default',
'option_values' => [],
'option_name' => ['ko' => '기본', 'en' => 'Default', 'ja' => 'デフォルト'],
'stock_quantity' => 9999,
'is_default' => true,
'is_active' => true,
],
],
]);
Log::info('KG Inicis CBT: test product created', [
'product_id' => $product->id,
'product_id' => $product->id,
'product_code' => $product->product_code,
]);
return ResponseHelper::success('messages.success', [
'product_id' => $product->id,
return ResponseHelper::success('common.success', [
'product_id' => $product->id,
'product_code' => $product->product_code,
'admin_url' => '/admin/ecommerce/products/' . $product->id . '/edit',
'shop_url' => '/shop/products/' . $product->id . '?locale=ja',
'admin_url' => '/admin/ecommerce/products/'.$product->id.'/edit',
'shop_url' => '/shop/products/'.$product->id.'?locale=ja',
]);
} catch (\Throwable $e) {
Log::error('KG Inicis CBT: test product creation failed', [
@@ -88,7 +88,7 @@ class AdminCbtTestProductController extends AdminBaseController
'trace' => $e->getTraceAsString(),
]);
return ResponseHelper::error('messages.failed', 500, ['detail' => $e->getMessage()]);
return ResponseHelper::error('common.failed', 500, ['detail' => $e->getMessage()]);
}
}
}
@@ -72,7 +72,7 @@ class AdminEscrowDeliveryController extends AdminBaseController
$payment = $this->findEscrowPayment($orderNumber);
if (! $payment) {
return ResponseHelper::success('messages.success', null);
return ResponseHelper::success('common.success', null);
}
$address = DB::table('ecommerce_order_addresses as a')
@@ -94,7 +94,7 @@ class AdminEscrowDeliveryController extends AdminBaseController
$escrowConfirm = $meta['escrow_confirm'] ?? null;
$denyConfirmed = isset($meta['escrow_deny_confirm']);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'has_escrow_payment' => true,
'tid' => $payment->transaction_id,
'price' => (int) round((float) $payment->paid_amount_local),
@@ -124,17 +124,17 @@ class AdminEscrowDeliveryController extends AdminBaseController
$exCode = trim((string) $request->input('ex_code', ''));
if ($invoice === '') {
return ResponseHelper::error('messages.failed', 422, ['invoice' => ['운송장번호를 입력해주세요.']]);
return ResponseHelper::error('common.failed', 422, ['invoice' => ['운송장번호를 입력해주세요.']]);
}
if ($exCode === '' || ! array_key_exists($exCode, self::COURIER_CODES)) {
return ResponseHelper::error('messages.failed', 422, ['ex_code' => ['택배사를 선택해주세요.']]);
return ResponseHelper::error('common.failed', 422, ['ex_code' => ['택배사를 선택해주세요.']]);
}
$payment = $this->findEscrowPayment($orderNumber);
if (! $payment) {
return ResponseHelper::error('messages.failed', 404, null);
return ResponseHelper::error('common.failed', 404, null);
}
$report = in_array($request->input('report'), ['I', 'U'], true) ? $request->input('report') : 'I';
@@ -206,7 +206,7 @@ class AdminEscrowDeliveryController extends AdminBaseController
'pg_response' => $sanitizedPgResponse,
]);
return ResponseHelper::error('messages.failed', 502, [
return ResponseHelper::error('common.failed', 502, [
'message' => [$pgResponse['resultMsg'] ?? '배송등록에 실패했습니다.'],
]);
}
@@ -240,7 +240,7 @@ class AdminEscrowDeliveryController extends AdminBaseController
'ex_name' => $exName,
]);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'result_code' => $resultCode,
'result_msg' => $pgResponse['resultMsg'] ?? 'OK',
'invoice' => $invoice,
@@ -253,7 +253,7 @@ class AdminEscrowDeliveryController extends AdminBaseController
'error' => $e->getMessage(),
]);
return ResponseHelper::error('messages.failed', 500, [
return ResponseHelper::error('common.failed', 500, [
'message' => [$e->getMessage()],
]);
}
@@ -53,14 +53,14 @@ class AdminEscrowDenyConfirmController extends AdminBaseController
$payment = $this->findEscrowPayment($orderNumber);
if (! $payment) {
return ResponseHelper::error('messages.failed', 404, null);
return ResponseHelper::error('common.failed', 404, null);
}
$meta = $payment->payment_meta ? json_decode($payment->payment_meta, true) : [];
// 이미 구매거절확인 완료된 경우
if (isset($meta['escrow_deny_confirm'])) {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['이미 구매거절확인이 완료되었습니다.'],
]);
}
@@ -91,7 +91,7 @@ class AdminEscrowDenyConfirmController extends AdminBaseController
'pg_response' => $sanitizedPgResponse,
]);
return ResponseHelper::error('messages.failed', 502, [
return ResponseHelper::error('common.failed', 502, [
'message' => [$pgResponse['resultMsg'] ?? '구매거절확인에 실패했습니다.'],
]);
}
@@ -116,7 +116,7 @@ class AdminEscrowDenyConfirmController extends AdminBaseController
'tid' => $payment->transaction_id,
]);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'result_code' => $resultCode,
'result_msg' => $pgResponse['resultMsg'] ?? 'OK',
]);
@@ -127,7 +127,7 @@ class AdminEscrowDenyConfirmController extends AdminBaseController
'error' => $e->getMessage(),
]);
return ResponseHelper::error('messages.failed', 500, [
return ResponseHelper::error('common.failed', 500, [
'message' => [$e->getMessage()],
]);
}
@@ -45,7 +45,7 @@ class AdminOrderListController extends AdminBaseController
}
}
return ResponseHelper::success('messages.success', $map);
return ResponseHelper::success('common.success', $map);
}
/**
@@ -21,7 +21,7 @@ class AdminSettingsStatusController
*/
public function testMode(): JsonResponse
{
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'is_test_mode' => (bool) $this->settingsService->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true),
]);
}
@@ -31,7 +31,7 @@ class AdminTransactionController extends AdminBaseController
$tid = trim((string) $request->input('tid', ''));
if ($tid === '') {
return ResponseHelper::error('messages.failed', 422, ['tid' => ['TID를 입력하세요.']]);
return ResponseHelper::error('common.failed', 422, ['tid' => ['TID를 입력하세요.']]);
}
return $this->queryByTid($tid);
@@ -55,7 +55,7 @@ class AdminTransactionController extends AdminBaseController
->first();
if (! $payment) {
return ResponseHelper::success('messages.success', null);
return ResponseHelper::success('common.success', null);
}
return $this->queryByTid($payment->transaction_id);
@@ -117,7 +117,7 @@ class AdminTransactionController extends AdminBaseController
is_string($embeddedPgProvider) ? $embeddedPgProvider : null,
);
return ResponseHelper::success('messages.success', $result);
return ResponseHelper::success('common.success', $result);
}
// 결제 시점 모드(payment_meta.is_test_mode) 가 있으면 그 모드의 inapi 자격증명으로 조회.
@@ -140,14 +140,14 @@ class AdminTransactionController extends AdminBaseController
is_string($embeddedPgProvider) ? $embeddedPgProvider : null,
);
return ResponseHelper::success('messages.success', $result);
return ResponseHelper::success('common.success', $result);
} catch (\Exception $e) {
Log::error('KG Inicis queryTransaction failed', [
'tid' => $tid,
'error' => $e->getMessage(),
]);
return ResponseHelper::error('messages.failed', 502, null);
return ResponseHelper::error('common.failed', 502, null);
}
}
@@ -43,7 +43,7 @@ class PaymentCloseReportController
$rateLimitKey = $this->rateLimitKey($request, $oid);
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return ResponseHelper::error('messages.failed', 429, [
return ResponseHelper::error('common.failed', 429, [
'message' => ['Too many KG Inicis payment close reports. Please try again later.'],
]);
}
@@ -58,13 +58,13 @@ class PaymentCloseReportController
$order = $this->orderService->findByOrderNumber($oid);
if (! $order) {
return ResponseHelper::error('messages.failed', 404, [
return ResponseHelper::error('common.failed', 404, [
'message' => ['Order not found.'],
]);
}
if (! $order->order_status->isBeforePayment()) {
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'ignored',
'reason' => 'order_not_payable',
]);
@@ -76,20 +76,20 @@ class PaymentCloseReportController
// 어긋나는 race 차단). failPayment 자체에도 동일 가드가 있으나, 여기서 차단해 불필요한
// 마일리지 복원 시도·결제취소 이력 기록까지 미연에 방지한다.
if ($order->payment?->isPaid()) {
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'ignored',
'reason' => 'payment_already_paid',
]);
}
if (strtoupper((string) $order->currency) !== 'KRW') {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Standard KG Inicis close report is only available for KRW orders.'],
]);
}
if (! $this->requestMatchesOrderBuyer($request, $order)) {
return ResponseHelper::error('messages.failed', 403, [
return ResponseHelper::error('common.failed', 403, [
'message' => ['Order buyer verification failed.'],
]);
}
@@ -98,13 +98,13 @@ class PaymentCloseReportController
'received_amount' => $price,
]);
if ($expectedPrice === null) {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment currency is not chargeable.'],
]);
}
if ($price !== $expectedPrice) {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment amount does not match the order amount.'],
]);
}
@@ -129,11 +129,11 @@ class PaymentCloseReportController
$closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE,
);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'recorded',
]);
} catch (LockTimeoutException) {
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'ignored',
'reason' => 'callback_in_progress',
]);
@@ -69,7 +69,7 @@ class AdminEscrowDeliveryController extends AdminBaseController
$payment = $this->findEscrowPayment($orderNumber);
if (! $payment) {
return ResponseHelper::success('messages.success', null);
return ResponseHelper::success('common.success', null);
}
$address = DB::table('ecommerce_order_addresses as a')
@@ -88,7 +88,7 @@ class AdminEscrowDeliveryController extends AdminBaseController
$meta = $payment->payment_meta ? json_decode($payment->payment_meta, true) : [];
$escrowDelivery = $meta['escrow_delivery'] ?? null;
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'has_escrow_payment' => true,
'tno' => $payment->transaction_id,
'courier_codes' => self::COURIER_CODES,
@@ -120,17 +120,17 @@ class AdminEscrowDeliveryController extends AdminBaseController
$deliCorp = trim((string) $request->input('deli_corp', ''));
if ($deliNumb === '') {
return ResponseHelper::error('messages.failed', 422, ['deli_numb' => ['운송장번호를 입력해주세요.']]);
return ResponseHelper::error('common.failed', 422, ['deli_numb' => ['운송장번호를 입력해주세요.']]);
}
if ($deliCorp === '' || ! array_key_exists($deliCorp, self::COURIER_CODES)) {
return ResponseHelper::error('messages.failed', 422, ['deli_corp' => ['택배사를 선택해주세요.']]);
return ResponseHelper::error('common.failed', 422, ['deli_corp' => ['택배사를 선택해주세요.']]);
}
$payment = $this->findEscrowPayment($orderNumber);
if (! $payment) {
return ResponseHelper::error('messages.failed', 404, null);
return ResponseHelper::error('common.failed', 404, null);
}
Log::info('KCP: escrow delivery register requested', [
@@ -173,7 +173,7 @@ class AdminEscrowDeliveryController extends AdminBaseController
'courier_name' => $courierName,
]);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'res_cd' => $pgResponse['res_cd'] ?? '0000',
'deli_numb' => $deliNumb,
'courier_name' => $courierName,
@@ -185,7 +185,7 @@ class AdminEscrowDeliveryController extends AdminBaseController
'error' => $e->getMessage(),
]);
return ResponseHelper::error('messages.failed', 500, [
return ResponseHelper::error('common.failed', 500, [
'message' => [$e->getMessage()],
]);
}
@@ -59,7 +59,7 @@ class AdminOrderListController extends AdminBaseController
}
}
return ResponseHelper::success('messages.success', $map);
return ResponseHelper::success('common.success', $map);
}
/**
@@ -99,6 +99,6 @@ class AdminOrderListController extends AdminBaseController
];
}
return ResponseHelper::success('messages.success', $map);
return ResponseHelper::success('common.success', $map);
}
}
@@ -21,7 +21,7 @@ class AdminSettingsStatusController
*/
public function testMode(): JsonResponse
{
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'is_test_mode' => (bool) $this->settingsService->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true),
]);
}
@@ -66,7 +66,7 @@ class AdminTransactionController extends AdminBaseController
->first();
if (! $payment) {
return ResponseHelper::success('messages.success', null);
return ResponseHelper::success('common.success', null);
}
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
@@ -85,7 +85,7 @@ class AdminTransactionController extends AdminBaseController
$cancelledAmount = (float) ($payment->cancelled_amount ?? 0);
$refundAmount = $refund ? (float) ($refund->refund_amount ?? 0) : 0.0;
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'tno' => $payment->transaction_id,
'app_no' => $rawResponse['app_no'] ?? $meta['app_no'] ?? null,
'use_pay_method' => $meta['use_pay_method'] ?? $rawResponse['use_pay_method'] ?? null,
@@ -40,7 +40,7 @@ class PaymentCloseReportController
$rateLimitKey = $this->rateLimitKey($request, $oid);
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return ResponseHelper::error('messages.failed', 429, [
return ResponseHelper::error('common.failed', 429, [
'message' => ['Too many NHN KCP payment close reports. Please try again later.'],
]);
}
@@ -55,13 +55,13 @@ class PaymentCloseReportController
$order = $this->orderService->findByOrderNumber($oid);
if (! $order) {
return ResponseHelper::error('messages.failed', 404, [
return ResponseHelper::error('common.failed', 404, [
'message' => ['Order not found.'],
]);
}
if (! $order->order_status->isBeforePayment()) {
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'ignored',
'reason' => 'order_not_payable',
]);
@@ -73,14 +73,14 @@ class PaymentCloseReportController
// 어긋나는 race 차단). failPayment 자체에도 동일 가드가 있으나 여기서 차단해 불필요한
// 결제취소 이력 기록까지 미연에 방지한다.
if ($order->payment?->isPaid()) {
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'ignored',
'reason' => 'payment_already_paid',
]);
}
if (! $this->requestMatchesOrderBuyer($request, $order)) {
return ResponseHelper::error('messages.failed', 403, [
return ResponseHelper::error('common.failed', 403, [
'message' => ['Order buyer verification failed.'],
]);
}
@@ -91,13 +91,13 @@ class PaymentCloseReportController
'ip' => $request->ip(),
]);
if ($expectedPrice === null) {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment currency is not chargeable.'],
]);
}
if ($price !== $expectedPrice) {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment amount does not match the order amount.'],
]);
}
@@ -111,11 +111,11 @@ class PaymentCloseReportController
$closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE,
);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'recorded',
]);
} catch (LockTimeoutException) {
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'ignored',
'reason' => 'callback_in_progress',
]);
@@ -33,7 +33,7 @@ class PaymentRetryController
$rateLimitKey = $this->rateLimitKey($request, $oid);
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return ResponseHelper::error('messages.failed', 429, [
return ResponseHelper::error('common.failed', 429, [
'message' => ['Too many NHN KCP payment retry requests. Please try again later.'],
]);
}
@@ -41,13 +41,13 @@ class PaymentRetryController
$order = $this->orderService->findByOrderNumber($oid);
if (! $order) {
return ResponseHelper::error('messages.failed', 404, [
return ResponseHelper::error('common.failed', 404, [
'message' => ['Order not found.'],
]);
}
if (! $this->requestMatchesOrderBuyer($request, $order)) {
return ResponseHelper::error('messages.failed', 403, [
return ResponseHelper::error('common.failed', 403, [
'message' => ['Order buyer verification failed.'],
]);
}
@@ -58,30 +58,30 @@ class PaymentRetryController
'ip' => $request->ip(),
]);
if ($expectedPrice === null) {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment currency is not chargeable.'],
]);
}
if ($price !== $expectedPrice) {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment amount does not match the order amount.'],
]);
}
if ($order->order_status->isBeforePayment()) {
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'ready',
]);
}
if ($this->restoreRetryableKcpOrder($order, $price)) {
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'restored',
]);
}
return ResponseHelper::error('messages.failed', 409, [
return ResponseHelper::error('common.failed', 409, [
'message' => ['Order is not retryable for NHN KCP payment.'],
]);
}
@@ -45,7 +45,7 @@ class AdminEscrowController extends AdminBaseController
->where('ecommerce_order_payments.is_escrow', 1)
->get(['ecommerce_order_payments.id', 'ecommerce_order_payments.transaction_id', 'ecommerce_order_payments.payment_method', 'ecommerce_order_payments.payment_status']);
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'escrow_payments' => $payments->map(fn ($p) => [
'id' => $p->id,
'transaction_id' => $p->transaction_id,
@@ -82,7 +82,7 @@ class AdminEscrowController extends AdminBaseController
try {
$payment = $this->findRegisterableEscrowPayment($validated['tid']);
if (! $payment) {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Escrow payment not found for the requested NicePayments TID.'],
]);
}
@@ -103,7 +103,7 @@ class AdminEscrowController extends AdminBaseController
'tracking_number' => $validated['tracking_number'],
]);
return ResponseHelper::success('messages.success', $result);
return ResponseHelper::success('common.success', $result);
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), 502, null);
}
@@ -46,7 +46,7 @@ class AdminOrderListController extends AdminBaseController
}
}
return ResponseHelper::success('messages.success', $map);
return ResponseHelper::success('common.success', $map);
}
/**
@@ -86,6 +86,6 @@ class AdminOrderListController extends AdminBaseController
];
}
return ResponseHelper::success('messages.success', $map);
return ResponseHelper::success('common.success', $map);
}
}
@@ -21,7 +21,7 @@ class AdminSettingsStatusController
*/
public function testMode(): JsonResponse
{
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'is_test_mode' => (bool) $this->settingsService->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true),
]);
}
@@ -35,7 +35,7 @@ class AdminTransactionController extends AdminBaseController
$tid = trim((string) $request->input('tid', ''));
if ($tid === '') {
return ResponseHelper::error('messages.failed', 422, ['tid' => [__('sirsoft-pay_nicepayments::messages.errors.tid_required')]]);
return ResponseHelper::error('common.failed', 422, ['tid' => [__('sirsoft-pay_nicepayments::messages.errors.tid_required')]]);
}
return $this->queryByTid($tid);
@@ -62,7 +62,7 @@ class AdminTransactionController extends AdminBaseController
->first();
if (!$payment) {
return ResponseHelper::success('messages.success', null);
return ResponseHelper::success('common.success', null);
}
return $this->queryByTid($payment->transaction_id);
@@ -98,13 +98,13 @@ class AdminTransactionController extends AdminBaseController
$result['_is_test_mode'] = (bool) ($meta['is_test_mode'] ?? false);
}
return ResponseHelper::success('messages.success', $result);
return ResponseHelper::success('common.success', $result);
} catch (\Exception $e) {
Log::error('NicePayments queryTransaction failed', [
'tid' => $tid,
'error' => $e->getMessage(),
]);
return ResponseHelper::error('messages.failed', 502, null);
return ResponseHelper::error('common.failed', 502, null);
}
}
}
@@ -58,7 +58,7 @@ class AdminVbankRefundController extends AdminBaseController
if ($tid === '' || $moid === '' || $cancelAmt <= 0
|| $refundAcctNo === '' || $refundBankCd === '' || $refundAcctNm === '') {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => __('sirsoft-pay_nicepayments::messages.errors.vbank_refund_required_fields'),
]);
}
@@ -110,7 +110,7 @@ class AdminVbankRefundController extends AdminBaseController
'cancel_amt' => $cancelAmt,
]);
return ResponseHelper::success('messages.success', $result);
return ResponseHelper::success('common.success', $result);
} catch (\Exception $e) {
Log::error('NicePayments: admin vbank refund failed', [
'tid' => $tid,
@@ -120,7 +120,7 @@ class AdminVbankRefundController extends AdminBaseController
$this->markRefundFailed($claimedPayment, $e, $pgCancelled);
return ResponseHelper::error('messages.failed', 502, null);
return ResponseHelper::error('common.failed', 502, null);
}
}
@@ -245,7 +245,7 @@ class AdminVbankRefundController extends AdminBaseController
private function refundError(string $key, int $status): JsonResponse
{
return ResponseHelper::error('messages.failed', $status, [
return ResponseHelper::error('common.failed', $status, [
'message' => __("sirsoft-pay_nicepayments::messages.errors.{$key}"),
]);
}
@@ -40,7 +40,7 @@ class PaymentCloseReportController
$rateLimitKey = $this->rateLimitKey($request, $oid);
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return ResponseHelper::error('messages.failed', 429, [
return ResponseHelper::error('common.failed', 429, [
'message' => ['Too many NicePayments payment close reports. Please try again later.'],
]);
}
@@ -48,13 +48,13 @@ class PaymentCloseReportController
$order = $this->orderService->findByOrderNumber($oid);
if (! $order) {
return ResponseHelper::error('messages.failed', 404, [
return ResponseHelper::error('common.failed', 404, [
'message' => ['Order not found.'],
]);
}
if (! $this->requestMatchesOrderBuyer($request, $order)) {
return ResponseHelper::error('messages.failed', 403, [
return ResponseHelper::error('common.failed', 403, [
'message' => ['Order buyer verification failed.'],
]);
}
@@ -65,13 +65,13 @@ class PaymentCloseReportController
'ip' => $request->ip(),
]);
if ($expectedPrice === null) {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment currency is not chargeable.'],
]);
}
if ($price !== $expectedPrice) {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment amount does not match the order amount.'],
]);
}
@@ -84,25 +84,25 @@ class PaymentCloseReportController
);
if (($result['status'] ?? null) === 'amount_mismatch') {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment amount does not match the order amount.'],
]);
}
if (($result['status'] ?? null) === 'invalid_payment_currency') {
return ResponseHelper::error('messages.failed', 422, [
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment currency is not chargeable.'],
]);
}
if (($result['status'] ?? null) === 'ignored') {
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'ignored',
'reason' => $result['reason'] ?? 'order_not_payable',
]);
}
return ResponseHelper::success('messages.success', [
return ResponseHelper::success('common.success', [
'status' => 'recorded',
]);
}
@@ -111,9 +111,9 @@ OK
| eventType | body | string | 아니오 | max 64 | 토스 이벤트 종류 (예: `PAYMENT_STATUS_CHANGED`). |
| createdAt | body | string | 아니오 | max 64 | 토스가 이벤트를 생성한 시각. |
| data | body | array | 예 | — | 결제 정보 객체. `data.orderId`(주문번호)와 `data.status`(토스 결제상태)를 읽어 로컬 상태와 대조한다. |
| data.orderId | body | string | 예 | max 100 | <!-- TODO: 용도 --> |
| data.status | body | string | 예 | max 40 | <!-- TODO: 용도 --> |
| data.paymentKey | body | string | 아니오 | max 255 | <!-- TODO: 용도 --> |
| data.orderId | body | string | 예 | max 100 | 주문번호 (G7 `orders.order_number`). 이 값으로 주문·결제 레코드를 조회해 로컬 결제상태를 확인한다. |
| data.status | body | string | 예 | max 40 | 토스 측 결제상태 (예: `DONE`, `CANCELED`, `WAITING_FOR_DEPOSIT`). 로컬 `payments.payment_status` 와 함께 로그에 기록해 불일치를 추적한다. |
| data.paymentKey | body | string | 아니오 | max 255 | 토스 결제 키. 수신만 하며 이 엔드포인트에서 상태 전이에 사용하지 않는다. |
**요청 예시**
@@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
- 사이트 언어를 바꾼 뒤에는 「본인확인 시작」을 눌러도 인증창이 열리지 않던 문제를 수정했습니다. 아무 반응도 오류 안내도 없어 원인을 알기 어려운 증상이었으며, 페이지를 새로 고치면 정상 동작했습니다.
- 본인인증 결과를 조회한 뒤 표시되는 안내 문구가 번역된 문장 대신 내부 식별자가 그대로 보이던 문제를 수정했습니다. 이제 사용 중인 언어에 맞는 안내 문구가 표시됩니다.
## [1.0.1] - 2026-07-14
@@ -38,9 +38,19 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드. 본인확인 record 가 없는 사용자는 `data` 가 `null` 이다(컨트롤러가 `ResponseHelper::success('messages.success', null)` 로 분기)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| method | string | `"KG이니시스 본인확인"` | 본인확인 수단 표시 라벨 (Resource 고정 문자열) |
| verified_at | string\|null | `"2026-07-01 14:22:10"` | 최종 본인확인 시각 (`Y-m-d H:i:s`). 재인증 이력이 있으면 `re_verified_at`, 없으면 최초 `verified_at` |
| name_masked | string | `"홍**"` | 마스킹된 실명 (첫 글자 + 나머지 `*`) |
| birthday_masked | string | `"1990-**-**"` | 마스킹된 생년월일 (연도 4자리만 노출) |
| phone_masked | string | `"010-****-5678"` | 마스킹된 휴대폰 번호 (앞 3자리 + 뒤 4자리) |
| is_adult | boolean | `true` | 성인 여부 (생년월일 기반 만 19세 이상 자동 계산값) |
| is_foreigner | boolean | `false` | 외국인 여부 (이니시스 `isForeign`) |
<!-- 실측 응답에 필드 없음(빈 목록 등) — 데이터가 있는 상태로 재실측하거나 사람이 작성. -->
`di`/`ci`/`ci2` 등 동일인 식별값과 평문 PII 는 응답에 포함하지 않는다. `InicisIdentityResource` 는 `resourceMeta()` 를 spread 하지 않으므로 `is_owner`/`abilities` 메타도 포함되지 않는다.
**응답 예시**
@@ -53,7 +63,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "messages.success",
"message": "성공적으로 처리되었습니다.",
"data": null
}
```
@@ -85,7 +95,7 @@ HTTP/1.1 200
| is_adult | boolean | `true` | 성인 여부(연령 게이팅 판정에 사용). |
| is_foreigner | boolean | `false` | 외국인 여부. |
이 외에 `BaseApiResource` 공통 메타 `is_owner`(항상 본인이므로 `true`) + `abilities` 가 함께 붙는다.
`InicisIdentityResource` 는 `BaseApiResource` 를 상속하지만 `resourceMeta()` 를 spread 하지 않으므로 `is_owner`/`abilities` 메타 필드는 응답에 포함되지 않는다(대상이 항상 본인이며 조회 전용이라 불필요).
- **응답 예시 주의**: 실측 시 샘플 사용자에게 본인인증 record 가 없어 `data: null`(record 없음) 응답만 관측된다. 아래 두 응답 예시 중 "record 존재 시" 는 `InicisIdentityResource` 구조 기준 정적 작성이다. 실제 record 가 있는 사용자로 실측하면 마스킹된 본인확인 정보가 채워진다.
**응답 예시** (record 존재 시 — 정적, `InicisIdentityResource` 구조 기준)
@@ -93,6 +103,7 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "성공적으로 처리되었습니다.",
"data": {
"method": "KG이니시스 본인확인",
"verified_at": "2026-07-01 14:22:10",
@@ -100,12 +111,8 @@ HTTP/1.1 200
"birthday_masked": "1990-**-**",
"phone_masked": "010-****-5678",
"is_adult": true,
"is_foreigner": false,
"is_owner": true,
"abilities": {}
},
"message": "성공적으로 처리되었습니다.",
"error": null
"is_foreigner": false
}
}
```
@@ -114,9 +121,8 @@ HTTP/1.1 200
```json
{
"success": true,
"data": null,
"message": "성공적으로 처리되었습니다.",
"error": null
"data": null
}
```
@@ -37,19 +37,55 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-302 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON 봉투를 반환하지 않습니다. 이니시스 STEP2 콜백을 처리한 뒤 `302 Found` 로 popup-bridge 페이지(`GET /plugins/sirsoft-verification_kginicis/plugin/inicis/popup-bridge`)로 redirect 하며, 처리 결과는 아래 query string 으로 전달됩니다 (`InicisCallbackOutcome::toBridgeQuery()`)._
| 필드 (query) | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| verification_token | string | `9f1c...` (1회용 토큰) | 성공 시에만 부착. 코어 verify 결과 claims 의 `verification_token` — 후속 폼 제출에 첨부해 본인인증 완료를 증명 |
| challenge_id | string (uuid) | `1f2a3b4c-...` | 성공 시에만 부착. mTxId 로 매칭된 본인인증 challenge UUID |
| identity_error | string | `NOT_FOUND` | 실패 시에만 부착. 실패 코드 (아래 에러 응답 표 참조) |
**응답 예시**
<!-- 실측 제외: http-302 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 302 Found
Location: https://api.example.com/plugins/sirsoft-verification_kginicis/plugin/inicis/popup-bridge?verification_token=9f1c8a2e6b5d4c3f&challenge_id=1f2a3b4c-5d6e-7f80-9a1b-2c3d4e5f6a7b
```
실패 시:
```http
HTTP/1.1 302 Found
Location: https://api.example.com/plugins/sirsoft-verification_kginicis/plugin/inicis/popup-bridge?identity_error=NOT_FOUND
```
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
이 엔드포인트는 HTTP 에러 상태코드를 반환하지 않고, 항상 `302` 로 redirect 하면서 `identity_error` query 에 실패 코드를 실어 보냅니다 (`InicisCallbackResolver::resolve()`).
| identity_error | 발생 조건 |
| --- | --- |
| 이니시스 `resultCode` 원문 (예: `9999`) | 콜백의 `resultCode` 가 `0000` 이 아닌 경우 — 이니시스가 보고한 실패 코드를 그대로 전달 |
| `PROVIDER_ERROR` | `resultCode` 가 `0000` 이 아니고 값도 비어 있는 경우 |
| `INVALID_AUTH_URL` | `authRequestUrl` 이 허용된 이니시스 도메인이 아닌 경우 (위조 도메인 차단) |
| `REMOTE_CALL_FAILED` | STEP3(`authRequestUrl`) 호출이 통신 오류/비정상 응답으로 실패한 경우 |
| `DECRYPT_FAILED` | STEP3 응답의 SEED 암호화 필드 복호화에 실패한 경우 |
| `NOT_FOUND` | STEP3 응답의 `mTxId` 로 challenge 매핑을 찾지 못한 경우 |
| `ALREADY_CONSUMED` | 해당 challenge 가 이미 소비(사용)된 경우 |
| 코어 verify 실패 코드 / `UNKNOWN` | 코어 `IdentityVerificationService::handleProviderCallback()` 이 실패를 반환한 경우 (`failureCode` 그대로, 없으면 `UNKNOWN`) |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명**
이니시스 본인인증 매뉴얼 STEP2 콜백 수신 엔드포인트입니다. 이니시스 인증창이 인증을 마치면 이 URL 로 form POST 를 보내며(`resultCode`, `authRequestUrl`, `txId`, `token` 등), 컨트롤러는 이를 `InicisCallbackResolver` 에 그대로 위임합니다. Resolver 는 STEP3(`authRequestUrl`) 를 호출해 인증 결과를 받아 SEED 복호화하고, 응답의 `mTxId` 로 challenge 를 매칭한 뒤 코어 IDV Service 에 verify 를 위임합니다.
주의사항:
- 외부 PG 가 호출하므로 CSRF 검증이 면제되어 있고, 인증(로그인)도 필요하지 않습니다.
- 이니시스가 challenge_id 를 콜백에 echo 하지 않기 때문에 코어 표준 콜백 라우트(`POST /api/identity/callback/{providerId}`)를 사용하지 않고 이 전용 라우트를 둡니다. challenge 매칭은 `mTxId` ↔ 매핑 테이블로 수행합니다.
- 외부 PG 가 보내는 임의 필드를 차단하지 않기 위해 FormRequest 검증을 두지 않습니다 (raw form POST 를 그대로 전달).
- 사용자 브라우저가 직접 따라가는 redirect 이므로 응답은 항상 302 이며, 실패도 HTTP 에러가 아닌 `identity_error` query 로 표현됩니다.
### GET /plugins/sirsoft-verification_kginicis/plugin/inicis/popup-bridge
@@ -72,18 +108,60 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON 봉투(`data`)를 반환하지 않습니다. `200 OK` + `Content-Type: text/html; charset=UTF-8` 로 브라우저에서 실행되는 bridge HTML 페이지를 반환합니다. 페이지 스크립트에 주입되는 payload 필드는 다음과 같습니다._
| 필드 (script payload) | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| verification_token | string | `9f1c8a2e6b5d4c3f` | 성공 토큰. 값이 없으면 빈 문자열(`""`) |
| challenge_id | string (uuid) | `1f2a3b4c-5d6e-7f80-9a1b-2c3d4e5f6a7b` | challenge UUID. 값이 없으면 빈 문자열(`""`) |
| identity_error | string | `NOT_FOUND` | 실패 코드. 값이 없으면 빈 문자열(`""`) |
**응답 예시**
<!-- 실측 제외: http-200 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
```
```html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>본인인증 결과</title>
</head>
<body>
<script>
(function () {
var REDIRECT_STASH_KEY = 'g7.identity.redirectStash';
var payload = {"verification_token":"9f1c8a2e6b5d4c3f","challenge_id":"1f2a3b4c-5d6e-7f80-9a1b-2c3d4e5f6a7b","identity_error":""};
// 데스크톱: 부모창 postMessage({ type: 'identity_result', ... }) + window.close()
// 모바일: sessionStorage 의 redirectStash 복원 후 return_url 로 이동
})();
</script>
</body>
</html>
```
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
_에러 응답 없음 — 인증/검증이 없고 query 파라미터가 모두 선택이므로, 어떤 요청에도 `200 OK` + bridge HTML 을 반환합니다. 본인인증 실패는 HTTP 에러가 아니라 `identity_error` query 로 전달되어 페이지 스크립트가 처리합니다._
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명**
이니시스 본인인증 매뉴얼 STEP4 결과 전달 페이지입니다. callback 컨트롤러가 302 redirect 로 넘긴 query(`verification_token` / `challenge_id` / `identity_error`)를 받아, 사용자 브라우저 환경에 따라 두 가지로 분기하는 HTML 을 반환합니다.
- 데스크톱(팝업, `window.opener` 존재): 부모창에 `postMessage({ type: 'identity_result', verification_token, challenge_id, identity_error })` 를 same-origin 으로 전송한 뒤 팝업을 닫습니다.
- 모바일(`window.opener` 부재): `sessionStorage` 의 `g7.identity.redirectStash` 에 저장된 `return_url` 을 복원하고, 성공이면 `verification_token`, 실패면 `identity_error` 를 query 로 붙여 원래 페이지로 이동합니다. stash 가 없으면 `/` 로 이동합니다.
주의사항:
- 페이지에 주입되는 payload 는 `JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT` 로 인코딩되어 script 컨텍스트에서 안전합니다.
- `sessionStorage` 키는 코어 IDV launcher 의 `IDENTITY_REDIRECT_STASH_KEY` 와 동일해야 복원이 동작합니다 (폼 값 복원인 formStash 처리는 launcher 책임).
- 이 URL 은 사용자가 직접 호출하는 API 가 아니라 callback 의 redirect 목적지입니다.
@@ -38,11 +38,11 @@ class MyInicisIdentityShowController extends AuthBaseController
$record = $this->cardService->findForUser((int) Auth::id());
if ($record === null) {
return ResponseHelper::success('messages.success', null);
return ResponseHelper::success('common.success', null);
}
return ResponseHelper::success(
'messages.success',
'common.success',
(new InicisIdentityResource($record))->resolve(),
);
}