From 6c8381f164010727bb5535a27ece7e5c100e312f Mon Sep 17 00:00:00 2001 From: HeuJung Date: Wed, 15 Jul 2026 09:52:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(core,board,ecommerce,plugins):=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=AF=B8=EC=B1=84=EC=9B=80=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=20=EC=A0=84=EC=88=98=20=EC=B1=84=EC=9B=80=20+=20?= =?UTF-8?q?=EB=AF=B8=ED=95=B4=EC=84=9D=20lang=20=ED=82=A4=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95=20+=20IDV=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=EB=8F=84?= =?UTF-8?q?=ED=9A=9F=EC=88=98=20=EB=B9=84=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 본연: 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. --- CHANGELOG.md | 1 + app/Console/Commands/ApiDocgenCommand.php | 137 +- app/Helpers/ResponseHelper.php | 36 +- .../Identity/AdminIdentityLogController.php | 7 +- .../AdminIdentityPolicyController.php | 22 +- .../AdminIdentityProviderController.php | 8 +- .../Api/Admin/PluginSettingsController.php | 4 +- .../Api/Admin/SeoCacheController.php | 18 +- .../IdentityVerificationController.php | 22 +- .../AdminIdentityProviderIndexRequest.php | 38 + app/Services/IdentityVerificationService.php | 3 + app/Support/ApiDoc/ApiDocScaffolder.php | 108 +- app/Support/ApiDoc/ParameterDescriber.php | 173 ++ app/Support/ApiDoc/ResourceFieldDescriber.php | 107 + docs/backend/api-documentation.md | 27 +- docs/backend/api/activity-logs.md | 26 +- docs/backend/api/attachment.md | 36 +- docs/backend/api/attachments.md | 167 +- docs/backend/api/auth.md | 333 ++- docs/backend/api/avatar.md | 31 +- docs/backend/api/broadcasting.md | 13 +- docs/backend/api/core-update.md | 24 +- docs/backend/api/dashboard.md | 54 +- docs/backend/api/extensions.md | 45 +- docs/backend/api/identity.md | 696 +++++- docs/backend/api/language-packs.md | 790 ++++++- docs/backend/api/layouts.md | 138 +- docs/backend/api/locales.md | 2 +- docs/backend/api/me.md | 86 +- docs/backend/api/menus.md | 179 +- docs/backend/api/modules.md | 874 +++++++- docs/backend/api/notification-definitions.md | 88 +- docs/backend/api/notification-logs.md | 24 +- docs/backend/api/notification-templates.md | 78 +- docs/backend/api/notifications.md | 196 +- docs/backend/api/password.md | 10 +- docs/backend/api/plugins.md | 877 +++++++- docs/backend/api/profile.md | 249 ++- docs/backend/api/roles.md | 2 +- docs/backend/api/schedules.md | 104 +- docs/backend/api/seo.md | 48 +- docs/backend/api/settings.md | 431 ++-- docs/backend/api/templates.md | 1949 +++++++++++++++-- docs/backend/api/users.md | 295 ++- docs/backend/api/verify-password.md | 13 +- lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 2 + .../_bundled/g7-core-ja/backend/ja/common.php | 3 + .../g7-core-ja/backend/ja/identity.php | 1 + .../CHANGELOG.md | 1 + .../backend/ja/messages.php | 12 +- lang/en/common.php | 3 + lang/en/identity.php | 1 + lang/ko/common.php | 3 + lang/ko/identity.php | 1 + .../gnuboard7-hello_module/docs/api/memos.md | 76 +- .../sirsoft-board/docs/api/board-types.md | 59 +- .../_bundled/sirsoft-board/docs/api/board.md | 594 ++++- .../_bundled/sirsoft-board/docs/api/boards.md | 1060 ++++++++- .../sirsoft-board/docs/api/reports.md | 175 +- .../sirsoft-board/docs/api/settings.md | 155 +- .../Http/Controllers/Admin/PostController.php | 19 +- .../Requests/Admin/PostFormDataRequest.php | 37 + .../Requests/Admin/PostFormMetaRequest.php | 34 + .../Http/Requests/Admin/PostIndexRequest.php | 38 + .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 1 + .../sirsoft-ecommerce/docs/api/addresses.md | 4 +- .../sirsoft-ecommerce/docs/api/brands.md | 106 +- .../sirsoft-ecommerce/docs/api/cart.md | 336 ++- .../sirsoft-ecommerce/docs/api/categories.md | 283 ++- .../docs/api/category-image.md | 30 +- .../sirsoft-ecommerce/docs/api/checkout.md | 396 +++- .../docs/api/claim-reasons.md | 107 +- .../sirsoft-ecommerce/docs/api/coupons.md | 42 +- .../sirsoft-ecommerce/docs/api/currency.md | 23 +- .../docs/api/extra-fee-templates.md | 175 +- .../sirsoft-ecommerce/docs/api/guest.md | 320 ++- .../sirsoft-ecommerce/docs/api/inquiries.md | 185 +- .../docs/api/mileage-transactions.md | 237 +- .../sirsoft-ecommerce/docs/api/mileage.md | 22 +- .../sirsoft-ecommerce/docs/api/options.md | 83 +- .../sirsoft-ecommerce/docs/api/orders.md | 834 ++++++- .../sirsoft-ecommerce/docs/api/payments.md | 85 +- .../sirsoft-ecommerce/docs/api/presets.md | 137 +- .../docs/api/product-common-infos.md | 112 +- .../docs/api/product-image.md | 32 +- .../docs/api/product-labels.md | 86 +- .../docs/api/product-notice-templates.md | 110 +- .../sirsoft-ecommerce/docs/api/products.md | 703 +++++- .../docs/api/promotion-coupons.md | 417 +++- .../docs/api/review-image.md | 30 +- .../sirsoft-ecommerce/docs/api/reviews.md | 229 +- .../sirsoft-ecommerce/docs/api/settings.md | 186 +- .../docs/api/shipping-carriers.md | 102 +- .../docs/api/shipping-policies.md | 215 +- .../sirsoft-ecommerce/docs/api/users.md | 23 +- .../sirsoft-ecommerce/docs/api/wishlist.md | 77 +- .../src/lang/en/messages.php | 12 +- .../src/lang/ko/messages.php | 12 +- .../sirsoft-page/docs/api/attachments.md | 67 +- .../_bundled/sirsoft-page/docs/api/pages.md | 376 +++- .../sirsoft-ckeditor5/docs/api/images.md | 33 +- .../sirsoft-ckeditor5/docs/api/upload.md | 22 +- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 4 + .../sirsoft-gdpr/docs/api/consent-log.md | 2 +- .../_bundled/sirsoft-gdpr/docs/api/consent.md | 71 +- .../sirsoft-gdpr/docs/api/policy-versions.md | 6 +- .../sirsoft-gdpr/docs/api/settings.md | 72 +- .../Admin/GdprAdminConsentLogController.php | 2 +- .../GdprAdminPolicyVersionController.php | 6 +- .../Admin/GdprAdminSettingsController.php | 2 +- .../Public/GdprCookieConsentController.php | 2 +- .../Public/GdprSettingsController.php | 2 +- .../User/GdprConsentController.php | 4 +- .../_bundled/sirsoft-marketing/CHANGELOG.md | 4 + .../sirsoft-marketing/docs/api/channels.md | 38 +- .../sirsoft-marketing/docs/api/settings.md | 4 +- .../MarketingSettingsController.php | 2 +- .../sirsoft-pay_kginicis/CHANGELOG.md | 2 +- .../sirsoft-pay_kginicis/docs/api/cbt.md | 2 +- .../sirsoft-pay_kginicis/docs/api/orders.md | 335 ++- .../sirsoft-pay_kginicis/docs/api/payment.md | 395 +++- .../docs/api/transaction.md | 110 +- .../AdminCbtCvsOperationsController.php | 2 +- .../AdminCbtReconciliationController.php | 2 +- .../AdminCbtTestProductController.php | 42 +- .../AdminEscrowDeliveryController.php | 16 +- .../AdminEscrowDenyConfirmController.php | 10 +- .../Controllers/AdminOrderListController.php | 2 +- .../AdminSettingsStatusController.php | 2 +- .../AdminTransactionController.php | 10 +- .../PaymentCloseReportController.php | 20 +- .../AdminEscrowDeliveryController.php | 14 +- .../Controllers/AdminOrderListController.php | 4 +- .../AdminSettingsStatusController.php | 2 +- .../AdminTransactionController.php | 4 +- .../PaymentCloseReportController.php | 18 +- .../Controllers/PaymentRetryController.php | 16 +- .../src/Controllers/AdminEscrowController.php | 6 +- .../Controllers/AdminOrderListController.php | 4 +- .../AdminSettingsStatusController.php | 2 +- .../AdminTransactionController.php | 8 +- .../AdminVbankRefundController.php | 8 +- .../PaymentCloseReportController.php | 18 +- .../sirsoft-tosspayments/docs/api/webhook.md | 6 +- .../CHANGELOG.md | 1 + .../docs/api/identity.md | 28 +- .../docs/api/plugin.md | 94 +- .../MyInicisIdentityShowController.php | 4 +- .../Identity/IdentityChallengeShowTest.php | 12 +- .../Feature/Console/ApiDocgenCommandTest.php | 48 + tests/Unit/Helpers/ResponseHelperTest.php | 74 +- .../Support/ApiDoc/ApiDocPipelineTest.php | 241 +- .../Support/ApiDoc/ParameterDescriberTest.php | 68 + .../ApiDoc/ResourceFieldDescriberTest.php | 37 + ...ntity-challenge-status-field-exposure.yaml | 40 + 155 files changed, 17192 insertions(+), 1829 deletions(-) create mode 100644 app/Http/Requests/Identity/AdminIdentityProviderIndexRequest.php create mode 100644 modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostFormDataRequest.php create mode 100644 modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostFormMetaRequest.php create mode 100644 modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostIndexRequest.php create mode 100644 tests/scenarios/identity-challenge-status-field-exposure.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index d4cf3f1f..4853f747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,7 @@ #### 안내 문구·주소 처리 - 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다. +- 회원 정보 수정, 권한·메뉴 설정, 본인인증, 알림 설정 등 여러 화면에서 처리를 마친 뒤 표시되는 안내 문구가 번역된 문장 대신 내부 식별자가 그대로 보이던 문제를 수정했습니다. 이제 성공·실패·권한 없음 등 모든 안내가 사용 중인 언어에 맞는 문장으로 표시됩니다. - 환경설정 저장이 실패했을 때 안내 문구에 항목 이름이 `security.password min length` 같은 내부 식별자로 표시되던 문제를 수정했습니다. 보안·업로드·SEO·고급·드라이버·메일 등 모든 설정 항목이 화면에 보이는 한글 항목명으로 안내됩니다. - 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다. - 목록의 열 제목을 눌러 정렬할 때, 이미 다른 열로 정렬 중이었다면 화살표는 새로 누른 열에 표시되면서 실제 정렬은 이전 열 기준으로 바뀌던 문제를 수정했습니다. 표시와 실제 정렬 기준이 어긋나 결과를 신뢰할 수 없었으며, 이제 누른 열 기준으로 한 번에 정렬됩니다. diff --git a/app/Console/Commands/ApiDocgenCommand.php b/app/Console/Commands/ApiDocgenCommand.php index 700cf915..53c07d72 100644 --- a/app/Console/Commands/ApiDocgenCommand.php +++ b/app/Console/Commands/ApiDocgenCommand.php @@ -126,6 +126,7 @@ class ApiDocgenCommand extends Command $stats = ['files' => 0, 'endpoints' => 0, 'probed' => 0, 'skipped' => 0, 'examples' => 0]; $checkFindings = []; + $skippedProbes = []; $examplesOnly = (bool) $this->option('examples-only'); // 대상(코어/확장)별 README 목차 항목 누적: readmeKey => ['label' => ..., 'entries' => [...]] @@ -136,6 +137,17 @@ class ApiDocgenCommand extends Command $sectionKeys = []; $exampleBlocks = []; + // `Route::match(['get','post'], ...)->name('x')` 는 하나의 라우트명으로 두 엔드포인트를 + // 등록한다. 생성 블록 키가 라우트명뿐이면 한 문서에 같은 키가 둘 생겨, 블록 조회(strpos)가 + // 언제나 첫 블록만 잡아 두 번째 블록의 사람 서술이 재생성마다 유실된다. + // 문서 안에서 중복되는 라우트명만 골라 키에 메서드를 붙인다 (나머지 키는 그대로 — 전 문서의 + // 키를 바꾸면 기존 블록과 대조가 어긋나 사람 서술이 통째로 사라진다). + $nameCounts = array_count_values(array_map( + static fn (array $r): string => $r['name'] ?: $r['uri'], + $items, + )); + $duplicatedNames = array_keys(array_filter($nameCounts, static fn (int $n): bool => $n > 1)); + // 목차용 도메인 파일 항목 (소유별). 예시-only/check 모드에서도 목차는 최신화한다. $owner = $items[0]['owner']; $readmeKey = $this->readmeFile($owner); @@ -156,10 +168,14 @@ class ApiDocgenCommand extends Command $stats['probed']++; } else { $stats['skipped']++; - $checkFindings[] = "{$route['method']} {$route['uri']} — {$probeMeta['skipped_reason']}"; + // 실측 제외는 설계상 정상이다 — 부수효과 쓰기·미치환 path·검증 실패는 의도적으로 + // 호출하지 않고, 그 자리는 사람이 코드 근거로 채운다. drift 로 세면 문서가 완전해도 + // --check 가 통과할 수 없어 기준이 무의미해진다. 정보로만 남긴다. + $skippedProbes[] = "{$route['method']} {$route['uri']} — {$probeMeta['skipped_reason']}"; } - $key = $route['name'] ?: $route['uri']; + $methodScopedKey = in_array($route['name'] ?: $route['uri'], $duplicatedNames, true); + $key = $scaffolder->generatedKey($route, $methodScopedKey); if ($examplesOnly) { // 표·서술을 건드리지 않고 예시 2블록만 산출 (SSoT=스캐폴더 예시 메서드). @@ -179,7 +195,7 @@ class ApiDocgenCommand extends Command } $commentMap = $this->columnComments($route, $commentResolver, $sampleMap); - $sections[] = $scaffolder->endpointSection($route, $request, $schema, $probeMeta, $commentMap); + $sections[] = $scaffolder->endpointSection($route, $request, $schema, $probeMeta, $commentMap, $methodScopedKey); $sectionKeys[] = $key; $stats['endpoints']++; } @@ -187,6 +203,12 @@ class ApiDocgenCommand extends Command if ($this->option('check')) { if (! File::exists($file)) { $checkFindings[] = "문서 파일 없음: {$file}"; + } else { + // 실측 불가 자리를 사람이 채우지 않고 마커로 남겨 두면 문서가 미완이다. + // (규정: docs/backend/api-documentation.md "미채움 마커 5종") + foreach ($this->unfilledMarkers(File::get($file)) as $marker) { + $checkFindings[] = "미채움 마커: {$file} — {$marker}"; + } } continue; @@ -216,6 +238,15 @@ class ApiDocgenCommand extends Command $header = $this->documentHeader($file, $items[0]); $existing = File::exists($file) ? File::get($file) : null; + + // 기존 문서는 중복 라우트명 블록을 구 키(라우트명만)로 갖고 있다. 그대로 두면 새 키 + // (라우트명::메서드)와 대조되지 않아 이번 재생성에서 그 블록의 사람 서술이 유실된다. + // 병합 전에 구 키를 등장 순서대로 새 키로 승격한다 — 문서 내 블록 순서는 $items 순서와 + // 같으므로(같은 그룹을 같은 순서로 순회) 메서드 대응이 어긋나지 않는다. + if ($existing !== null && $duplicatedNames !== []) { + $existing = $this->migrateDuplicateBlockKeys($existing, $items, $duplicatedNames, $scaffolder); + } + $content = $scaffolder->mergeDocument($existing, $header, $sections, $sectionKeys); File::ensureDirectoryExists(dirname($file)); @@ -253,8 +284,20 @@ class ApiDocgenCommand extends Command } if ($this->option('check')) { + // 실측 제외는 정상 동작이므로 참고 정보로만 표시한다 (drift 아님). + if ($skippedProbes !== []) { + $this->line('실측 제외 '.count($skippedProbes).'건 (정상 — 해당 자리는 사람이 작성):'); + foreach (array_slice($skippedProbes, 0, 10) as $s) { + $this->line(' · '.$s); + } + if (count($skippedProbes) > 10) { + $this->line(' · ... 외 '.(count($skippedProbes) - 10).'건'); + } + $this->newLine(); + } + if ($checkFindings !== []) { - $this->warn('실측 제외/문서 누락 '.count($checkFindings).'건:'); + $this->warn('drift '.count($checkFindings).'건:'); foreach (array_slice($checkFindings, 0, 50) as $f) { $this->line(' - '.$f); } @@ -899,6 +942,92 @@ class ApiDocgenCommand extends Command return base_path("{$base}/_bundled/{$owner['id']}/docs/api/README.md"); } + /** + * 문서에 남아 있는 미채움 마커를 수집합니다. + * + * `api:docgen` 은 실측 불가한 자리에 "사람이 작성하세요" 마커를 남긴다. 실측 불가는 그 자리를 + * 비워둘 사유가 아니며, 컨트롤러/Resource/FormRequest/Enum/lang 을 읽어 채우는 것이 규정이다 + * (docs/backend/api-documentation.md "미채움 마커 5종"). 마커가 남아 있으면 문서는 미완이다. + * + * 잔량 전수 집계는 `check-api-doc-unfilled.cjs` 가 담당하고, 여기서는 `--check` 가 자기 scope + * 문서의 미완을 drift 로 잡기 위해 마커 종류만 요약한다. + * + * @param string $content 문서 내용 + * @return array 발견된 마커 요약 (종류 => 건수) + */ + private function unfilledMarkers(string $content): array + { + $markers = [ + '실측 제외:' => '실측 제외 (응답 필드 표 / 응답 예시)', + 'TODO: 용도' => '요청 파라미터 설명', + 'TODO: 설명' => '응답 필드 설명', + '실측 응답에 필드 없음' => '빈 목록 관측 (응답 필드 표)', + '대표 에러 없음' => '도메인 특이 에러', + ]; + + $found = []; + + foreach ($markers as $needle => $label) { + $count = substr_count($content, $needle); + + if ($count > 0) { + $found[] = "{$label} {$count}건"; + } + } + + return $found; + } + + /** + * 기존 문서의 중복 생성 블록 키를 메서드 스코프 키로 승격합니다. + * + * `Route::match(['get','post'], ...)->name('x')` 로 등록된 라우트는 한 문서에 같은 + * `@generated:start:x` 블록을 둘 만든다. 새 키(`x::get` / `x::post`)로 재생성하면 구 키를 + * 가진 기존 블록과 대조되지 않아 그 블록의 사람 서술이 유실되므로, 병합 전에 구 키를 + * 등장 순서대로 새 키로 바꿔 준다. + * + * 문서 내 블록 순서는 `$items` 순서와 같다(같은 그룹을 같은 순서로 순회해 생성했다). + * 따라서 n 번째 구 키 블록에는 그 라우트명을 가진 n 번째 라우트의 메서드를 붙인다. + * + * @param string $existing 기존 문서 내용 + * @param array> $items 이 문서의 라우트 목록 (문서 순서) + * @param array $duplicatedNames 문서 안에서 중복되는 라우트명 + * @param ApiDocScaffolder $scaffolder 키 생성기 + * @return string 키가 승격된 문서 내용 + */ + private function migrateDuplicateBlockKeys( + string $existing, + array $items, + array $duplicatedNames, + ApiDocScaffolder $scaffolder + ): string { + foreach ($duplicatedNames as $name) { + $methods = array_values(array_map( + static fn (array $r): string => $r['method'], + array_filter($items, static fn (array $r): bool => ($r['name'] ?: $r['uri']) === $name), + )); + + $marker = ''; + $offset = 0; + + foreach ($methods as $method) { + $pos = strpos($existing, $marker, $offset); + + if ($pos === false) { + break; + } + + $newKey = $scaffolder->generatedKey(['name' => $name, 'uri' => $name, 'method' => $method], true); + $newMarker = ''; + + $existing = substr_replace($existing, $newMarker, $pos, strlen($marker)); + $offset = $pos + strlen($newMarker); + } + } + + return $existing; + } + /** * 문서 헤더(제목 + TL;DR)를 생성합니다. * diff --git a/app/Helpers/ResponseHelper.php b/app/Helpers/ResponseHelper.php index 8c062723..77283cac 100644 --- a/app/Helpers/ResponseHelper.php +++ b/app/Helpers/ResponseHelper.php @@ -21,7 +21,7 @@ class ResponseHelper /** * 성공 응답을 생성합니다. * - * @param string $messageKey 메시지 키 (기본값: 'messages.success') + * @param string $messageKey 메시지 키 (기본값: 'common.success') * @param mixed $data 응답 데이터 * @param int $statusCode HTTP 상태 코드 (기본값: 200) * @param array $messageParams 메시지 매개변수 @@ -29,7 +29,7 @@ class ResponseHelper * @return JsonResponse JSON 응답 */ public static function success( - string $messageKey = 'messages.success', + string $messageKey = 'common.success', mixed $data = null, int $statusCode = 200, array $messageParams = [], @@ -45,7 +45,7 @@ class ResponseHelper /** * 실패 응답을 생성합니다. * - * @param string $messageKey 메시지 키 (기본값: 'messages.failed') + * @param string $messageKey 메시지 키 (기본값: 'common.failed') * @param int $statusCode HTTP 상태 코드 (기본값: 400) * @param mixed $errors 오류 정보 * @param array $messageParams 메시지 매개변수 @@ -53,7 +53,7 @@ class ResponseHelper * @return JsonResponse JSON 응답 */ public static function error( - string $messageKey = 'messages.failed', + string $messageKey = 'common.failed', int $statusCode = 400, mixed $errors = null, array $messageParams = [], @@ -87,14 +87,14 @@ class ResponseHelper * 입력 검증 실패 응답을 생성합니다. * * @param mixed $errors 검증 오류 정보 - * @param string $messageKey 메시지 키 (기본값: 'messages.validation_failed') + * @param string $messageKey 메시지 키 (기본값: 'common.validation_failed') * @param array $messageParams 메시지 매개변수 * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 422 상태 코드를 가진 JSON 응답 */ public static function validationError( mixed $errors, - string $messageKey = 'messages.validation_failed', + string $messageKey = 'common.validation_failed', array $messageParams = [], string $domain = 'core' ): JsonResponse { @@ -108,13 +108,13 @@ class ResponseHelper /** * 인증 실패 응답을 생성합니다. * - * @param string $messageKey 메시지 키 (기본값: 'messages.unauthorized') + * @param string $messageKey 메시지 키 (기본값: 'common.unauthorized') * @param array $messageParams 메시지 매개변수 * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 401 상태 코드를 가진 JSON 응답 */ public static function unauthorized( - string $messageKey = 'messages.unauthorized', + string $messageKey = 'common.unauthorized', array $messageParams = [], string $domain = 'core' ): JsonResponse { @@ -127,13 +127,13 @@ class ResponseHelper /** * 권한 부족 응답을 생성합니다. * - * @param string $messageKey 멤시지 키 (기본값: 'messages.forbidden') + * @param string $messageKey 멤시지 키 (기본값: 'common.forbidden') * @param array $messageParams 멤시지 매개변수 * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 403 상태 코드를 가진 JSON 응답 */ public static function forbidden( - string $messageKey = 'messages.forbidden', + string $messageKey = 'common.forbidden', array $messageParams = [], string $domain = 'core' ): JsonResponse { @@ -146,13 +146,13 @@ class ResponseHelper /** * 리소스를 찾을 수 없음 응답을 생성합니다. * - * @param string $messageKey 멤시지 키 (기본값: 'messages.not_found') + * @param string $messageKey 멤시지 키 (기본값: 'common.not_found') * @param array $messageParams 멤시지 매개변수 * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 404 상태 코드를 가진 JSON 응답 */ public static function notFound( - string $messageKey = 'messages.not_found', + string $messageKey = 'common.not_found', array $messageParams = [], string $domain = 'core' ): JsonResponse { @@ -165,14 +165,14 @@ class ResponseHelper /** * 서버 내부 오류 응답을 생성합니다. * - * @param string $messageKey 멤시지 키 (기본값: 'messages.error_occurred') + * @param string $messageKey 멤시지 키 (기본값: 'common.error_occurred') * @param mixed $error 오류 정보 (디버그 모드에서만 표시) * @param array $messageParams 멤시지 매개변수 * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 500 상태 코드를 가진 JSON 응답 */ public static function serverError( - string $messageKey = 'messages.error_occurred', + string $messageKey = 'common.error_occurred', mixed $error = null, array $messageParams = [], string $domain = 'core' @@ -341,7 +341,7 @@ class ResponseHelper * (색인 누락 안내)가 사라지면 검색이 0건을 돌려주는 사실을 운영자가 알 방법이 없습니다. * `success`/`message`/`data` 키는 부가 데이터로 덮어쓰지 않습니다. * - * @param string $messageKey 멤시지 키 (기본값: 'messages.success') + * @param string $messageKey 메시지 키 (기본값: 'common.success') * @param JsonResource|ResourceCollection|null $resource JSON 리소스 * @param int $statusCode HTTP 상태 코드 (기본값: 200) * @param array $messageParams 멤시지 매개변수 @@ -349,7 +349,7 @@ class ResponseHelper * @return JsonResponse JSON 응답 */ public static function successWithResource( - string $messageKey = 'messages.success', + string $messageKey = 'common.success', JsonResource|ResourceCollection|null $resource = null, int $statusCode = 200, array $messageParams = [], @@ -415,14 +415,14 @@ class ResponseHelper /** * 페이지네이션된 리소스 응답을 생성합니다. * - * @param string $messageKey 멤시지 키 (기본값: 'messages.success') + * @param string $messageKey 메시지 키 (기본값: 'common.success') * @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션 * @param array $messageParams 멤시지 매개변수 * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 페이지네이션 메타 데이터를 포함한 JSON 응답 */ public static function successWithPagination( - string $messageKey = 'messages.success', + string $messageKey = 'common.success', ?ResourceCollection $collection = null, array $messageParams = [], string $domain = 'core' diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php index 59c00b67..07e2b7ee 100644 --- a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php +++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\Admin\Identity; +use App\Enums\PermissionType; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Requests\Identity\AdminIdentityLogIndexRequest; use App\Http\Requests\Identity\AdminIdentityLogPurgeRequest; @@ -45,7 +46,7 @@ class AdminIdentityLogController extends AdminBaseController $paginated = $this->logService->search($filters, (int) ($validated['per_page'] ?? 20)); - return $this->success('messages.success', [ + return $this->success('common.success', [ 'data' => IdentityLogResource::collection(collect($paginated->items()))->resolve(), 'pagination' => [ 'current_page' => $paginated->currentPage(), @@ -59,7 +60,7 @@ class AdminIdentityLogController extends AdminBaseController 'abilities' => [ 'can_purge' => $request->user()?->hasPermission( 'core.admin.identity.logs.purge', - \App\Enums\PermissionType::Admin, + PermissionType::Admin, ) ?? false, ], ]); @@ -76,7 +77,7 @@ class AdminIdentityLogController extends AdminBaseController $days = (int) ($request->validated()['older_than_days'] ?? 180); $count = $this->logService->purge($days); - return $this->success('messages.success', [ + return $this->success('common.success', [ 'purged_count' => $count, 'older_than_days' => $days, ]); diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php index 277c8886..7df11c8a 100644 --- a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php +++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php @@ -60,7 +60,7 @@ class AdminIdentityPolicyController extends AdminBaseController $paginated = $this->policyService->search($filters, $perPage); $collection = (new PolicyCollection($paginated))->toArray($request); - return $this->success('messages.success', [ + return $this->success('common.success', [ 'data' => $collection['data'], 'abilities' => $collection['abilities'] ?? [], 'meta' => [ @@ -83,7 +83,7 @@ class AdminIdentityPolicyController extends AdminBaseController $policy = $this->policyService->createAdminPolicy($request->validated()); return $this->success( - 'messages.created', + 'common.created', (new PolicyResource($policy))->toArray($request), 201, ); @@ -101,7 +101,7 @@ class AdminIdentityPolicyController extends AdminBaseController $policy = $this->policyService->findById($id); if (! $policy) { - return $this->error('messages.not_found', 404); + return $this->error('common.not_found', 404); } $validated = $request->validated(); @@ -119,13 +119,13 @@ class AdminIdentityPolicyController extends AdminBaseController } if (! $this->policyService->updatePolicy($policy, $validated)) { - return $this->error('messages.failed', 500); + return $this->error('common.failed', 500); } $policy->refresh(); return $this->success( - 'messages.updated', + 'common.updated', (new PolicyResource($policy))->toArray($request), ); } @@ -142,16 +142,16 @@ class AdminIdentityPolicyController extends AdminBaseController $policy = $this->policyService->findById($id); if (! $policy) { - return $this->error('messages.not_found', 404); + return $this->error('common.not_found', 404); } if ($policy->source_type !== IdentityPolicySourceType::Admin) { - return $this->forbidden('messages.cannot_delete_system_resource'); + return $this->forbidden('identity.errors.cannot_delete_system_policy'); } return $this->policyService->deleteAdminPolicy($policy) - ? $this->success('messages.deleted') - : $this->error('messages.failed', 500); + ? $this->success('common.deleted') + : $this->error('common.failed', 500); } /** @@ -170,7 +170,7 @@ class AdminIdentityPolicyController extends AdminBaseController $policy = $this->policyService->findById($id); if (! $policy) { - return $this->error('messages.not_found', 404); + return $this->error('common.not_found', 404); } if ($policy->source_type === IdentityPolicySourceType::Admin) { @@ -184,6 +184,6 @@ class AdminIdentityPolicyController extends AdminBaseController return $this->error('identity.errors.reset_field_failed', 422); } - return $this->successWithResource('messages.success', new PolicyResource($policy->fresh())); + return $this->successWithResource('common.success', new PolicyResource($policy->fresh())); } } diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php index 42fbe090..44ff405d 100644 --- a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php +++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php @@ -5,9 +5,9 @@ namespace App\Http\Controllers\Api\Admin\Identity; use App\Extension\HookManager; use App\Extension\IdentityVerification\IdentityVerificationManager; use App\Http\Controllers\Api\Base\AdminBaseController; +use App\Http\Requests\Identity\AdminIdentityProviderIndexRequest; use App\Http\Resources\Identity\ProviderResource; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; /** * 관리자 — IDV 프로바이더 조회 + 설정 스키마 수집 컨트롤러. @@ -28,10 +28,10 @@ class AdminIdentityProviderController extends AdminBaseController /** * 등록된 프로바이더 목록과 각 프로바이더의 설정 스키마를 반환합니다. * - * @param Request $request HTTP 요청 + * @param AdminIdentityProviderIndexRequest $request 검증된 요청 (파라미터 없음) * @return JsonResponse 프로바이더 목록 (settings_schema 포함) */ - public function index(Request $request): JsonResponse + public function index(AdminIdentityProviderIndexRequest $request): JsonResponse { $providers = array_values($this->manager->all()); @@ -48,6 +48,6 @@ class AdminIdentityProviderController extends AdminBaseController return $resource + ['settings_schema' => is_array($schema) ? $schema : []]; }, $providers); - return $this->success('messages.success', $rows); + return $this->success('common.success', $rows); } } diff --git a/app/Http/Controllers/Api/Admin/PluginSettingsController.php b/app/Http/Controllers/Api/Admin/PluginSettingsController.php index cebd3ef2..192a5335 100644 --- a/app/Http/Controllers/Api/Admin/PluginSettingsController.php +++ b/app/Http/Controllers/Api/Admin/PluginSettingsController.php @@ -63,7 +63,7 @@ class PluginSettingsController extends AdminBaseController return $this->notFound('plugins.not_found'); } - return $this->success('messages.success', $this->maskSensitive($identifier, $settings)); + return $this->success('common.success', $this->maskSensitive($identifier, $settings)); } /** @@ -109,6 +109,6 @@ class PluginSettingsController extends AdminBaseController return $this->notFound('plugins.not_found'); } - return $this->success('messages.success', $layout); + return $this->success('common.success', $layout); } } diff --git a/app/Http/Controllers/Api/Admin/SeoCacheController.php b/app/Http/Controllers/Api/Admin/SeoCacheController.php index 8f3e7be8..c0ebee0c 100644 --- a/app/Http/Controllers/Api/Admin/SeoCacheController.php +++ b/app/Http/Controllers/Api/Admin/SeoCacheController.php @@ -48,9 +48,9 @@ class SeoCacheController extends AdminBaseController 'by_module' => $this->statsService->getStatsByModule($since), ]; - return $this->success('messages.success', $data); + return $this->success('common.success', $data); } catch (\Exception $e) { - return $this->error('messages.error_occurred', 500, $e->getMessage()); + return $this->error('common.error_occurred', 500, $e->getMessage()); } } @@ -70,14 +70,14 @@ class SeoCacheController extends AdminBaseController if ($layout) { $count = $this->cacheManager->invalidateByLayout($layout); - return $this->success('messages.success', ['cleared' => $count]); + return $this->success('common.success', ['cleared' => $count]); } $this->cacheManager->clearAll(); - return $this->success('messages.success', ['cleared' => 'all']); + return $this->success('common.success', ['cleared' => 'all']); } catch (\Exception $e) { - return $this->error('messages.error_occurred', 500, $e->getMessage()); + return $this->error('common.error_occurred', 500, $e->getMessage()); } } @@ -92,12 +92,12 @@ class SeoCacheController extends AdminBaseController { try { // Phase 5의 SeoDeclarationCollector 구현 후 실제 워밍업 로직 추가 예정 - return $this->success('messages.success', [ + return $this->success('common.success', [ 'status' => 'dispatched', 'message' => __('seo.warmup_dispatched'), ]); } catch (\Exception $e) { - return $this->error('messages.error_occurred', 500, $e->getMessage()); + return $this->error('common.error_occurred', 500, $e->getMessage()); } } @@ -149,9 +149,9 @@ class SeoCacheController extends AdminBaseController try { $urls = $this->cacheManager->getCachedUrls(); - return $this->success('messages.success', ['urls' => $urls, 'count' => count($urls)]); + return $this->success('common.success', ['urls' => $urls, 'count' => count($urls)]); } catch (\Exception $e) { - return $this->error('messages.error_occurred', 500, $e->getMessage()); + return $this->error('common.error_occurred', 500, $e->getMessage()); } } } diff --git a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php index a8df7880..2d898b0c 100644 --- a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php +++ b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\Identity; +use App\Enums\IdentityOriginType; use App\Extension\IdentityVerification\IdentityVerificationManager; use App\Http\Controllers\Api\Base\PublicBaseController; use App\Http\Requests\Identity\CancelChallengeRequest; @@ -15,6 +16,7 @@ use App\Http\Requests\Identity\VerifyChallengeRequest; use App\Http\Resources\Identity\ChallengeResource; use App\Http\Resources\Identity\ProviderResource; use App\Models\IdentityVerificationLog; +use App\Models\User; use App\Services\IdentityPolicyService; use App\Services\IdentityVerificationService; use Illuminate\Http\JsonResponse; @@ -53,7 +55,7 @@ class IdentityVerificationController extends PublicBaseController $user = $request->user(); $target = $user ?: ($validated['target'] ?? []); - if (! ($user instanceof \App\Models\User) && empty($target['email']) && empty($target['phone'])) { + if (! ($user instanceof User) && empty($target['email']) && empty($target['phone'])) { return $this->error('identity.errors.missing_target', 422); } @@ -68,7 +70,7 @@ class IdentityVerificationController extends PublicBaseController context: [ 'ip_address' => $request->ip(), 'user_agent' => substr((string) $request->userAgent(), 0, 512), - 'origin_type' => \App\Enums\IdentityOriginType::Api->value, + 'origin_type' => IdentityOriginType::Api->value, 'origin_identifier' => '/api/identity/challenges', ], providerId: $providerId, @@ -136,7 +138,7 @@ class IdentityVerificationController extends PublicBaseController * * @param CancelChallengeRequest $request 검증된 요청 * @param IdentityVerificationLog $challenge 라우트 모델 바인딩으로 resolve 된 challenge 로그 - * @return JsonResponse + * @return JsonResponse 취소 결과 */ public function cancel(CancelChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse { @@ -159,7 +161,8 @@ class IdentityVerificationController extends PublicBaseController * * @param ShowChallengeRequest $request 검증된 요청 * @param IdentityVerificationLog $challenge 라우트 모델 바인딩으로 resolve 된 challenge 로그 - * @return JsonResponse + * @return JsonResponse 공개 안전 상태 필드 + * * @since engine-v1.46.0 */ public function show(ShowChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse @@ -170,7 +173,7 @@ class IdentityVerificationController extends PublicBaseController return $this->error('identity.errors.challenge_not_found', 404); } - return $this->success('messages.success', $status); + return $this->success('common.success', $status); } /** @@ -188,6 +191,7 @@ class IdentityVerificationController extends PublicBaseController * @param IdentityCallbackRequest $request 검증된 요청 * @param string $providerId 콜백을 보낸 provider 식별자 * @return JsonResponse|RedirectResponse + * * @since engine-v1.46.0 */ public function callback(IdentityCallbackRequest $request, string $providerId) @@ -271,7 +275,7 @@ class IdentityVerificationController extends PublicBaseController $providers, ); - return $this->success('messages.success', $data); + return $this->success('common.success', $data); } /** @@ -300,7 +304,7 @@ class IdentityVerificationController extends PublicBaseController ]; } - return $this->success('messages.success', $data); + return $this->success('common.success', $data); } /** @@ -353,11 +357,11 @@ class IdentityVerificationController extends PublicBaseController $policy = $this->policyService->resolve($scope, $target); if (! $policy || ! $policy->enabled) { - return $this->success('messages.success', null); + return $this->success('common.success', null); } // 민감 필드는 노출하지 않고 UI 힌트에 필요한 최소 필드만 반환 - return $this->success('messages.success', [ + return $this->success('common.success', [ 'policy_key' => $policy->key, 'scope' => $policy->scope, 'target' => $policy->target, diff --git a/app/Http/Requests/Identity/AdminIdentityProviderIndexRequest.php b/app/Http/Requests/Identity/AdminIdentityProviderIndexRequest.php new file mode 100644 index 00000000..514f1788 --- /dev/null +++ b/app/Http/Requests/Identity/AdminIdentityProviderIndexRequest.php @@ -0,0 +1,38 @@ +> 검증 규칙 (요청 파라미터 없음) + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Services/IdentityVerificationService.php b/app/Services/IdentityVerificationService.php index 4c35b60f..13a641e9 100644 --- a/app/Services/IdentityVerificationService.php +++ b/app/Services/IdentityVerificationService.php @@ -199,6 +199,9 @@ class IdentityVerificationService $publicPayload = $log->properties['public_payload']; } + // 시도 횟수(attempts / max_attempts) 는 노출하지 않는다. + // 이 엔드포인트는 권한 가드 없는 공개 폴링용이라 challenge id 만 알면 누구나 조회할 수 있고, + // 두 값을 함께 알면 남은 시도 횟수를 정확히 계산해 무차별 대입 시점을 노릴 수 있다. return [ 'id' => $log->id, 'status' => $log->status->value, diff --git a/app/Support/ApiDoc/ApiDocScaffolder.php b/app/Support/ApiDoc/ApiDocScaffolder.php index 1ed93767..e17ecc4d 100644 --- a/app/Support/ApiDoc/ApiDocScaffolder.php +++ b/app/Support/ApiDoc/ApiDocScaffolder.php @@ -69,6 +69,28 @@ class ApiDocScaffolder private readonly ParameterDescriber $paramDescriber = new ParameterDescriber ) {} + /** + * 엔드포인트의 `@generated` 블록 키를 만듭니다. + * + * 기본은 라우트명이다. 다만 `Route::match(['get','post'], ...)->name('x')` 처럼 **하나의 + * 라우트명이 여러 메서드**를 등록하면(KG이니시스 CBT/모바일 콜백), 한 문서에 같은 키의 + * 생성 블록이 둘 생긴다. 블록 조회는 `strpos` 로 첫 출현을 잡으므로 두 번째 블록의 사람 + * 서술이 재생성마다 유실된다. 그런 라우트에 한해 메서드를 접미로 붙여 키를 고유화한다. + * + * 중복이 아닌 절대다수의 라우트는 키를 그대로 둔다 — 전 문서의 키를 바꾸면 기존 블록과 + * 대조가 어긋나 사람 서술이 통째로 유실되기 때문이다. + * + * @param array $route 라우트 메타데이터 + * @param bool $methodScoped 라우트명이 문서 안에서 중복되는가 (메서드 접미 필요) + * @return string 생성 블록 키 + */ + public function generatedKey(array $route, bool $methodScoped = false): string + { + $name = $route['name'] ?: $route['uri']; + + return $methodScoped ? $name.'::'.strtolower((string) $route['method']) : $name; + } + /** * 단일 엔드포인트의 마크다운 섹션을 생성합니다. * @@ -77,13 +99,14 @@ class ApiDocScaffolder * @param array|null $schema 실측 응답 스키마 (null=실측 안 됨) * @param array $probeMeta 실측 메타 (status, skipped_reason, base_url, resolved_uri, body) * @param array $commentMap 컬럼명 => 주석 (필드 설명 기본값) + * @param bool $methodScopedKey 라우트명이 문서 안에서 중복되어 메서드 접미가 필요한가 * @return string 마크다운 섹션 */ - public function endpointSection(array $route, array $request, ?array $schema, array $probeMeta, array $commentMap = []): string + public function endpointSection(array $route, array $request, ?array $schema, array $probeMeta, array $commentMap = [], bool $methodScopedKey = false): string { $name = $route['name'] ?: '(unnamed)'; $heading = "### {$route['method']} {$route['uri']}"; - $genKey = $name; + $genKey = $this->generatedKey($route, $methodScopedKey); $lines = []; $lines[] = $heading; @@ -173,6 +196,9 @@ class ApiDocScaffolder ?string $existing = null, ?array $extensions = null ): string { + // 줄 끝 정규화 — mergeDocument 와 같은 이유 (CRLF 체크아웃에서 줄 단위 매칭이 전부 실패). + $existing = $existing === null ? null : $this->normalizeEol($existing); + usort($entries, fn ($a, $b) => strcmp($a['domain'], $b['domain'])); $total = array_sum(array_column($entries, 'count')); @@ -472,9 +498,11 @@ class ApiDocScaffolder } // 403: admin 게이트 또는 permission 요구 → 권한 부족. + // permission 은 여러 권한의 OR 조합(`core.modules.read | core.menus.read`)일 수 있으므로 + // 셀에 박기 전에 파이프를 이스케이프한다 — 안 하면 3열 표가 갈라져 깨진다. if ($this->hasMiddleware($mw, 'AdminMiddleware') || ! empty($route['permission'])) { $cond = ! empty($route['permission']) - ? "요구 권한(`{$route['permission']}`)이 없는 경우" + ? '요구 권한(`'.$this->escapeCell((string) $route['permission']).'`)이 없는 경우' : '관리자 권한이 없는 경우'; $rows[] = "| 403 | Forbidden | {$cond} |"; } @@ -1040,6 +1068,24 @@ class ApiDocScaffolder return str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $text); } + /** + * 기존 문서의 줄 끝을 LF 로 정규화합니다. + * + * 보존 로직(표 셀·응답 본문·에러 표·사람 서술)은 전부 `explode("\n")` 후 줄 끝 매칭 + * (`str_ends_with($line, ' |')` 등)으로 동작한다. Windows 체크아웃(core.autocrlf=true)이 + * 내려준 CRLF 문서에서는 줄 끝에 \r 이 남아 이 매칭이 전부 실패하고, 결과적으로 사람이 + * 채운 셀·본문이 하나도 보존되지 않은 채 TODO/마커로 퇴행한다. + * + * 문서는 `.gitattributes` 가 `eol=lf` 로 저장하므로 정규화가 저장 형식을 바꾸지 않는다. + * + * @param string $content 기존 문서 내용 + * @return string LF 로 정규화된 내용 + */ + private function normalizeEol(string $content): string + { + return str_replace(["\r\n", "\r"], "\n", $content); + } + /** * 기존 문서에 새 생성 블록을 병합합니다. 사람 서술은 보존합니다. * @@ -1055,6 +1101,12 @@ class ApiDocScaffolder return $header."\n".implode("\n", $sections); } + // Windows 체크아웃(core.autocrlf=true)은 문서를 CRLF 로 내려준다. 아래 보존 로직은 + // 전부 `explode("\n")` + 줄 끝 매칭(`str_ends_with($line, ' |')`) 기반이라, 줄 끝에 + // \r 이 남아 있으면 표 행이 하나도 파싱되지 않아 사람이 채운 셀이 통째로 유실된다. + // 파싱 전에 줄 끝을 LF 로 정규화한다 (문서는 .gitattributes 가 eol=lf 로 저장한다). + $existing = $this->normalizeEol($existing); + $merged = $header."\n"; foreach ($sections as $i => $section) { @@ -1207,17 +1259,30 @@ class ApiDocScaffolder } $previousRows = $this->errorTableRows($previous); - - if ($previousRows === []) { - return $section; - } - $currentBody = $this->extractErrorTable($section); if ($currentBody === null) { return $section; } + // 기존 본문이 표가 아닌 사람 서술인 경우 (자동 추론이 에러 행을 만들지 못하는 공개 조회 + // 엔드포인트에서, 사람이 `대표 에러 없음` 초안 자리를 "왜 도메인 에러가 없는지" 로 채운 것). + // 표 행이 없다고 보존을 건너뛰면 재생성이 그 서술을 TODO 초안으로 덮는다. + if ($previousRows === []) { + $previousBody = $this->extractErrorTable($previous); + + $previousIsHumanProse = $previousBody !== null + && trim($previousBody) !== '' + && ! $this->isTodoCell($previousBody) + && ! str_contains($previousBody, '대표 에러 없음'); + + if ($previousIsHumanProse) { + return str_replace($currentBody, trim($previousBody), $section); + } + + return $section; + } + // 상태코드 키로 병합. `+` 는 왼쪽 우선이므로 기존 행을 먼저 둬서, 같은 상태코드면 // 사람이 쓴 구체적 조건이 자동 문구를 이긴다. 자동 추론에만 있는 상태코드는 새로 편입된다. $merged = $previousRows + $this->errorTableRows($section); @@ -1708,12 +1773,32 @@ class ApiDocScaffolder private function locateSubsectionBody(string $block, string $label, array $terminators): ?array { $labelPos = strpos($block, $label); + $labelLength = strlen($label); + // 라벨 정확 일치 실패 시 베이스 라벨(굵은 제목)만으로 재시도한다. + // 봉투를 쓰지 않는 엔드포인트(레이아웃 JSON 을 root 에 그대로 내리는 미리보기 서빙 등)에서는 + // `**응답 필드** (`data` 내부)` 라벨이 사실과 달라 사람이 접미를 떼어낸다. 정확 일치만 보면 + // 기존 본문 구간을 못 찾아, 표가 아닌 **서술**로 채운 내용이 마커로 퇴행한다 + // (표는 applyPreservedTableCells 가 행 키로 따로 지키지만, 서술은 이 경로가 유일하다). + // 라벨 줄 전체를 라벨 길이로 삼아야 본문 시작 위치가 어긋나지 않는다. if ($labelPos === false) { - return null; + $base = Str::before($label, ' ('); + + if ($base === $label) { + return null; + } + + $labelPos = strpos($block, $base); + + if ($labelPos === false) { + return null; + } + + $lineEnd = strpos($block, "\n", $labelPos); + $labelLength = ($lineEnd === false ? strlen($block) : $lineEnd) - $labelPos; } - $bodyStart = $labelPos + strlen($label); + $bodyStart = $labelPos + $labelLength; $candidates = []; foreach ($terminators as $terminator) { @@ -2033,6 +2118,9 @@ class ApiDocScaffolder */ public function insertExampleBlocks(string $content, array $exampleBlocks): array { + // 줄 끝 정규화 — mergeDocument 와 같은 이유 (CRLF 체크아웃에서 줄 단위 매칭이 전부 실패). + $content = $this->normalizeEol($content); + $inserted = 0; foreach ($exampleBlocks as $key => $blocks) { diff --git a/app/Support/ApiDoc/ParameterDescriber.php b/app/Support/ApiDoc/ParameterDescriber.php index 623b7102..42837901 100644 --- a/app/Support/ApiDoc/ParameterDescriber.php +++ b/app/Support/ApiDoc/ParameterDescriber.php @@ -193,6 +193,91 @@ class ParameterDescriber 'websocket_port' => 'WebSocket 포트 번호', 'websocket_scheme' => 'WebSocket 스킴 (http/https)', 'websocket_app_key' => 'WebSocket 앱 키', + 'websocket_app_secret' => 'WebSocket 앱 시크릿', + 'websocket_server_host' => 'WebSocket 서버 호스트 주소 (서버측 발행 대상)', + 'websocket_server_port' => 'WebSocket 서버 포트 번호 (서버측 발행 대상)', + 'websocket_server_scheme' => 'WebSocket 서버 스킴 (http/https — 서버측 발행 대상)', + 'websocket_verify_ssl' => 'WebSocket 서버 SSL 인증서 검증 여부', + 'log_driver' => '로그 드라이버 (single/daily/stack)', + 'log_level' => '로그 레벨 (debug/info/warning/error 등)', + 'log_days' => '로그 파일 보관 일수', + 'search_engine_driver' => '검색 엔진 드라이버 (Scout 엔진 선택)', + 'session_lifetime' => '세션 유효 시간 (분)', + + // 레이아웃 JSON 스키마 최상위 키 (근거: docs/frontend/layout-json.md 필수/선택 필드 표, + // UpdateLayoutContentRequest 검증 규칙. 템플릿/레이아웃 저장 API 의 content.* 키.) + 'layout_name' => '레이아웃 이름 (식별자 — 파일 경로 기반, 예: board/popular)', + 'components' => '컴포넌트 트리 배열 (레이아웃이 렌더할 컴포넌트 정의)', + 'data_sources' => 'API 데이터 소스 정의 배열 (id/endpoint/method)', + 'init_actions' => '레이아웃 로드 시 실행할 초기화 액션 배열', + 'named_actions' => '이름으로 호출 가능한 재사용 액션 정의 맵', + 'defines' => '재사용 컴포넌트 정의 맵 (컴포넌트 트리에서 참조)', + 'computed' => '계산된 값 정의 맵 (키 → 표현식)', + 'modals' => '모달 컴포넌트 정의 배열', + 'scripts' => '동적 로드할 외부 스크립트 배열', + 'errorHandling' => '레이아웃 레벨 에러 핸들링 설정 (에러 코드별 핸들러 매핑)', + 'globalHeaders' => '전역 HTTP 헤더 규칙 배열 (pattern + headers)', + 'init_state' => '초기 상태 값 맵', + 'initLocal' => 'API 응답을 `_local` 상태에 자동 복사할 키/경로', + 'initGlobal' => 'API 응답을 `_global` 상태에 자동 복사할 키/경로', + 'initIsolated' => 'API 응답을 `_isolated` 상태에 자동 복사할 키/경로', + 'global_state' => '전역 상태 초기값 맵', + 'slots' => '슬롯별 삽입 콘텐츠 맵 (베이스 레이아웃의 slot 위치에 주입)', + 'extends' => '상속할 베이스 레이아웃 이름', + 'transition_overlay' => '페이지 전환 오버레이 설정 (스켈레톤/스피너)', + 'pageConfig' => '페이지 단위 설정 객체', + 'error_config' => '에러 표시 설정 객체', + + // SEO 페이지 생성기 (근거: docs/frontend/layout-json.md meta.seo 표, + // docs/backend/seo-system.md, Settings SEO 카탈로그) + 'changefreq' => 'sitemap changefreq 값 (daily/weekly/monthly 등)', + 'structured_data' => 'JSON-LD 구조화 데이터 정의', + 'og' => 'Open Graph 메타태그 정의 맵', + 'page_type' => 'SEO 템플릿 키를 결정하는 페이지 유형', + 'toggle_setting' => 'SEO 활성화 여부를 결정하는 설정 경로', + 'vars' => 'SEO 변수 선언 맵 (데이터 소스 값의 표현식 매핑)', + 'meta_keywords' => 'SEO 메타 키워드 (검색엔진 노출 키워드, 쉼표 구분)', + 'meta_title_suffix' => '모든 페이지 SEO 제목 뒤에 붙는 접미 문구', + 'google_site_verification' => 'Google Search Console 사이트 소유 확인 코드', + 'naver_site_verification' => '네이버 서치어드바이저 사이트 소유 확인 코드', + 'twitter_default_card' => '기본 트위터 카드 유형 (summary 등)', + 'twitter_default_site' => '기본 트위터 사이트 계정 (@handle)', + 'og_image_default_width' => '기본 Open Graph 이미지 너비 (px)', + 'og_image_default_height' => '기본 Open Graph 이미지 높이 (px)', + 'sitemap_enabled' => 'sitemap.xml 생성 사용 여부', + 'sitemap_schedule' => 'sitemap 자동 생성 주기', + 'sitemap_schedule_time' => 'sitemap 자동 생성 시각', + 'sitemap_cache_ttl' => 'sitemap 캐시 유효 시간 (초)', + 'bot_detection_enabled' => '검색엔진 봇 감지 사용 여부 (봇 요청에 SEO 렌더링 적용)', + 'bot_detection_library_enabled' => '봇 감지 라이브러리 사용 여부 (User-Agent 목록 대신 라이브러리 판정)', + 'bot_user_agents' => '봇으로 판정할 User-Agent 목록', + 'generator_enabled' => 'SEO 페이지 생성기 사용 여부', + 'generator_content' => 'SEO 렌더링 본문 생성 방식', + + // 캐시/보안/업로드 공통 (근거: Settings 카탈로그 advanced/security/upload 그룹) + 'cache_enabled' => '캐시 사용 여부', + 'cache_ttl' => '캐시 유효 시간 (초)', + 'debug_mode' => '디버그 모드 사용 여부 (상세 오류 노출)', + 'sql_query_log' => 'SQL 쿼리 로그 기록 여부', + 'maintenance_mode' => '점검 모드 사용 여부 (사이트 접근 차단)', + 'force_https' => 'HTTPS 강제 리다이렉트 여부', + 'max_login_attempts' => '로그인 실패 허용 횟수 (초과 시 잠금)', + 'login_lockout_time' => '로그인 잠금 지속 시간 (분)', + 'login_attempt_enabled' => '로그인 시도 제한 사용 여부', + 'auth_token_lifetime' => '인증 토큰 유효 시간 (분)', + 'max_file_size' => '업로드 허용 최대 파일 크기', + 'max_file_count' => '업로드 허용 최대 파일 개수', + 'allowed_extensions' => '업로드 허용 확장자 목록', + 'image_quality' => '이미지 리사이즈 시 압축 품질 (1~100)', + 'image_max_width' => '이미지 리사이즈 최대 너비 (px)', + 'image_max_height' => '이미지 리사이즈 최대 높이 (px)', + + // 사이트 기본 정보 (근거: Settings general 그룹) + 'site_url' => '사이트 기본 URL', + 'site_description' => '사이트 설명', + 'site_logo' => '사이트 로고 이미지', + 'admin_email' => '관리자 이메일 주소', + 'currency' => '통화 코드 (ISO 4217 — 예: KRW)', ]; /** @@ -204,6 +289,94 @@ class ParameterDescriber * @return string|null 설명 (없으면 null) */ public function describe(string $name, string $location = '', string $type = ''): ?string + { + // 중첩 필드(general.site_name, shipping.zipcode 등)는 전체명으로는 어떤 규칙에도 + // 걸리지 않는다. 전체명 매칭을 먼저 시도하고, 실패하면 마지막 세그먼트(leaf)로 + // 재시도한다 — 중첩 경로의 의미는 leaf 가 결정하고 부모는 그룹 라벨일 뿐이다. + // 다국어 로케일 접미(name.ko / alt_text.en)는 부모 필드의 로케일별 값이다. + if (str_contains($name, '.')) { + return $this->describeNested($name, $location, $type); + } + + return $this->describeFlat($name, $location, $type); + } + + /** + * 중첩 필드(`.` 포함)의 설명을 leaf 세그먼트로 유추합니다. + * + * @param string $name 중첩 파라미터명 (예: general.site_name) + * @param string $location 위치 + * @param string $type 타입 + * @return string|null 설명 (미매칭 시 null) + */ + private function describeNested(string $name, string $location, string $type): ?string + { + $leaf = Str::afterLast($name, '.'); + $parent = Str::beforeLast($name, '.'); + + // 배열 원소 인덱스(items.*.name / ids.0) — leaf 가 인덱스면 그 앞을 leaf 로 본다. + if ($leaf === '*' || ctype_digit($leaf)) { + return $this->describe($parent, $location, $type); + } + + // 로케일 접미(name.ko / alt_text.en): 부모 필드의 로케일별 값. + if (in_array($leaf, config('app.supported_locales', ['ko', 'en']), true)) { + $parentDesc = $this->describe($parent, $location, $type); + + return $parentDesc === null + ? null + : "{$parentDesc} — `{$leaf}` 로케일 값"; + } + + // 부모가 의미를 한정하는 그룹인 경우: leaf 만으로는 오설명이 된다. + // 예) seo_meta.title 은 "제목" 이 아니라 "SEO 메타 제목" 이다. + $scoped = $this->describeScopedLeaf($parent, $leaf); + if ($scoped !== null) { + return $scoped; + } + + // leaf 로 재시도 (부모 그룹 라벨은 의미를 바꾸지 않는다). + return $this->describeFlat($leaf, $location, $type); + } + + /** + * 부모 그룹이 의미를 한정하는 중첩 키의 설명을 반환합니다. + * + * 대부분의 설정 그룹(general/mail/upload 등)은 라벨일 뿐이라 leaf 가 의미를 결정하지만, + * SEO 메타 그룹의 `title`/`description`/`keywords` 는 일반 제목/설명이 아니라 검색엔진 + * 노출용 메타 값이다. leaf 폴백만 태우면 "제목"/"설명" 으로 축소되어 오설명이 된다. + * + * @param string $parent 부모 경로 (예: seo_meta, content.meta.seo) + * @param string $leaf 마지막 세그먼트 + * @return string|null 설명 (해당 없으면 null → 일반 leaf 폴백) + */ + private function describeScopedLeaf(string $parent, string $leaf): ?string + { + $parentLeaf = Str::afterLast($parent, '.'); + + // SEO 메타 그룹 (근거: Page\*Request 의 seo_meta.{title,description,keywords}, + // 레이아웃 meta.seo — 검색엔진/소셜 공유 노출 값) + if (in_array($parentLeaf, ['seo', 'seo_meta'], true)) { + return match ($leaf) { + 'title' => 'SEO 메타 제목 (검색엔진/소셜 공유 표시 제목)', + 'description' => 'SEO 메타 설명 (검색엔진/소셜 공유 표시 요약)', + 'keywords' => 'SEO 메타 키워드 (검색엔진 노출 키워드, 쉼표 구분)', + default => null, + }; + } + + return null; + } + + /** + * 단일 세그먼트 파라미터의 설명을 반환합니다. + * + * @param string $name 파라미터명 (`.` 없음) + * @param string $location 위치 (path/query/body) + * @param string $type 타입 + * @return string|null 설명 (미매칭 시 null) + */ + private function describeFlat(string $name, string $location, string $type): ?string { // path 파라미터는 언제나 리소스 식별자다(라우트 모델 바인딩). 이름이 무엇이든 // 정렬·필터 같은 조회 파라미터 의미를 가질 수 없으므로 이름 기반 규칙보다 앞서 처리한다. diff --git a/app/Support/ApiDoc/ResourceFieldDescriber.php b/app/Support/ApiDoc/ResourceFieldDescriber.php index c8cdf38f..53c1e1b7 100644 --- a/app/Support/ApiDoc/ResourceFieldDescriber.php +++ b/app/Support/ApiDoc/ResourceFieldDescriber.php @@ -105,6 +105,8 @@ class ResourceFieldDescriber 'install_blocked_reason' => '설치가 차단된 사유 (차단 없으면 null)', // 감사/관계 공통 (도메인 무관하게 역할 고정 — 쓰기 응답에서 흔히 등장) + // creator/permissions 는 위 "관계/연관 객체" 블록에 이미 있으므로 여기서 재정의하지 않는다 + // (PHP 배열은 뒤 키가 앞을 덮어 같은 값이 두 번 선언되는 상태였다). 'updated_by' => '최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null)', 'user' => '대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생)', 'roles' => '보유 역할 목록 (각 원소 id/name/permissions — roles 관계 파생)', @@ -133,6 +135,47 @@ class ResourceFieldDescriber 'spec' => '스펙 정의 객체 (편집기/컴포넌트 선언 스키마 등)', 'source_meta' => '원천 메타데이터 객체 (출처·경로·해석 정보)', 'validation_summary' => '검증 결과 요약 객체 (통과/실패 건수 등)', + + // 레이아웃 JSON 스키마 키 (근거: docs/frontend/layout-json.md 필수/선택 필드 표. + // 레이아웃 서빙/저장 응답의 content 하위 키 — 엔진 스키마 계약으로 의미 고정) + '$schema' => 'JSON 스키마 선언 URL (편집기/검증 도구용)', + 'layout_name' => '레이아웃 이름 (식별자 — 파일 경로 기반, 예: board/popular)', + 'components' => '컴포넌트 트리 (레이아웃이 렌더할 컴포넌트 정의)', + 'data_sources' => 'API 데이터 소스 정의 배열 (id/endpoint/method)', + 'modals' => '모달 컴포넌트 정의 배열', + 'slots' => '슬롯별 삽입 콘텐츠 맵 (베이스 레이아웃의 slot 위치에 주입)', + 'extends' => '상속하는 베이스 레이아웃 이름 (미상속 시 null)', + 'schema' => '스키마 정의 객체', + 'meta' => '메타 정보 객체 (title/description/seo 등)', + 'pageConfig' => '페이지 단위 설정 객체', + 'error_config' => '에러 표시 설정 객체', + 'base_layouts' => '상속 가능한 베이스 레이아웃 목록', + 'layout_versions' => '레이아웃 버전 이력 목록', + 'preview' => '미리보기 정보 객체', + 'vars' => 'SEO 변수 선언 맵 (데이터 소스 값의 표현식 매핑)', + 'events' => '이벤트 정의 목록', + 'toggle_settings' => '토글 가능한 설정 항목 목록', + 'page_types' => 'SEO 템플릿 키로 사용 가능한 페이지 유형 목록', + 'display_mode' => '표시 방식 (렌더/노출 모드 구분 값)', + 'templateId' => '대상 템플릿의 식별자', + + // 확장 manifest / 에셋 계약 (근거: module.json/plugin.json/template.json manifest, + // ExtensionBundleService 에셋 매니페스트) + 'g7_version' => '요구하는 코어 최소 버전 (manifest requires.g7_version)', + 'required_version' => '요구되는 최소 버전', + 'manifest_source' => 'manifest 를 읽어온 출처 경로 (활성/_bundled 구분)', + 'manifest_present' => 'manifest 파일 존재 여부', + 'license' => '라이선스 식별자 (manifest license)', + 'release_date' => '배포일', + 'cache_version' => '에셋 캐시 무효화 버전 (번들 URL 파일명에 포함)', + 'js' => '프론트엔드 JS 에셋 목록 (manifest assets 파생)', + 'css' => '프론트엔드 CSS 에셋 목록 (manifest assets 파생)', + 'externals' => '외부 의존 라이브러리 목록 (번들에서 제외되고 전역에서 해석)', + 'routes' => '라우트 정의 목록', + 'components_count' => '컴포넌트 개수 (집계)', + 'theme_color' => '테마 대표 색상', + 'popup_width' => '팝업 창 너비 (px)', + 'popup_height' => '팝업 창 높이 (px)', ]; /** @@ -143,6 +186,70 @@ class ResourceFieldDescriber * @return string|null 설명 (없으면 null) */ public function describe(string $field, string $type = ''): ?string + { + // 중첩 필드(settings.general.site_name, meta.total 등)는 전체명으로 어떤 규칙에도 + // 걸리지 않는다. 마지막 세그먼트(leaf)로 폴백한다 — 중첩 경로의 의미는 leaf 가 + // 결정하고 상위 세그먼트는 그룹 라벨일 뿐이다. (ParameterDescriber 와 동일 계약) + if (str_contains($field, '.')) { + return $this->describeNested($field, $type); + } + + return $this->describeFlat($field, $type); + } + + /** + * 중첩 필드(`.` 포함)의 설명을 leaf 세그먼트로 유추합니다. + * + * @param string $field 중첩 필드명 (예: settings.general.site_name) + * @param string $type 실측 타입 + * @return string|null 설명 (미매칭 시 null) + */ + private function describeNested(string $field, string $type): ?string + { + $leaf = Str::afterLast($field, '.'); + $parent = Str::beforeLast($field, '.'); + + // 배열 원소 인덱스(items.*.name / items.0.name) — 그 앞을 leaf 로 본다. + if ($leaf === '*' || ctype_digit($leaf)) { + return $this->describe($parent, $type); + } + + // 로케일 접미(name.ko): 부모 필드의 로케일별 값. + if (in_array($leaf, config('app.supported_locales', ['ko', 'en']), true)) { + $parentDesc = $this->describe($parent, $type); + + return $parentDesc === null + ? null + : "{$parentDesc} — `{$leaf}` 로케일 값"; + } + + // 부모가 의미를 한정하는 그룹(seo/seo_meta)의 title/description/keywords 는 + // 일반 제목/설명이 아니라 검색엔진 노출용 메타 값이다 (ParameterDescriber 와 동일 계약). + $parentLeaf = Str::afterLast($parent, '.'); + if (in_array($parentLeaf, ['seo', 'seo_meta'], true)) { + $scoped = match ($leaf) { + 'title' => 'SEO 메타 제목 (검색엔진/소셜 공유 표시 제목)', + 'description' => 'SEO 메타 설명 (검색엔진/소셜 공유 표시 요약)', + 'keywords' => 'SEO 메타 키워드 (검색엔진 노출 키워드, 쉼표 구분)', + default => null, + }; + + if ($scoped !== null) { + return $scoped; + } + } + + return $this->describeFlat($leaf, $type); + } + + /** + * 단일 세그먼트 필드의 설명을 반환합니다. + * + * @param string $field 필드명 (`.` 없음) + * @param string $type 실측 타입 + * @return string|null 설명 (미매칭 시 null) + */ + private function describeFlat(string $field, string $type): ?string { // sort_order 는 응답에서 표시 정렬 순서 값(정수 컬럼)으로 고정된다. // 문자열이면 정렬 방향일 수 있어 도메인 특이 → TODO 유지. diff --git a/docs/backend/api-documentation.md b/docs/backend/api-documentation.md index 048f9e9a..14bf47f5 100644 --- a/docs/backend/api-documentation.md +++ b/docs/backend/api-documentation.md @@ -427,10 +427,35 @@ php artisan api:docgen-backfill-fields □ 응답 필드 표가 envelope 의 data 내부 기준으로 작성되었는가? □ 응답 예시(envelope 전문 JSON) 블록이 방출되었는가? (목록은 2항목 절단) □ 훅 주입 파라미터가 있으면 주석 + 사람 보강이 되었는가? -□ TODO 마커가 모두 채워졌는가? +□ 미채움 마커 5종이 전수 0 인가? (아래 참조 — `TODO:` 만 세는 부분 집계 금지) □ api:docgen --check 가 drift 0 인가? ``` +### 미채움 마커 5종 (전수 0 이 완료 기준) + +`api:docgen` 은 실측할 수 없는 자리에 "사람이 작성하세요" 마커를 남긴다. **실측 불가는 그 자리를 비워둘 +사유가 아니다.** 컨트롤러/Resource/FormRequest/Enum/lang 을 읽어 코드 근거로 채우는 것이 규정이다. + +| 마커 | 채울 것 | 근거 소스 | +| --- | --- | --- | +| `` | 응답 필드 표 | Resource `toArray()` 의 키 전수 + 타입, Enum `label()`/`variant()` | +| `` | 응답 예시 JSON | 컨트롤러의 `ResponseHelper::success/paginated/dataSource` 호출 형태 + Resource + lang 의 message 문구 | +| `` | 요청 파라미터 설명 셀 | FormRequest `rules()`/`messages()`, 설정 키는 관리자 설정 UI 다국어 라벨 | +| `` | 응답 필드 설명 셀 | Resource `toArray()` 파생 로직, 모델 `$casts`/관계, 마이그레이션 한국어 comment | +| `_대표 에러 없음 ... _` | 도메인 특이 에러 표 | 컨트롤러의 `abort()`/예외 throw, Service 의 커스텀 Exception | + +도메인 무관하게 의미가 고정된 파라미터/필드는 문서에 손으로 쓰지 말고 `ParameterDescriber`/ +`ResourceFieldDescriber` 사전에 등재한다 — 사전은 재생성에 영속하고 신규 문서에도 자동 적용된다. +반대로 **도메인 특이 의미를 공통 사전에 넣지 않는다** (오설명이 전 문서로 퍼진다). + +전수 집계는 5종 마커를 한 번에 세야 한다. `TODO:` 만 세면 그 바깥의 미채움(`실측 제외` 등)이 집계에서 +빠져 부분 집계가 완료로 통과한다: + +```bash +grep -rcE "실측 제외:|TODO: 용도|TODO: 설명|실측 응답에 필드 없음|대표 에러 없음" \ + --include=*.md docs/backend/api {modules,plugins}/_bundled/*/docs/api +``` + --- ## 관련 문서 diff --git a/docs/backend/api/activity-logs.md b/docs/backend/api/activity-logs.md index 26031d35..0e3d36ef 100644 --- a/docs/backend/api/activity-logs.md +++ b/docs/backend/api/activity-logs.md @@ -214,7 +214,7 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| ids | body | array | 예 | min 1 | 삭제할 활동 로그 ID 배열 (원소는 integer 이며 `activity_logs.id` 에 존재해야 함) | **요청 예시** @@ -234,11 +234,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `3` | 실제로 삭제된 활동 로그 건수 (`ActivityLogService::deleteMany()` 반환값 — 요청한 `ids` 중 삭제에 성공한 개수) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "선택한 활동 로그가 삭제되었습니다.", + "data": { + "deleted_count": 3 + } +} +``` **에러 응답** @@ -276,9 +292,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `success('activity_log.delete_success')` 를 데이터 인자 없이 호출하여 `data` 는 `null`)._ **응답 예시** diff --git a/docs/backend/api/attachment.md b/docs/backend/api/attachment.md index af9eb195..a58b7c8f 100644 --- a/docs/backend/api/attachment.md +++ b/docs/backend/api/attachment.md @@ -27,7 +27,7 @@ | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | +| hash | path | string | 예 | — | 다운로드할 첨부파일의 해시 식별자 (`attachments.hash`) | **요청 예시** @@ -39,17 +39,45 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투(`data`)를 반환하지 않습니다. 성공 시 파일 바이너리 본문을 그대로 응답합니다 (실패 시에만 JSON 에러 봉투)._ + +| 응답 헤더 | 값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `image/png` 등 | 첨부파일의 MIME 타입 (`attachments.mime_type`) | +| Content-Disposition | `attachment; filename="원본파일명.pdf"` | 이미지가 아닌 파일에만 부여 — 원본 파일명으로 다운로드 | +| Cache-Control | `public, max-age=86400, immutable` (프로덕션) / `no-cache` (그 외) | 이미지 응답의 캐싱 정책. max-age 는 환경설정 `cache.layout_ttl` (기본 86400초) | +| Expires | `Wed, 15 Jul 2026 00:00:00 GMT` | 이미지 응답의 만료 시각 (현재 시각 + max-age) | +| ETag | `9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d` | 이미지 응답의 검증자 (파일 수정시각 + 크기의 MD5). 요청의 `If-None-Match` 와 일치하면 `304 Not Modified` | **응답 예시** - +```http +HTTP/1.1 200 OK +Content-Type: image/png +Cache-Control: public, max-age=86400, immutable +Expires: Wed, 15 Jul 2026 00:00:00 GMT +ETag: 9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + +<파일 바이너리> +``` + +이미지가 아닌 파일: + +```http +HTTP/1.1 200 OK +Content-Type: application/pdf +Content-Disposition: attachment; filename="manual.pdf" + +<파일 바이너리> +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 304 | Not Modified | 이미지 응답에서 요청 `If-None-Match` 헤더가 현재 ETag 와 일치하는 경우 (본문 없음) | +| 403 | Forbidden | 첨부파일 접근 권한 없음 (`core.attachment.download` 훅 권한 미충족) 또는 스토리지에 실제 파일이 없는 경우 — `{"success": false, "message": "이 첨부파일에 대한 접근 권한이 없습니다."}` | +| 404 | Not Found | 해당 해시의 첨부파일이 존재하지 않는 경우 — `{"success": false, "message": "첨부파일을 찾을 수 없습니다."}` | diff --git a/docs/backend/api/attachments.md b/docs/backend/api/attachments.md index 97fbb53b..ff76605f 100644 --- a/docs/backend/api/attachments.md +++ b/docs/backend/api/attachments.md @@ -75,11 +75,59 @@ example-key **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AttachmentResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 첨부파일 ID (기본 키) | +| hash | string | `"aB3xY9kLmQ7z"` | URL용 고유 해시 (12자). 다운로드 URL 식별자로 사용 | +| original_filename | string | `"example.pdf"` | 업로드된 원본 파일명 | +| mime_type | string | `"application/pdf"` | MIME 타입 (예: `image/jpeg`, `application/pdf`) | +| size | integer | `102400` | 파일 크기 (바이트) | +| size_formatted | string | `"100 KB"` | 사람이 읽기 쉬운 형식으로 포맷된 파일 크기 (B/KB/MB/GB) | +| collection | string | `"default"` | 첨부파일 컬렉션/그룹명 (미지정 시 `default`) | +| order | integer | `0` | 컬렉션 내 정렬 순서 | +| download_url | string | `"/api/attachment/aB3xY9kLmQ7z"` | 해시 기반 다운로드 경로 | +| is_image | boolean | `false` | MIME 타입이 `image/` 로 시작하는지 여부 | +| meta | object\|null | `{"width": 800, "height": 600}` | 추가 메타데이터. 이미지 업로드 시 width/height 자동 기록, 그 외에는 빈 객체 | +| source_type | string | `"core"` | 첨부 생성 출처 (`core` / `module` / `plugin`) | +| source_identifier | string\|null | `"sirsoft-board"` | 모듈/플러그인 식별자 (코어 업로드 시 `null`) | +| creator | object\|없음 | `{"uuid": "...", "name": "관리자"}` | 업로더 정보(`uuid`, `name`). `creator` 관계가 eager load 된 경우에만 포함되며, 업로드 응답에는 포함되지 않음 | +| created_at | string | `"2026-07-14 10:23:41"` | 생성 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) | +| updated_at | string | `"2026-07-14 10:23:41"` | 수정 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) | +| is_owner | boolean | `true` | 요청자가 업로더(`created_by`)인지 여부 | +| abilities | object | `{"can_update": true, "can_delete": true}` | 요청자의 권한 맵 (`can_update` = `core.attachments.update`, `can_delete` = `core.attachments.delete`) | **응답 예시** - +```json +{ + "success": true, + "message": "파일이 업로드되었습니다.", + "data": { + "id": 1, + "hash": "aB3xY9kLmQ7z", + "original_filename": "example.pdf", + "mime_type": "application/pdf", + "size": 102400, + "size_formatted": "100 KB", + "collection": "default", + "order": 0, + "download_url": "/api/attachment/aB3xY9kLmQ7z", + "is_image": false, + "meta": {}, + "source_type": "core", + "source_identifier": null, + "created_at": "2026-07-14 10:23:41", + "updated_at": "2026-07-14 10:23:41", + "is_owner": true, + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -88,6 +136,7 @@ example-key | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.attachments.create`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 파일 저장/DB 기록 중 예외 발생 시 (`attachment.upload_failed` — "파일 업로드에 실패했습니다.") | @@ -136,11 +185,88 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_배열 응답: `data` 는 `AttachmentResource` 객체의 배열입니다 (페이지네이션 없음). 각 항목의 필드는 단일 업로드 응답과 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| data[].id | integer | `1` | 첨부파일 ID (기본 키) | +| data[].hash | string | `"aB3xY9kLmQ7z"` | URL용 고유 해시 (12자) | +| data[].original_filename | string | `"photo1.jpg"` | 업로드된 원본 파일명 | +| data[].mime_type | string | `"image/jpeg"` | MIME 타입 | +| data[].size | integer | `204800` | 파일 크기 (바이트) | +| data[].size_formatted | string | `"200 KB"` | 포맷된 파일 크기 | +| data[].collection | string | `"default"` | 첨부파일 컬렉션/그룹명 | +| data[].order | integer | `0` | 컬렉션 내 정렬 순서 (배치 내 파일마다 순차 증가) | +| data[].download_url | string | `"/api/attachment/aB3xY9kLmQ7z"` | 해시 기반 다운로드 경로 | +| data[].is_image | boolean | `true` | MIME 타입이 `image/` 로 시작하는지 여부 | +| data[].meta | object\|null | `{"width": 1920, "height": 1080}` | 이미지 업로드 시 width/height 자동 기록 | +| data[].source_type | string | `"core"` | 첨부 생성 출처 (`core` / `module` / `plugin`) | +| data[].source_identifier | string\|null | `null` | 모듈/플러그인 식별자 | +| data[].created_at | string | `"2026-07-14 10:23:41"` | 생성 일시 (사용자 타임존 기준) | +| data[].updated_at | string | `"2026-07-14 10:23:41"` | 수정 일시 (사용자 타임존 기준) | +| data[].is_owner | boolean | `true` | 요청자가 업로더인지 여부 | +| data[].abilities | object | `{"can_update": true, "can_delete": true}` | 요청자의 권한 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "파일이 일괄 업로드되었습니다.", + "data": [ + { + "id": 1, + "hash": "aB3xY9kLmQ7z", + "original_filename": "photo1.jpg", + "mime_type": "image/jpeg", + "size": 204800, + "size_formatted": "200 KB", + "collection": "default", + "order": 0, + "download_url": "/api/attachment/aB3xY9kLmQ7z", + "is_image": true, + "meta": { + "width": 1920, + "height": 1080 + }, + "source_type": "core", + "source_identifier": null, + "created_at": "2026-07-14 10:23:41", + "updated_at": "2026-07-14 10:23:41", + "is_owner": true, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 2, + "hash": "Zq1WeRt5YuIo", + "original_filename": "photo2.jpg", + "mime_type": "image/jpeg", + "size": 153600, + "size_formatted": "150 KB", + "collection": "default", + "order": 1, + "download_url": "/api/attachment/Zq1WeRt5YuIo", + "is_image": true, + "meta": { + "width": 1280, + "height": 720 + }, + "source_type": "core", + "source_identifier": null, + "created_at": "2026-07-14 10:23:41", + "updated_at": "2026-07-14 10:23:41", + "is_owner": true, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ] +} +``` **에러 응답** @@ -149,6 +275,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.attachments.create`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 배치 중 파일 저장/DB 기록 예외 발생 시 (`attachment.upload_failed` — "파일 업로드에 실패했습니다.") | @@ -187,19 +314,26 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 데이터를 반환하지 않습니다 (`data` 는 `null`, 성공 메시지만 반환)._ **응답 예시** - +```json +{ + "success": true, + "message": "파일 순서가 변경되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.attachments.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.attachments.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `order[].id` 가 존재하지 않는 첨부파일이면 "존재하지 않는 첨부파일입니다." | +| 500 | Internal Server Error | 순서 갱신 중 예외 발생 시 (`attachment.reorder_failed` — "파일 순서 변경에 실패했습니다.") | @@ -229,20 +363,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 데이터를 반환하지 않습니다 (`data` 는 `null`, 성공 메시지만 반환)._ **응답 예시** - +```json +{ + "success": true, + "message": "파일이 삭제되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 삭제 처리가 실패로 반환된 경우 (`attachment.delete_failed` — "파일 삭제에 실패했습니다.") | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.attachments.create`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.attachments.delete`)이 없는 경우 | +| 404 | Not Found | `{attachment}` 에 해당하는 첨부파일이 없는 경우 (라우트 모델 바인딩 실패) | +| 500 | Internal Server Error | 파일/DB 삭제 중 예외 발생 시 (`attachment.delete_failed` — "파일 삭제에 실패했습니다.") | diff --git a/docs/backend/api/auth.md b/docs/backend/api/auth.md index 83c4eab5..e8a2b82c 100644 --- a/docs/backend/api/auth.md +++ b/docs/backend/api/auth.md @@ -38,9 +38,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `AuthController@logout` 이 `success('auth.logout_success')` 를 인자 없이 호출하므로 `data` 는 `null`)._ **응답 예시** @@ -285,18 +283,59 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AuthService::login()` 이 반환한 배열 — `user` 만 `UserResource` 로 감싼다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| user | object | `{"uuid":"a234c2b1-…","name":"관리자","is_admin":true, …}` | 로그인한 관리자 정보 (`UserResource` — 필드 전수는 `GET /api/admin/auth/user` 응답 필드 표와 동일) | +| token | string | `75\|WgPUplvLGTv8YIj4507uIR6dEOHTXyNUed…` | 발급된 Sanctum 접근 토큰 평문 (이후 `Authorization: Bearer` 헤더로 사용, 발급 시 1회만 노출) | +| token_type | string | `Bearer` | 토큰 타입 (항상 `Bearer` — `AuthService::login()` 이 상수로 반환) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "관리자 로그인이 성공했습니다.", + "data": { + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "status": "active", + "is_admin": true, + "roles": [ + { "id": 1, "identifier": "admin", "name": "관리자" } + ], + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + }, + "token": "{MASKED}", + "token_type": "Bearer" + } +} +``` + +> `user` 객체는 지면 절약을 위해 축약했습니다. 실제로는 `GET /api/admin/auth/user` 의 `data` 와 동일한 `UserResource` 필드 전수가 내려옵니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 401 | Unauthenticated | 이메일/비밀번호가 일치하지 않는 경우 (`auth.login_failed`) | +| 403 | Forbidden | 자격 증명은 맞지만 관리자 역할이 아닌 경우 (`auth.admin_required`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우 (`auth.account_locked` — `error.locked_until`, `error.retry_after_seconds` 포함) | @@ -336,18 +375,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `AuthController@forgotPassword` 가 `success('auth.password_reset_email_sent')` 를 인자 없이 호출하므로 `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "비밀번호 재설정 이메일이 발송되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우, 또는 등록되지 않은 이메일인 경우 (`auth.password_reset_failed` — `error.errors.email` 에 `auth.email_not_registered` 메시지) | @@ -387,18 +435,56 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AuthService::login()` 이 반환한 배열 — `user` 만 `UserResource` 로 감싼다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| user | object | `{"uuid":"a234c2b1-…","name":"홍길동","is_admin":false, …}` | 로그인한 사용자 정보 (`UserResource` — 필드 전수는 `GET /api/auth/user` 응답 필드 표의 기본(코어) 필드와 동일) | +| token | string | `75\|WgPUplvLGTv8YIj4507uIR6dEOHTXyNUed…` | 발급된 Sanctum 접근 토큰 평문 (이후 `Authorization: Bearer` 헤더로 사용, 발급 시 1회만 노출) | +| token_type | string | `Bearer` | 토큰 타입 (항상 `Bearer`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "로그인이 성공했습니다.", + "data": { + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "language": "ko", + "status": "active", + "is_admin": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + }, + "token": "{MASKED}", + "token_type": "Bearer" + } +} +``` + +> `user` 객체는 지면 절약을 위해 축약했습니다. 실제로는 `UserResource` 필드 전수가 내려옵니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 401 | Unauthenticated | 이메일/비밀번호가 일치하지 않거나 계정 상태가 활성이 아닌 경우 (`auth.login_failed`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우 (`auth.account_locked` — `error.locked_until`, `error.retry_after_seconds` 포함) | @@ -491,9 +577,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `AuthController@logout` 이 `success('auth.logout_success')` 를 인자 없이 호출하므로 `data` 는 `null`)._ **응답 예시** @@ -579,18 +663,56 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AuthService::register()` 이 반환한 배열 — `user` 만 `UserResource` 로 감싼다). 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| user | object | `{"uuid":"a234c2b1-…","name":"홍길동","status":"active", …}` | 생성된 사용자 정보 (`UserResource`. `status` 는 `active`, 가입 후 본인인증 정책이 걸린 경우 `pending_verification`) | +| token | string | `75\|WgPUplvLGTv8YIj4507uIR6dEOHTXyNUed…` | 가입 즉시 발급되는 Sanctum 접근 토큰 평문 (가입 직후 로그인 상태로 이어짐) | +| token_type | string | `Bearer` | 토큰 타입 (항상 `Bearer`) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "회원가입이 성공했습니다.", + "data": { + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": null, + "email": "apidoc-sample-user@example.com", + "language": "ko", + "status": "active", + "is_admin": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + }, + "token": "{MASKED}", + "token_type": "Bearer" + } +} +``` + +> `user` 객체는 지면 절약을 위해 축약했습니다. 실제로는 `UserResource` 필드 전수가 내려옵니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`auth.register_failed` — `error.errors` 에 필드별 메시지) | +| 428 | Precondition Required | 가입 전 본인인증 정책이 매칭되었으나 유효한 `verification_token` 이 없는 경우 (`core.auth.before_register` 훅) | @@ -632,18 +754,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `AuthController@resetPassword` 가 `success('auth.password_reset_success')` 를 인자 없이 호출하므로 `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "비밀번호가 성공적으로 재설정되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반했거나, 토큰이 유효하지 않거나 만료된 경우 (`auth.password_reset_failed` — `error.errors` 에 필드별 메시지) | @@ -813,18 +944,33 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AuthService::validateResetToken()` 의 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| valid | boolean | `true` | 토큰 유효 여부. 성공 응답에서는 항상 `true` (유효하지 않으면 컨트롤러가 422 로 전환하므로 이 필드가 `false` 인 200 응답은 없음) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "valid": true + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반, 또는 토큰이 유효하지 않거나 만료된 경우 / 미등록 이메일 (`auth.reset_token_invalid` — `error.errors.token` 에 사유 메시지) | @@ -854,11 +1000,21 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `AuthController@logout` 이 `success('auth.logout_success')` 를 인자 없이 호출하므로 `data` 는 `null`). 공용 `POST /api/auth/logout` 과 동일한 컨트롤러 메서드._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "로그아웃이 성공했습니다.", + "data": null +} +``` **에러 응답** @@ -895,11 +1051,21 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `AuthController@logoutFromAllDevices` 가 `success('auth.logout_all_devices_success')` 를 인자 없이 호출하므로 `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "모든 디바이스에서 로그아웃되었습니다.", + "data": null +} +``` **에러 응답** @@ -936,11 +1102,47 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AuthService::refreshToken()` 반환 배열 — 공용 `POST /api/admin/auth/refresh` 와 동일 shape)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| user | object | `{"uuid":"a234c2b1-…","name":"홍길동", …}` | 토큰을 갱신한 사용자 정보 (`UserResource`) | +| token | string | `75\|WgPUplvLGTv8YIj4507uIR6dEOHTXyNUed…` | 새로 발급된 Sanctum 접근 토큰 평문 (기존 토큰은 폐기됨) | +| token_type | string | `Bearer` | 토큰 타입 (항상 `Bearer`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "status": "active", + "is_admin": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + }, + "token": "{MASKED}", + "token_type": "Bearer" + } +} +``` + +> `user` 객체는 지면 절약을 위해 축약했습니다. 실제로는 `UserResource` 필드 전수가 내려옵니다. **에러 응답** @@ -977,11 +1179,70 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 컨트롤러 메서드가 공용 `GET /api/auth/user` 와 동일한 `AuthController@user` 이므로, 응답은 `UserResource::toAuthArray()` 산물(코어 필드 + `core.user.filter_resource_data` 로 확장이 병합한 필드)로 그 문서의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| nickname | string \| null | `song.hyunji` | 닉네임 (미설정 시 null) | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| language | string | `ko` | 사용자 언어 설정 | +| status | string | `active` | 계정 상태 (active / inactive / blocked / withdrawn / pending_verification) | +| is_admin | boolean | `false` | 관리자 역할 보유 여부 | +| roles | array | `[{"id":2,"identifier":"user","name":"일반회원"}]` | 부여된 역할 목록 (id/identifier/name) | +| permissions | array | `[{"id":81,"identifier":"sirsoft-ecommerce.user-products.read","name":"상품 조회"}]` | 역할 경유 권한 목록 (id/identifier/name) | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_update":true, …}` | 이 리소스에 수행 가능한 작업 불리언 맵 | +| (확장 병합 필드) | — | `notify_*`, `marketing_consent*`, `ecommerce_*` 등 | marketing 플러그인·ecommerce 모듈이 `core.user.filter_resource_data` 필터로 병합 — 전수는 `GET /api/auth/user` 응답 필드 표 참조 | + +> 필드 전수(코어 + 확장 병합)는 `GET /api/auth/user` 의 응답 필드 표와 동일하므로 그 표를 SSoT 로 참조합니다. 게스트(비인증) 요청은 `optional.sanctum` 을 통과하지만 `request()->user()` 가 없으므로 이 경로는 인증 사용자 컨텍스트에서만 사용자 객체를 반환합니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": "song.hyunji", + "email": "apidoc-sample-user@example.com", + "language": "ko", + "status": "active", + "is_admin": false, + "roles": [ + { "id": 2, "identifier": "user", "name": "일반회원" } + ], + "permissions": [], + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + }, + "notify_post_complete": false, + "notify_post_reply": false, + "notify_comment": false, + "notify_reply_comment": false, + "ecommerce_mileage": { + "enabled": false + }, + "ecommerce_preferred_currency": null, + "ecommerce_preferred_shipping_country": null, + "ecommerce_preferred_shipping_country_name": null + } +} +``` + +> 지면 절약을 위해 축약했습니다. 실제 응답은 `GET /api/auth/user` 의 응답 예시와 동일한 필드 전수를 포함합니다. **에러 응답** diff --git a/docs/backend/api/avatar.md b/docs/backend/api/avatar.md index efcdbc74..9fdbe82d 100644 --- a/docs/backend/api/avatar.md +++ b/docs/backend/api/avatar.md @@ -38,17 +38,25 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```json +{ + "success": true, + "message": "프로필 이미지가 성공적으로 삭제되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | 삭제할 아바타 첨부파일이 없는 경우 (`user.avatar_not_found` — "삭제할 프로필 이미지가 없습니다.") | +| 500 | Server Error | 첨부파일/실제 파일 삭제 중 예외 발생 (`user.avatar_delete_failed` — "프로필 이미지 삭제에 실패했습니다.") | @@ -86,11 +94,25 @@ Content-Type: image/png **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| avatar | string\|null | `/api/attachment/9f1c2b7d8e...` | 업로드된 아바타의 다운로드 URL (`Attachment.download_url` = `/api/attachment/{hash}`). 레거시 `avatar` 컬럼만 있는 경우 `storage/attachments/avatars/{파일명}` 형태의 절대 URL | +| attachment_id | integer | `1` | 생성된 첨부파일(Attachment) 레코드의 기본 키 | **응답 예시** - +```json +{ + "success": true, + "message": "프로필 이미지가 성공적으로 업로드되었습니다.", + "data": { + "avatar": "/api/attachment/9f1c2b7d8e3a4c5b6d7e8f90a1b2c3d4", + "attachment_id": 1 + } +} +``` **에러 응답** @@ -98,6 +120,7 @@ Content-Type: image/png | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 파일 저장/첨부파일 생성 중 예외 발생 (`user.avatar_upload_failed` — "프로필 이미지 업로드에 실패했습니다.") | diff --git a/docs/backend/api/broadcasting.md b/docs/backend/api/broadcasting.md index 0bb3c297..8c54c152 100644 --- a/docs/backend/api/broadcasting.md +++ b/docs/backend/api/broadcasting.md @@ -37,17 +37,26 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 G7 공통 응답 봉투(`success`/`message`/`data`)를 사용하지 않습니다. 라우트 클로저가 `Broadcast::auth($request)` 의 반환값을 그대로 응답하므로, 응답 본문은 Laravel 브로드캐스팅 드라이버(Reverb — Pusher 프로토콜 호환)가 생성하는 채널 인증 페이로드입니다. 등록된 채널(`routes/channels.php`, 모듈/플러그인 `getChannels()`)은 모두 boolean 을 반환하는 private 채널이므로 아래 필드만 반환됩니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| auth | string | `앱키:서명` 형식 문자열 | 클라이언트가 WebSocket 서버(Reverb)에 프라이빗 채널 구독을 요청할 때 제시하는 인증 서명. `{reverb_app_key}:{HMAC-SHA256(socket_id:channel_name)}` 형태이며, 브로드캐스팅 클라이언트 라이브러리가 자동으로 소비합니다 | **응답 예시** - +```json +{ + "auth": "app-key:5f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8" +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 웹소켓 사용 OFF(`broadcasting.default === 'null'`) 로 채널 인증이 거부된 경우, 또는 요청한 채널의 인증 콜백이 `false` 를 반환한 경우(권한 부족·타 사용자 채널 구독 시도 등) | diff --git a/docs/backend/api/core-update.md b/docs/backend/api/core-update.md index 17783884..a2b60ab0 100644 --- a/docs/backend/api/core-update.md +++ b/docs/backend/api/core-update.md @@ -95,11 +95,31 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| update_available | boolean | `false` | 새 버전 존재 여부 (최신 릴리스 버전이 현재 버전보다 높으면 `true`) | +| current_version | string | `7.0.3` | 현재 설치된 코어 버전 (`config('app.version')`) | +| latest_version | string | `7.0.3` | GitHub 릴리스에서 조회한 최신 코어 버전 (조회 값이 없으면 현재 버전과 동일) | +| github_url | string | `https://github.com/gnuboard/g7` | 업데이트 조회 대상 GitHub 저장소 URL (`config('app.update.github_url')`) | + +> 조회 실패 시(GitHub 접속 불가 등)에는 422 응답이 반환되며, `error` 객체에 `reason`(실패 사유) · `current_version` · `github_url` 이 담깁니다. **응답 예시** - +```json +{ + "success": true, + "message": "업데이트 확인이 완료되었습니다.", + "data": { + "update_available": false, + "current_version": "7.0.3", + "latest_version": "7.0.3", + "github_url": "https://github.com/gnuboard/g7" + } +} +``` **에러 응답** diff --git a/docs/backend/api/dashboard.md b/docs/backend/api/dashboard.md index b4687944..cc9ed775 100644 --- a/docs/backend/api/dashboard.md +++ b/docs/backend/api/dashboard.md @@ -141,9 +141,21 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) +_목록 응답: `data` 배열 항목의 필드. `data` 는 `core.dashboard.alerts` 필터 훅의 결과이며, 알릴 항목이 없으면 빈 배열(`[]`)입니다. 코어 기본 리스너(`ExtensionCompatibilityAlertListener`)가 주입하는 항목의 필드는 다음과 같습니다._ - - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | string | `compat_plugins_sirsoft-gdpr` | 알림 식별자 (`compat_{type}_{identifier}` = 자동 비활성화, `recover_{type}_{identifier}` = 재호환. 알림 닫기(dismiss) 상태 판정 키) | +| type | string | `warning` | 알림 등급 (`warning`: 코어 비호환 자동 비활성화, `info`: 재호환 복구 가능) | +| subtype | string | `incompatible_core` | 알림 세부 분류 (`incompatible_core`: 코어 버전 비호환으로 자동 비활성화됨, `recovery_available`: 코어 업그레이드 후 다시 활성화 가능) | +| icon | string | `exclamation-triangle` | 아이콘 식별자 (warning: `exclamation-triangle`, info: `check-circle`) | +| title | string | `플러그인 "sirsoft-gdpr" 자동 비활성화됨` | 알림 제목 (다국어 문구 — `extensions.alerts.incompatible_deactivated` / `recovered_title`) | +| message | string | `필요 버전: 7.0.0-beta.9, 현재 설치됨: 7.0.0-beta.8` | 알림 본문 (다국어 문구 — `extensions.alerts.incompatible_message` / `recovered_body`) | +| extension_type | string | `plugin` | 대상 확장 종류 (`module` / `plugin` / `template`) | +| identifier | string | `sirsoft-gdpr` | 대상 확장 식별자 | +| recover_endpoint | string | `/api/admin/extensions/plugin/sirsoft-gdpr/recover` | 재활성화 호출 엔드포인트 (재호환 알림 `subtype=recovery_available` 에만 존재) | +| time | string\|null | `3시간 전` | 자동 비활성화 시각의 상대 표시 (diffForHumans() 산물, 비활성화 시각이 없으면 `null`) | +| read | boolean | `false` | 읽음 여부 (주입 시점에는 항상 `false`) | **응답 예시** @@ -151,6 +163,8 @@ Authorization: Bearer {YOUR_TOKEN} HTTP/1.1 200 ``` +알릴 항목이 없는 경우 (기본): + ```json { "success": true, @@ -159,6 +173,42 @@ HTTP/1.1 200 } ``` +코어 비호환으로 자동 비활성화된 확장이 있는 경우: + +```json +{ + "success": true, + "message": "시스템 알림을 성공적으로 조회했습니다.", + "data": [ + { + "id": "compat_plugins_sirsoft-gdpr", + "type": "warning", + "subtype": "incompatible_core", + "icon": "exclamation-triangle", + "title": "플러그인 \"sirsoft-gdpr\" 자동 비활성화됨", + "message": "필요 버전: 7.0.0-beta.9, 현재 설치됨: 7.0.0-beta.8", + "extension_type": "plugin", + "identifier": "sirsoft-gdpr", + "time": "3시간 전", + "read": false + }, + { + "id": "recover_modules_sirsoft-board", + "type": "info", + "subtype": "recovery_available", + "icon": "check-circle", + "title": "모듈 \"sirsoft-board\" 다시 호환 가능", + "message": "코어 업그레이드 후 호환됩니다 (이전 요구: 7.0.0-beta.9). 다시 활성화할 수 있습니다.", + "extension_type": "module", + "identifier": "sirsoft-board", + "recover_endpoint": "/api/admin/extensions/module/sirsoft-board/recover", + "time": "2일 전", + "read": false + } + ] +} +``` + **에러 응답** | 상태코드 | 의미 | 발생 조건 | diff --git a/docs/backend/api/extensions.md b/docs/backend/api/extensions.md index 10bfd426..7e0a2c8a 100644 --- a/docs/backend/api/extensions.md +++ b/docs/backend/api/extensions.md @@ -102,11 +102,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| alert_id | string | `compat_plugins_sirsoft-tosspayments` | dismiss 처리된 자동 비활성화 알림의 식별자 (`compat_{type}s_{identifier}` 형식). 재호환 알림(`recover_{type}s_{identifier}`)도 함께 dismiss 되지만 응답에는 포함되지 않음 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림을 닫았습니다.", + "data": { + "alert_id": "compat_plugins_sirsoft-tosspayments" + } +} +``` **에러 응답** @@ -145,11 +161,29 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| extension_type | string | `plugin` | 복구(재활성화)된 확장의 타입 (`module` \| `plugin` \| `template`) — 요청 path의 `{type}` 을 그대로 반환 | +| identifier | string | `sirsoft-tosspayments` | 복구된 확장의 식별자 — 요청 path의 `{identifier}` 를 그대로 반환 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "확장이 다시 활성화되었습니다.", + "data": { + "extension_type": "plugin", + "identifier": "sirsoft-tosspayments" + } +} +``` **에러 응답** @@ -157,7 +191,8 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.activate`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 (`extensions.errors.not_found`) | +| 422 | Unprocessable Entity | 재검증 결과 여전히 코어 버전이 요구 버전에 미달하는 경우 — 글로벌 핸들러가 `core_version_mismatch` 로 변환 | diff --git a/docs/backend/api/identity.md b/docs/backend/api/identity.md index 40c4e390..9a842161 100644 --- a/docs/backend/api/identity.md +++ b/docs/backend/api/identity.md @@ -93,7 +93,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": { "data": [ { @@ -221,7 +221,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": { "purged_count": 200, "older_than_days": 1 @@ -414,11 +414,99 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 정의를 `IdentityMessageDefinitionResource` 로 직렬화, `templates` eager load 포함). 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `13` | 기본 키 (내부 식별자) | +| provider_id | string | `g7:core.mail` | 이 정의가 속한 IDV 프로바이더 ID | +| scope_type | string | `policy` | 메시지 정의 스코프 (관리자 생성은 항상 `policy`) | +| scope_value | string | `admin.custom_action` | 스코프 값 (source_type='admin' 인 IdentityPolicy.key) | +| name | object | `{"ko":"...","en":"..."}` | 다국어 표시명 | +| description | object | `{"ko":"...","en":"..."}` | 다국어 설명 (미전달 시 null) | +| channels | array | `["mail"]` | 이 정의가 지원하는 활성 채널 목록 | +| variables | array | `[{"key":"code","description":"인증 코드"}]` | 템플릿 치환 가능 변수 메타데이터 목록 (미전달 시 null) | +| extension_type | string | `core` | 이 리소스를 소유한 확장의 타입 (관리자 생성은 core) | +| extension_identifier | string | `core` | 이 리소스를 소유한 확장의 식별자 | +| is_active | boolean | `true` | 활성 여부 | +| is_default | boolean | `false` | 시드 기본 정의 여부 (관리자 생성 정의는 false → 삭제 가능) | +| user_overrides | array | `null` | 운영자가 시드 기본값에서 수정한 필드 경로 목록 (신규 생성 직후 null) | +| templates | array | `[{"id":13,"definition_id":13,"channel":"mail","subject":{…},"body":{…}}]` | 함께 생성된 채널별 하위 템플릿 목록 | +| created_at | string | `2026-07-08 10:43:32` | 생성 일시 | +| updated_at | string | `2026-07-08 10:43:32` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "IDV 메시지 정의가 생성되었습니다.", + "data": { + "id": 13, + "provider_id": "g7:core.mail", + "scope_type": "policy", + "scope_value": "admin.custom_action", + "name": { + "ko": "커스텀 민감작업 인증", + "en": "Custom Sensitive Action" + }, + "description": { + "ko": "운영자가 등록한 정책 전용 인증 메일", + "en": "Policy-specific verification mail added by admin" + }, + "channels": [ + "mail" + ], + "variables": [ + { + "key": "code", + "description": "인증 코드 (text_code 흐름)" + } + ], + "extension_type": "core", + "extension_identifier": "core", + "is_active": true, + "is_default": false, + "user_overrides": null, + "templates": [ + { + "id": 13, + "definition_id": 13, + "channel": "mail", + "subject": { + "ko": "[{app_name}] 인증 코드", + "en": "[{app_name}] Verification Code" + }, + "body": { + "ko": "

인증 코드: {code}

", + "en": "

Your code: {code}

" + }, + "is_active": true, + "is_default": false, + "user_overrides": null, + "updated_by": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -427,6 +515,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 정의 생성 중 예외 발생 (`IDV 메시지 정의 생성에 실패했습니다.`) | @@ -456,20 +545,26 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `$this->success(__('identity_message.definition_deleted'))` 를 인자 1개로 호출)._ **응답 예시** - +```json +{ + "success": true, + "message": "IDV 메시지 정의가 삭제되었습니다." +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우, 또는 대상 정의가 시드 기본 정의(`is_default=true`)인 경우 (`시드된 기본 메시지 정의는 삭제할 수 없습니다.`) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 삭제 중 예외 발생 (`IDV 메시지 정의 삭제에 실패했습니다.`) | @@ -670,20 +765,111 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 정의를 `IdentityMessageDefinitionResource` 로 직렬화, `templates` eager load 포함 — GET 상세 응답과 동일 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| provider_id | string | `g7:core.mail` | IDV 프로바이더 ID | +| scope_type | string | `purpose` | 메시지 정의 스코프 (provider_default\|purpose\|policy) | +| scope_value | string | `checkout_verification` | 범위 값 (provider_default 빈 문자열 / purpose 키 / policy 키) | +| name | object | `{"ko":"결제 시 본인 확인","en":"Checkout Verification"}` | 다국어 표시명 (수정 반영) | +| description | object | `{"ko":"...","en":"..."}` | 다국어 설명 (수정 반영) | +| channels | array | `["mail"]` | 활성 채널 목록 (수정 반영) | +| variables | array | `[{"key":"code","description":"인증 코드 (text_code 흐름)"}]` | 템플릿 치환 변수 메타데이터 목록 | +| extension_type | string | `module` | 이 리소스를 소유한 확장의 타입 | +| extension_identifier | string | `sirsoft-ecommerce` | 이 리소스를 소유한 확장의 식별자 | +| is_active | boolean | `true` | 활성 여부 (수정 반영) | +| is_default | boolean | `true` | 시드 기본 정의 여부 | +| user_overrides | array | `["name","channels"]` | 이번 수정으로 append 된 운영자 재정의 필드 목록 (시더 재실행 시 보존 대상) | +| templates | array | `[{"id":1,"definition_id":1,"channel":"mail",…}]` | 채널별 하위 템플릿 목록 | +| created_at | string | `2026-07-08 10:43:32` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:36` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "IDV 메시지 정의가 수정되었습니다.", + "data": { + "id": 1, + "provider_id": "g7:core.mail", + "scope_type": "purpose", + "scope_value": "checkout_verification", + "name": { + "ko": "결제 시 본인 확인", + "en": "Checkout Verification" + }, + "description": { + "ko": "결제 진행 전 본인/성인 확인 인증 코드 메일", + "en": "Identity/adult verification code mail before checkout" + }, + "channels": [ + "mail" + ], + "variables": [ + { + "key": "code", + "description": "인증 코드 (text_code 흐름)" + } + ], + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "is_active": true, + "is_default": true, + "user_overrides": [ + "name" + ], + "templates": [ + { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": { + "ko": "[{app_name}] 결제 본인 확인 인증 코드", + "en": "[{app_name}] Checkout Verification Code" + }, + "body": { + "ko": "

결제 본인 확인

…", + "en": "

Checkout Verification

…" + }, + "is_active": true, + "is_default": true, + "user_overrides": null, + "updated_by": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 12:14:36", + "abilities": { + "can_update": true, + "can_delete": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 수정 중 예외 발생 (`IDV 메시지 정의 수정에 실패했습니다.`) | @@ -1002,19 +1188,38 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`IdentityMessageTemplate::replaceVariables()` 의 반환값 — 저장 없이 렌더 결과만 반환)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| subject | string | `[G7] 결제 본인 확인 인증 코드` | 지정 로케일 제목에 `{key}` 변수를 `data` 값으로 치환한 결과 문자열 | +| body | string | `

결제 본인 확인

… 123456 …

` | 지정 로케일 본문(HTML)에 `{key}` 변수를 치환한 결과 문자열 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "IDV 메시지 템플릿 미리보기를 생성했습니다.", + "data": { + "subject": "[G7] 결제 본인 확인 인증 코드", + "body": "

결제 본인 확인

결제를 진행하기 위해 본인 확인이 필요합니다. 아래 인증 코드를 입력해 주세요.

123456

이 코드는 5분 후 만료됩니다.

" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | `template_id` 에 해당하는 템플릿이 없거나(`findOrFail`) 렌더 중 예외 발생 (`IDV 메시지 템플릿 미리보기 생성에 실패했습니다.`) | @@ -1060,20 +1265,71 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 템플릿을 `IdentityMessageTemplateResource` 로 직렬화 — templates reset 응답과 동일 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| definition_id | integer | `1` | 이 템플릿이 속한 메시지 정의 ID | +| channel | string | `mail` | 메시지 템플릿 채널 (mail 현재 / sms 등 향후) | +| subject | object | `{"ko":"[{app_name}] 결제 본인 확인 인증 코드","en":"…"}` | 다국어 제목 (수정 반영, mail 채널에서만 의미) | +| body | object | `{"ko":"

결제 본인 확인

…","en":"…"}` | 다국어 본문 (수정 반영) | +| is_active | boolean | `true` | 활성 여부 (수정 반영) | +| is_default | boolean | `false` | 시더 기본값 유지 여부 (운영자 수정 시 false) | +| user_overrides | array | `["subject","body"]` | 이번 수정으로 append 된 운영자 재정의 필드 목록 | +| updated_by | null | `null` | 최종 수정한 사용자 정보 (uuid/name — 없으면 null) | +| created_at | string | `2026-07-08 10:43:32` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:36` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "IDV 메시지 템플릿이 수정되었습니다.", + "data": { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": { + "ko": "[{app_name}] 결제 본인 확인 인증 코드", + "en": "[{app_name}] Checkout Verification Code" + }, + "body": { + "ko": "

결제 본인 확인

아래 인증 코드를 입력해 주세요.

{code}

", + "en": "

Checkout Verification

Please enter the code below.

{code}

" + }, + "is_active": true, + "is_default": false, + "user_overrides": [ + "subject", + "body" + ], + "updated_by": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 12:14:36", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 수정 중 예외 발생 (`IDV 메시지 템플릿 수정에 실패했습니다.`) | @@ -1325,7 +1581,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": { "data": [ { @@ -1458,19 +1714,72 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 정책을 `PolicyResource` 로 직렬화). 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `25` | 기본 키 (내부 식별자) | +| key | string | `admin.custom_action` | 정책 식별자 (고유) | +| scope | string | `route` | 정책 적용 범위 (route / hook / custom — IdentityPolicyScope) | +| target | string | `api.me.password.update` | 매칭 대상 (라우트명/URI 패턴, 훅 이름, 또는 custom key) | +| purpose | string | `sensitive_action` | 이 정책이 요구하는 인증 목적 | +| provider_id | null | `null` | 강제할 프로바이더 ID (미지정 시 null → 목적 기본 프로바이더 사용) | +| grace_minutes | integer | `30` | 재인증 유예 시간(분) — 0=매번 요구 | +| enabled | boolean | `true` | 정책 사용 여부 | +| priority | integer | `100` | 정책 우선순위 (작을수록 우선) | +| conditions | object | `{"changed_fields":["email"]}` | 추가 매칭 조건 JSON (미지정 시 null) | +| source_type | string | `admin` | 정책 출처 — 이 엔드포인트로 생성한 정책은 항상 `admin` | +| source_identifier | string | `null` | 출처 식별자 (요청에서 전달한 값, 미전달 시 null) | +| applies_to | string | `both` | 적용 대상 사용자 (self / admin / both) | +| fail_mode | string | `block` | 실패 시 동작 (block: HTTP 428 차단 / log_only: 감사 로그만) | +| user_overrides | array | `[]` | 운영자가 선언 기본값에서 재정의한 필드 목록 (admin 정책은 빈 배열) | +| created_at | string | `2026-07-08 10:44:35` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:35` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "생성되었습니다.", + "data": { + "id": 25, + "key": "admin.custom_action", + "scope": "route", + "target": "api.me.password.update", + "purpose": "sensitive_action", + "provider_id": null, + "grace_minutes": 30, + "enabled": true, + "priority": 100, + "conditions": null, + "source_type": "admin", + "source_identifier": null, + "applies_to": "both", + "fail_mode": "block", + "user_overrides": [], + "created_at": "2026-07-08 10:44:35", + "updated_at": "2026-07-08 10:44:35", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — `key` 중복, `scope`/`applies_to`/`fail_mode` 허용값 위반 등) | @@ -1502,20 +1811,30 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `$this->success('messages.deleted')` 를 인자 1개로 호출)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "삭제되었습니다." +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.update`)이 없는 경우, 또는 대상 정책의 `source_type` 이 `admin` 이 아닌 선언형 정책(core/module/plugin)인 경우 (`messages.cannot_delete_system_resource`) | +| 404 | Not Found | 해당 ID 의 정책이 없는 경우 (`messages.not_found`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 삭제 처리 실패 (`messages.failed`) | @@ -1575,20 +1894,77 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정 후 `refresh()` 한 정책을 `PolicyResource` 로 직렬화 — 목록 항목과 동일 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `11` | 기본 키 (내부 식별자) | +| key | string | `sirsoft-board.report.create` | 정책 식별자 (선언형 정책은 변경 불가) | +| scope | string | `hook` | 정책 적용 범위 (선언형 정책은 변경 불가) | +| target | string | `sirsoft-board.report.before_create` | 매칭 대상 (선언형 정책은 변경 불가) | +| purpose | string | `sensitive_action` | 이 정책이 요구하는 인증 목적 (수정 반영) | +| provider_id | null | `null` | 강제할 프로바이더 ID (수정 반영) | +| grace_minutes | integer | `30` | 재인증 유예 시간(분) (수정 반영) | +| enabled | boolean | `true` | 정책 사용 여부 (수정 반영) | +| priority | integer | `100` | 정책 우선순위 (수정 반영) | +| conditions | object | `null` | 추가 매칭 조건 JSON (수정 반영) | +| source_type | string | `module` | 정책 출처 (core/module/plugin/admin) | +| source_identifier | string | `sirsoft-board` | 출처 식별자 | +| applies_to | string | `self` | 적용 대상 사용자 (수정 반영) | +| fail_mode | string | `block` | 실패 시 동작 (수정 반영) | +| user_overrides | array | `["enabled","grace_minutes"]` | 이번 수정으로 append 된 운영자 재정의 필드 목록 (시더 재실행 시 보존) | +| created_at | string | `2026-07-08 10:44:35` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:36` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "수정되었습니다.", + "data": { + "id": 11, + "key": "sirsoft-board.report.create", + "scope": "hook", + "target": "sirsoft-board.report.before_create", + "purpose": "sensitive_action", + "provider_id": null, + "grace_minutes": 30, + "enabled": true, + "priority": 100, + "conditions": null, + "source_type": "module", + "source_identifier": "sirsoft-board", + "applies_to": "self", + "fail_mode": "block", + "user_overrides": [ + "enabled", + "grace_minutes" + ], + "created_at": "2026-07-08 10:44:35", + "updated_at": "2026-07-08 12:14:36", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.update`)이 없는 경우 | +| 404 | Not Found | 해당 ID 의 정책이 없는 경우 (`messages.not_found`) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우, 또는 선언형 정책에 화이트리스트(`enabled`/`grace_minutes`/`provider_id`/`fail_mode`/`conditions`/`purpose`/`applies_to`/`priority`) 밖 필드만 전달되어 적용할 값이 남지 않은 경우 (`validation.nothing_to_update`) | +| 500 | Internal Server Error | 수정 처리 실패 (`messages.failed`) | @@ -1626,20 +2002,73 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (복원 후 `fresh()` 한 정책을 `PolicyResource` 로 직렬화 — 목록 항목과 동일 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `11` | 기본 키 (내부 식별자) | +| key | string | `sirsoft-board.report.create` | 정책 식별자 | +| scope | string | `hook` | 정책 적용 범위 (route / hook / custom) | +| target | string | `sirsoft-board.report.before_create` | 매칭 대상 | +| purpose | string | `sensitive_action` | 이 정책이 요구하는 인증 목적 | +| provider_id | null | `null` | 강제할 프로바이더 ID | +| grace_minutes | integer | `0` | 재인증 유예 시간(분) — 요청한 `field` 가 이 필드였다면 선언 기본값으로 복원된 값 | +| enabled | boolean | `false` | 정책 사용 여부 — 요청한 `field` 가 이 필드였다면 선언 기본값으로 복원된 값 | +| priority | integer | `100` | 정책 우선순위 | +| conditions | object | `null` | 추가 매칭 조건 JSON | +| source_type | string | `module` | 정책 출처 (admin 이면 이 엔드포인트는 403) | +| source_identifier | string | `sirsoft-board` | 출처 식별자 | +| applies_to | string | `self` | 적용 대상 사용자 | +| fail_mode | string | `block` | 실패 시 동작 | +| user_overrides | array | `[]` | 남아 있는 운영자 재정의 필드 목록 (요청한 `field` 가 제거된 상태) | +| created_at | string | `2026-07-08 10:44:35` | 생성 일시 | +| updated_at | string | `2026-07-08 12:20:11` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 11, + "key": "sirsoft-board.report.create", + "scope": "hook", + "target": "sirsoft-board.report.before_create", + "purpose": "sensitive_action", + "provider_id": null, + "grace_minutes": 0, + "enabled": false, + "priority": 100, + "conditions": null, + "source_type": "module", + "source_identifier": "sirsoft-board", + "applies_to": "self", + "fail_mode": "block", + "user_overrides": [], + "created_at": "2026-07-08 10:44:35", + "updated_at": "2026-07-08 12:20:11", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.update`)이 없는 경우, 또는 대상 정책의 `source_type` 이 `admin` 이라 선언 기본값이 존재하지 않는 경우 (`관리자가 직접 생성한 정책에는 선언 기본값이 없습니다.`) | +| 404 | Not Found | 해당 ID 의 정책이 없는 경우 (`messages.not_found`) | +| 422 | Unprocessable Entity | `field` 가 허용값 밖이거나 복원에 실패한 경우 (`선언 기본값 복원에 실패했습니다. 필드가 유효한지 확인하세요.`) | @@ -1687,7 +2116,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": [ { "id": "g7:core.mail", @@ -1831,20 +2260,49 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (성공 + `return` 쿼리가 없을 때만 JSON 을 반환합니다. `return` 이 있고 same-origin 이면 302 redirect 이므로 본문이 없습니다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| challenge_id | string | `00484973-8cd3-4a1d-85f2-78361feb6f0d` | 검증이 완료된 challenge 의 식별자 | +| provider_id | string | `inicis` | 이 콜백을 처리한 프로바이더 식별자 | +| verified_at | string | `2026-05-12T18:14:19+00:00` | 검증 완료 일시 (ISO 8601, 미검증이면 null) | +| verification_token | string | `eyJ0eXAiOiJKV1QiLCJhbGciOi…` | 후속 민감 작업 요청에 제출할 본인인증 토큰 (`claims.verification_token`, 없으면 빈 문자열) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "본인 확인이 완료되었습니다.", + "data": { + "challenge_id": "00484973-8cd3-4a1d-85f2-78361feb6f0d", + "provider_id": "inicis", + "verified_at": "2026-05-12T18:14:19+00:00", + "verification_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." + } +} +``` + +`return` 쿼리(same-origin) 가 있으면 JSON 대신 302 redirect 입니다. + +```http +HTTP/1.1 302 +Location: /checkout?verification_token=eyJ0eXAiOiJKV1Qi...&challenge_id=00484973-8cd3-4a1d-85f2-78361feb6f0d +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 302 | Found (실패 redirect) | 검증 실패 + `return` 쿼리(same-origin) 존재 시 `{return}?identity_error={failure_code}` 로 리다이렉트 | +| 401 | Unauthenticated | Bearer 토큰을 보냈으나 만료/무효인 경우 (비회원은 헤더 생략 가능) | +| 404 | Not Found | `providerId` 에 해당하는 프로바이더가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 검증 실패 + `return` 쿼리 없음 — 본문의 `error.failure_code` 에 실패 사유 코드 (`CHALLENGE_NOT_FOUND`/`EXPIRED`/`INVALID_CODE` 등, `identity.errors.*` 메시지 동반) | @@ -1863,8 +2321,8 @@ Content-Type: application/json | --- | --- | --- | --- | --- | --- | | purpose | body | string | 예 | max 64 | 인증 목적 (signup/password_reset/self_update/sensitive_action/login 또는 모듈 정의 목적) | | target | body | array | 아니오 | — | 비로그인 게스트의 인증 대상 (target.email 또는 target.phone — 로그인 사용자는 본인으로 자동 설정) | -| target.email | body | email | 아니오 | max 255 | | -| target.phone | body | string | 아니오 | max 32 | | +| target.email | body | email | 아니오 | max 255 | 이메일 주소 | +| target.phone | body | string | 아니오 | max 32 | 전화번호 | | provider_id | body | string | 아니오 | max 64 | provider 식별자 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.request_validation_rules`). @@ -1891,19 +2349,51 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ChallengeResource` 로 직렬화한 VerificationChallenge DTO — 민감 metadata(code_hash 등) 제외). 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | string | `00484973-8cd3-4a1d-85f2-78361feb6f0d` | 생성된 challenge 의 식별자 (이후 verify/cancel/show 에 사용) | +| provider_id | string | `g7:core.mail` | 이 challenge 를 처리하는 프로바이더 식별자 | +| purpose | string | `signup` | 인증 목적 | +| channel | string | `email` | 실제 발송에 사용된 전송 채널 | +| render_hint | string | `text_code` | 프론트 렌더 방식 힌트 (text_code / link / external_redirect) | +| redirect_url | string | `null` | external_redirect 흐름에서 사용자를 보낼 외부 인증 페이지 URL (그 외 흐름은 null) | +| expires_at | string | `2026-05-12T18:14:19+00:00` | 만료 일시 (ISO 8601) | +| public_payload | array | `[]` | 프론트 렌더에 필요한 공개 안전 페이로드 (프로바이더별 UI 힌트, 없으면 빈 배열) | +| max_attempts | integer | `5` | 허용되는 최대 검증 시도 횟수 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "본인인증 코드를 발송했습니다.", + "data": { + "id": "00484973-8cd3-4a1d-85f2-78361feb6f0d", + "provider_id": "g7:core.mail", + "purpose": "signup", + "channel": "email", + "render_hint": "text_code", + "redirect_url": null, + "expires_at": "2026-05-12T18:14:19+00:00", + "public_payload": [], + "max_attempts": 5 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 401 | Unauthenticated | Bearer 토큰을 보냈으나 만료/무효인 경우 (비회원은 헤더 생략 가능) | +| 403 | Forbidden | 요구 권한(`core.identity.request`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 비로그인 상태에서 `target.email`·`target.phone` 이 모두 비어 있는 경우 (`인증 대상(이메일·전화번호)이 필요합니다.`), 선택한 프로바이더가 해당 목적을 지원하지 않는 경우(`선택된 프로바이더는 이 목적을 지원하지 않습니다.`), 프로바이더 사용 불가(`본인인증 프로바이더를 사용할 수 없습니다.`) | @@ -1957,7 +2447,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": { "id": "006209fd-1b6c-40aa-a38f-9b907f1a4bca", "status": "requested", @@ -2008,19 +2498,28 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `$this->success('identity.messages.challenge_cancelled')` 를 인자 1개로 호출)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "본인인증 요청이 취소되었습니다." +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 401 | Unauthenticated | Bearer 토큰을 보냈으나 만료/무효인 경우 (비회원은 헤더 생략 가능) | +| 403 | Forbidden | 요구 권한(`core.identity.cancel`)이 없는 경우, 또는 scope=self 가드가 본인 challenge 가 아니라고 판정한 경우 | +| 404 | Not Found | path 의 challenge 를 찾을 수 없거나 취소 처리에 실패한 경우 (`유효하지 않은 인증 요청입니다.`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -2061,20 +2560,42 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (컨트롤러가 VerificationResult DTO 에서 직접 조립)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| challenge_id | string | `00484973-8cd3-4a1d-85f2-78361feb6f0d` | 검증이 완료된 challenge 의 식별자 | +| provider_id | string | `g7:core.mail` | 이 challenge 를 처리한 프로바이더 식별자 | +| verified_at | string | `2026-05-12T18:14:19+00:00` | 검증 완료 일시 (ISO 8601, 없으면 null) | +| verification_token | string | `eyJ0eXAiOiJKV1QiLCJhbGciOi…` | 후속 민감 작업 요청에 제출할 본인인증 토큰 (`claims.verification_token`, 없으면 null) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "본인 확인이 완료되었습니다.", + "data": { + "challenge_id": "00484973-8cd3-4a1d-85f2-78361feb6f0d", + "provider_id": "g7:core.mail", + "verified_at": "2026-05-12T18:14:19+00:00", + "verification_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 401 | Unauthenticated | Bearer 토큰을 보냈으나 만료/무효인 경우 (비회원은 헤더 생략 가능) | +| 403 | Forbidden | 요구 권한(`core.identity.verify`)이 없는 경우, 또는 scope=self 가드가 본인 challenge 가 아니라고 판정한 경우 | +| 404 | Not Found | path 파라미터에 해당하는 challenge 가 없는 경우 | +| 422 | Unprocessable Entity | 검증 실패 — 응답 `error` 에 `failure_code`(예: `INVALID_CODE`/`EXPIRED`/`MAX_ATTEMPTS`) 와 서버 기준 `attempts`/`max_attempts` 를 함께 반환. 메시지는 `identity.errors.*` (`인증 코드가 올바르지 않습니다.` / `인증 시간이 만료되었습니다. 다시 시도해주세요.` / `시도 횟수를 초과했습니다. 다시 요청해주세요.` 등) | @@ -2105,19 +2626,58 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (매칭·활성 정책이 없으면 `data` 는 `null`). 컨트롤러가 UI 힌트용 최소 필드만 직접 조립하며 민감 필드는 노출하지 않습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| policy_key | string | `sirsoft-board.report.create` | 매칭된 정책의 식별자 | +| scope | string | `hook` | 정책 적용 범위 (route / hook / custom) | +| target | string | `sirsoft-board.report.before_create` | 매칭 대상 | +| purpose | string | `sensitive_action` | 이 정책이 요구하는 인증 목적 | +| provider_id | null | `null` | 강제할 프로바이더 ID (미지정 시 null) | +| grace_minutes | integer | `30` | 재인증 유예 시간(분) — 0=매번 요구 | +| applies_to | string | `self` | 적용 대상 사용자 (self / admin / both) | +| fail_mode | string | `block` | 실패 시 동작 (block: HTTP 428 차단 / log_only: 감사 로그만) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "policy_key": "sirsoft-board.report.create", + "scope": "hook", + "target": "sirsoft-board.report.before_create", + "purpose": "sensitive_action", + "provider_id": null, + "grace_minutes": 30, + "applies_to": "self", + "fail_mode": "block" + } +} +``` + +매칭 정책이 없거나 `enabled=false` 인 경우: + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 401 | Unauthenticated | Bearer 토큰을 보냈으나 만료/무효인 경우 (비회원은 헤더 생략 가능) | +| 422 | Unprocessable Entity | 필수 쿼리 `scope`/`target` 누락 또는 길이 초과 (`error.errors` 에 필드별 메시지) | @@ -2163,7 +2723,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": [ { "id": "g7:core.mail", @@ -2251,7 +2811,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": [ { "id": "signup", diff --git a/docs/backend/api/language-packs.md b/docs/backend/api/language-packs.md index 4fab1a73..a8cbb02f 100644 --- a/docs/backend/api/language-packs.md +++ b/docs/backend/api/language-packs.md @@ -248,11 +248,41 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`LanguagePackService::bulkActivate()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| succeeded | array | `[3, 5]` | 활성화에 성공한 언어팩 ID 목록 | +| failed | array | `[{"id": 7, "reason": "not_found"}]` | 활성화에 실패한 항목 목록 (요소: `id`, `reason`) | +| failed[].id | integer\|string | `7` | 실패한 항목의 요청 ID (요청 `ids` 원본 값) | +| failed[].reason | string | `not_found` | 실패 사유. 대상 미존재 시 `not_found`, 그 외에는 발생한 예외 메시지 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "language_packs.bulk_activate_success", + "data": { + "succeeded": [ + 3, + 5 + ], + "failed": [ + { + "id": 7, + "reason": "not_found" + } + ] + } +} +``` + +> `message` 는 `language_packs.bulk_activate_success` 키로 응답하지만, 현재 `lang/ko/language_packs.php` 에 해당 키가 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 내려갑니다. **에러 응답** @@ -288,11 +318,51 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`LanguagePackService::checkUpdates()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| checked | integer | `3` | 실제로 검사한 언어팩 수 (GitHub URL 해석 가능하거나 번들 매니페스트가 있는 팩만 대상) | +| updates | integer | `1` | 검사 결과 업데이트가 있는 언어팩 수 | +| details | array | `[{"identifier": "g7-core-ja", "current": "1.0.0", "latest": "1.1.0", "has_update": true, "error": null}]` | 팩별 검사 결과 목록 | +| details[].identifier | string | `g7-core-ja` | 검사 대상 언어팩 식별자 | +| details[].current | string | `1.0.0` | 현재 설치된 버전 | +| details[].latest | string\|null | `1.1.0` | 감지된 최신 버전 (GitHub 릴리스 태그 → 실패 시 번들 매니페스트 폴백, 둘 다 없으면 null) | +| details[].has_update | boolean | `true` | `latest > current` 여부 | +| details[].error | string\|null | `null` | GitHub 조회 중 발생한 오류 메시지 (성공 시 null) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "업데이트 확인을 완료했습니다.", + "data": { + "checked": 3, + "updates": 1, + "details": [ + { + "identifier": "g7-core-ja", + "current": "1.0.0", + "latest": "1.1.0", + "has_update": true, + "error": null + }, + { + "identifier": "apidoc-sample-lang", + "current": "1.0.0", + "latest": "1.0.0", + "has_update": false, + "error": null + } + ] + } +} +``` **에러 응답** @@ -301,6 +371,7 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 업데이트 확인 중 예외 발생 (`업데이트 확인에 실패했습니다: :error` — `language_packs.check_updates_failed`) | @@ -337,11 +408,93 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (설치된 언어팩 = `LanguagePackResource::toArray()`, 상태코드 201)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| identifier | string | `g7-core-ja` | 언어팩 고유 식별자 ({vendor}-{scope}-{target?}-{locale}) | +| vendor | string | `g7` | 언어팩 제작자 식별자 | +| scope | string | `core` | 적용 대상 분류 (core/module/plugin/template) | +| target_identifier | string\|null | `null` | 대상 확장 식별자 (scope=core 일 때 null) | +| locale | string | `ja` | IETF BCP-47 locale 태그 | +| locale_name | string | `Japanese` | 영문 언어명 | +| locale_native_name | string | `日本語` | 원어 언어명 | +| text_direction | string | `ltr` | 텍스트 방향 (ltr/rtl) | +| version | string | `1.0.0` | 설치된 언어팩 버전 | +| latest_version | string\|null | `1.0.0` | 감지된 최신 배포 버전 | +| target_version_constraint | string\|null | `null` | 대상 확장 버전 제약 (semver) | +| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 | +| name | string\|null | `일본어 언어팩` | manifest `name` 의 현재 로케일 해석값 | +| license | string\|null | `MIT` | 라이선스 | +| description | string\|null | `코어 일본어 번역` | 언어팩 설명 (다국어) | +| status | string | `active` | 언어팩 상태 (auto_activate=true 면 active, 아니면 installed) | +| is_protected | boolean | `false` | 보호(protected) 팩 여부 | +| source_type | string | `bundled` | 설치 소스 유형 (zip/github/url/bundled/bundled_with_extension/built_in) | +| origin | string\|null | `bundled` | source_type 에서 파생된 출처 값 (`LanguagePackOrigin`) | +| source_url | string\|null | `null` | 설치 소스 URL 또는 경로 | +| github_url | string\|null | `null` | GitHub 저장소 URL (manifest 파생) | +| github_changelog_url | string\|null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) | +| bundled_identifier | string\|null | `null` | 대응하는 번들 확장 식별자 (번들 원본 매칭용) | +| install_blocked_reason | string\|null | `null` | 설치가 차단된 사유 (차단 없으면 null) | +| target_name | string\|null | `null` | 대상 확장의 표시 이름 (scope+target_identifier 로 해석) | +| installed_at | string\|null | `2026-07-05 10:41:24` | 설치 일시 (사용자 타임존) | +| activated_at | string\|null | `2026-07-05 10:41:24` | 활성화 일시 (사용자 타임존) | +| created_at | string\|null | `2026-07-05 10:41:24` | 생성 일시 | +| updated_at | string\|null | `2026-07-05 10:41:24` | 최종 수정 일시 | +| has_update | boolean | `false` | `latest_version > version` 여부 | +| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall":true}` | 현재 사용자가 이 언어팩에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "언어팩을 설치했습니다.", + "data": { + "id": 1, + "identifier": "g7-core-ja", + "vendor": "g7", + "scope": "core", + "target_identifier": null, + "locale": "ja", + "locale_name": "Japanese", + "locale_native_name": "日本語", + "text_direction": "ltr", + "version": "1.0.0", + "latest_version": "1.0.0", + "target_version_constraint": null, + "target_version_mismatch": false, + "name": "일본어 언어팩", + "license": "MIT", + "description": "코어 일본어 번역", + "status": "active", + "is_protected": false, + "source_type": "bundled", + "origin": "bundled", + "source_url": null, + "github_url": null, + "github_changelog_url": null, + "bundled_identifier": null, + "install_blocked_reason": null, + "target_name": null, + "installed_at": "2026-07-05 10:41:24", + "activated_at": "2026-07-05 10:41:24", + "created_at": "2026-07-05 10:41:24", + "updated_at": "2026-07-05 10:41:24", + "has_update": false, + "abilities": { + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true + } + } +} +``` **에러 응답** @@ -392,11 +545,95 @@ Content-Disposition: form-data; name="auto_activate" **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (설치된 언어팩 = `LanguagePackResource::toArray()`, 상태코드 201). 필드 구성은 [install-from-bundled](#post-apiadminlanguage-packsinstall-from-bundled) 응답과 동일합니다 — `source_type` 은 `zip`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `2` | 기본 키 (내부 식별자) | +| identifier | string | `acme-core-de` | 언어팩 고유 식별자 | +| vendor | string | `acme` | 언어팩 제작자 식별자 | +| scope | string | `core` | 적용 대상 분류 (core/module/plugin/template) | +| target_identifier | string\|null | `null` | 대상 확장 식별자 (scope=core 일 때 null) | +| locale | string | `de` | IETF BCP-47 locale 태그 | +| locale_name | string | `German` | 영문 언어명 | +| locale_native_name | string | `Deutsch` | 원어 언어명 | +| text_direction | string | `ltr` | 텍스트 방향 (ltr/rtl) | +| version | string | `1.0.0` | 설치된 언어팩 버전 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| target_version_constraint | string\|null | `null` | 대상 확장 버전 제약 (semver) | +| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 | +| name | string\|null | `독일어 언어팩` | manifest `name` 의 현재 로케일 해석값 | +| license | string\|null | `MIT` | 라이선스 | +| description | string\|null | `—` | 언어팩 설명 (다국어) | +| status | string | `installed` | 언어팩 상태 (auto_activate=true 면 active) | +| is_protected | boolean | `false` | 보호(protected) 팩 여부 | +| source_type | string | `zip` | 설치 소스 유형 (파일 업로드 설치이므로 zip) | +| origin | string\|null | `zip` | source_type 에서 파생된 출처 값 | +| source_url | string\|null | `null` | 설치 소스 URL 또는 경로 | +| github_url | string\|null | `null` | GitHub 저장소 URL (manifest 파생) | +| github_changelog_url | string\|null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) | +| bundled_identifier | string\|null | `null` | 대응하는 번들 확장 식별자 | +| install_blocked_reason | string\|null | `null` | 설치가 차단된 사유 (차단 없으면 null) | +| target_name | string\|null | `null` | 대상 확장의 표시 이름 | +| installed_at | string\|null | `2026-07-05 10:41:24` | 설치 일시 (사용자 타임존) | +| activated_at | string\|null | `null` | 활성화 일시 (미활성 시 null) | +| created_at | string\|null | `2026-07-05 10:41:24` | 생성 일시 | +| updated_at | string\|null | `2026-07-05 10:41:24` | 최종 수정 일시 | +| has_update | boolean | `false` | `latest_version > version` 여부 | +| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall":true}` | 현재 사용자가 이 언어팩에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "언어팩을 설치했습니다.", + "data": { + "id": 2, + "identifier": "acme-core-de", + "vendor": "acme", + "scope": "core", + "target_identifier": null, + "locale": "de", + "locale_name": "German", + "locale_native_name": "Deutsch", + "text_direction": "ltr", + "version": "1.0.0", + "latest_version": null, + "target_version_constraint": null, + "target_version_mismatch": false, + "name": "독일어 언어팩", + "license": "MIT", + "description": null, + "status": "installed", + "is_protected": false, + "source_type": "zip", + "origin": "zip", + "source_url": null, + "github_url": null, + "github_changelog_url": null, + "bundled_identifier": null, + "install_blocked_reason": null, + "target_name": null, + "installed_at": "2026-07-05 10:41:24", + "activated_at": null, + "created_at": "2026-07-05 10:41:24", + "updated_at": "2026-07-05 10:41:24", + "has_update": false, + "abilities": { + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true + } + } +} +``` + +> manifest 검증 실패 시 422 (`language-pack.json 검증에 실패했습니다.`), 그 외 설치 실패 시 500 (`언어팩 설치에 실패했습니다: :error`) 으로 응답합니다. **에러 응답** @@ -441,11 +678,93 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (설치된 언어팩 = `LanguagePackResource::toArray()`, 상태코드 201). 필드 구성은 install-from-bundled 응답과 동일하며 `source_type` 이 `github`, `source_url`/`github_url` 에 저장소 URL 이 들어갑니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `3` | 기본 키 (내부 식별자) | +| identifier | string | `acme-core-es` | 언어팩 고유 식별자 | +| vendor | string | `acme` | 언어팩 제작자 식별자 | +| scope | string | `core` | 적용 대상 분류 (core/module/plugin/template) | +| target_identifier | string\|null | `null` | 대상 확장 식별자 (scope=core 일 때 null) | +| locale | string | `es` | IETF BCP-47 locale 태그 | +| locale_name | string | `Spanish` | 영문 언어명 | +| locale_native_name | string | `Español` | 원어 언어명 | +| text_direction | string | `ltr` | 텍스트 방향 (ltr/rtl) | +| version | string | `1.0.0` | 설치된 언어팩 버전 (GitHub 최신 릴리스 태그 기준) | +| latest_version | string\|null | `1.0.0` | 감지된 최신 배포 버전 | +| target_version_constraint | string\|null | `null` | 대상 확장 버전 제약 (semver) | +| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 | +| name | string\|null | `스페인어 언어팩` | manifest `name` 의 현재 로케일 해석값 | +| license | string\|null | `MIT` | 라이선스 | +| description | string\|null | `—` | 언어팩 설명 (다국어) | +| status | string | `active` | 언어팩 상태 (auto_activate=true 면 active, 아니면 installed) | +| is_protected | boolean | `false` | 보호(protected) 팩 여부 | +| source_type | string | `github` | 설치 소스 유형 | +| origin | string\|null | `github` | source_type 에서 파생된 출처 값 | +| source_url | string\|null | `https://github.com/acme/g7-core-es` | 설치에 사용한 GitHub 저장소 URL | +| github_url | string\|null | `https://github.com/acme/g7-core-es` | GitHub 저장소 URL (manifest 파생) | +| github_changelog_url | string\|null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) | +| bundled_identifier | string\|null | `null` | 대응하는 번들 확장 식별자 | +| install_blocked_reason | string\|null | `null` | 설치가 차단된 사유 (차단 없으면 null) | +| target_name | string\|null | `null` | 대상 확장의 표시 이름 | +| installed_at | string\|null | `2026-07-05 10:41:24` | 설치 일시 (사용자 타임존) | +| activated_at | string\|null | `2026-07-05 10:41:24` | 활성화 일시 | +| created_at | string\|null | `2026-07-05 10:41:24` | 생성 일시 | +| updated_at | string\|null | `2026-07-05 10:41:24` | 최종 수정 일시 | +| has_update | boolean | `false` | `latest_version > version` 여부 | +| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall":true}` | 현재 사용자가 이 언어팩에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "언어팩을 설치했습니다.", + "data": { + "id": 3, + "identifier": "acme-core-es", + "vendor": "acme", + "scope": "core", + "target_identifier": null, + "locale": "es", + "locale_name": "Spanish", + "locale_native_name": "Español", + "text_direction": "ltr", + "version": "1.0.0", + "latest_version": "1.0.0", + "target_version_constraint": null, + "target_version_mismatch": false, + "name": "스페인어 언어팩", + "license": "MIT", + "description": null, + "status": "active", + "is_protected": false, + "source_type": "github", + "origin": "github", + "source_url": "https://github.com/acme/g7-core-es", + "github_url": "https://github.com/acme/g7-core-es", + "github_changelog_url": null, + "bundled_identifier": null, + "install_blocked_reason": null, + "target_name": null, + "installed_at": "2026-07-05 10:41:24", + "activated_at": "2026-07-05 10:41:24", + "created_at": "2026-07-05 10:41:24", + "updated_at": "2026-07-05 10:41:24", + "has_update": false, + "abilities": { + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true + } + } +} +``` **에러 응답** @@ -494,11 +813,95 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (설치된 언어팩 = `LanguagePackResource::toArray()`, 상태코드 201). 필드 구성은 install-from-bundled 응답과 동일하며 `source_type` 이 `url`, `source_url` 에 다운로드 URL 이 들어갑니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `4` | 기본 키 (내부 식별자) | +| identifier | string | `acme-core-it` | 언어팩 고유 식별자 | +| vendor | string | `acme` | 언어팩 제작자 식별자 | +| scope | string | `core` | 적용 대상 분류 (core/module/plugin/template) | +| target_identifier | string\|null | `null` | 대상 확장 식별자 (scope=core 일 때 null) | +| locale | string | `it` | IETF BCP-47 locale 태그 | +| locale_name | string | `Italian` | 영문 언어명 | +| locale_native_name | string | `Italiano` | 원어 언어명 | +| text_direction | string | `ltr` | 텍스트 방향 (ltr/rtl) | +| version | string | `1.0.0` | 설치된 언어팩 버전 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| target_version_constraint | string\|null | `null` | 대상 확장 버전 제약 (semver) | +| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 | +| name | string\|null | `이탈리아어 언어팩` | manifest `name` 의 현재 로케일 해석값 | +| license | string\|null | `MIT` | 라이선스 | +| description | string\|null | `—` | 언어팩 설명 (다국어) | +| status | string | `active` | 언어팩 상태 (auto_activate=true 면 active, 아니면 installed) | +| is_protected | boolean | `false` | 보호(protected) 팩 여부 | +| source_type | string | `url` | 설치 소스 유형 | +| origin | string\|null | `url` | source_type 에서 파생된 출처 값 | +| source_url | string\|null | `https://cdn.example.com/acme-core-it.zip` | 다운로드에 사용한 URL | +| github_url | string\|null | `null` | GitHub 저장소 URL (manifest 파생) | +| github_changelog_url | string\|null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) | +| bundled_identifier | string\|null | `null` | 대응하는 번들 확장 식별자 | +| install_blocked_reason | string\|null | `null` | 설치가 차단된 사유 (차단 없으면 null) | +| target_name | string\|null | `null` | 대상 확장의 표시 이름 | +| installed_at | string\|null | `2026-07-05 10:41:24` | 설치 일시 (사용자 타임존) | +| activated_at | string\|null | `2026-07-05 10:41:24` | 활성화 일시 | +| created_at | string\|null | `2026-07-05 10:41:24` | 생성 일시 | +| updated_at | string\|null | `2026-07-05 10:41:24` | 최종 수정 일시 | +| has_update | boolean | `false` | `latest_version > version` 여부 | +| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall":true}` | 현재 사용자가 이 언어팩에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "언어팩을 설치했습니다.", + "data": { + "id": 4, + "identifier": "acme-core-it", + "vendor": "acme", + "scope": "core", + "target_identifier": null, + "locale": "it", + "locale_name": "Italian", + "locale_native_name": "Italiano", + "text_direction": "ltr", + "version": "1.0.0", + "latest_version": null, + "target_version_constraint": null, + "target_version_mismatch": false, + "name": "이탈리아어 언어팩", + "license": "MIT", + "description": null, + "status": "active", + "is_protected": false, + "source_type": "url", + "origin": "url", + "source_url": "https://cdn.example.com/acme-core-it.zip", + "github_url": null, + "github_changelog_url": null, + "bundled_identifier": null, + "install_blocked_reason": null, + "target_name": null, + "installed_at": "2026-07-05 10:41:24", + "activated_at": "2026-07-05 10:41:24", + "created_at": "2026-07-05 10:41:24", + "updated_at": "2026-07-05 10:41:24", + "has_update": false, + "abilities": { + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true + } + } +} +``` + +> `checksum` 불일치 시 500 (`체크섬이 일치하지 않습니다.`), manifest 검증 실패 시 422 로 응답합니다. **에러 응답** @@ -544,11 +947,51 @@ Content-Type: application/octet-stream **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`LanguagePackService::previewManifest()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| manifest | object | `{"identifier":"acme-core-de","locale":"de","version":"1.0.0"}` | ZIP 안 `language-pack.json` 원본 전체 (파싱된 객체) | +| validation | object | `{"errors":[],"is_valid":true,...}` | manifest 검증 결과 요약 | +| validation.errors | array | `[]` | 검증 실패 메시지 목록 (통과 시 빈 배열) | +| validation.is_valid | boolean | `true` | 검증 통과 여부 (`errors` 가 비어 있으면 true) | +| validation.already_installed | boolean | `false` | 동일 `identifier` 언어팩이 이미 설치되어 있는지 여부 | +| validation.existing_version | string\|null | `null` | 이미 설치되어 있다면 그 버전 (없으면 null) | +| validation.target_version_mismatch | boolean | `false` | 대상 확장 버전이 manifest 요구 조건과 어긋나는지 여부 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "manifest 미리보기를 완료했습니다.", + "data": { + "manifest": { + "identifier": "acme-core-de", + "vendor": "acme", + "scope": "core", + "locale": "de", + "version": "1.0.0", + "name": { + "ko": "독일어 언어팩", + "en": "German Language Pack" + }, + "license": "MIT" + }, + "validation": { + "errors": [], + "is_valid": true, + "already_installed": false, + "existing_version": null, + "target_version_mismatch": false + } + } +} +``` **에러 응답** @@ -584,11 +1027,33 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`LanguagePackService::refreshCache()` 반환값 — 각 캐시 계층의 무효화 성공 여부)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| registry | boolean | `true` | 언어팩 레지스트리 캐시 무효화 성공 여부 | +| translator | boolean | `true` | Laravel 번역기(loaded 번역 배열) 초기화 성공 여부 | +| template | boolean | `true` | 템플릿 언어(`template-lang`) 캐시 플러시 성공 여부 | +| version | boolean | `true` | 프론트엔드 cache busting 용 확장 캐시 버전 증가 성공 여부 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "언어팩 캐시를 갱신했습니다.", + "data": { + "registry": true, + "translator": true, + "template": true, + "version": true + } +} +``` **에러 응답** @@ -627,11 +1092,23 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `success('language_packs.uninstall_success')` 만 호출하므로 `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "언어팩을 제거했습니다.", + "data": null +} +``` + +> 보호된 언어팩(`is_protected`)은 제거할 수 없으며, 이 경우 500 (`언어팩 제거에 실패했습니다: 보호된 언어팩은 비활성화/제거할 수 없습니다.`) 으로 응답합니다. **에러 응답** @@ -713,11 +1190,108 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (활성화된 언어팩 = `LanguagePackResource::toArray()`). 필드 구성은 [GET /api/admin/language-packs/{id}](#get-apiadminlanguage-packsid) 의 `manifest`/`validation_summary`/`source_meta`/`changelog_entries` 4개를 제외한 나머지와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| identifier | string | `apidoc-sample-lang` | 언어팩 고유 식별자 | +| vendor | string | `apidoc` | 언어팩 제작자 식별자 | +| scope | string | `core` | 적용 대상 분류 (core/module/plugin/template) | +| target_identifier | string\|null | `null` | 대상 확장 식별자 (scope=core 일 때 null) | +| locale | string | `fr` | IETF BCP-47 locale 태그 | +| locale_name | string | `French` | 영문 언어명 | +| locale_native_name | string | `Français` | 원어 언어명 | +| text_direction | string | `ltr` | 텍스트 방향 (ltr/rtl) | +| version | string | `1.0.0` | 언어팩 버전 | +| latest_version | string\|null | `1.0.0` | 감지된 최신 배포 버전 | +| target_version_constraint | string\|null | `null` | 대상 확장 버전 제약 (semver) | +| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 | +| name | string\|null | `API 문서 샘플 언어팩` | manifest `name` 의 현재 로케일 해석값 | +| license | string\|null | `MIT` | 라이선스 | +| description | string\|null | `문서 실측용 언어팩` | 언어팩 설명 (다국어) | +| status | string | `active` | 활성화 후 상태 (`active`) | +| is_protected | boolean | `false` | 보호(protected) 팩 여부 | +| source_type | string | `bundled` | 설치 소스 유형 | +| origin | string\|null | `bundled` | source_type 에서 파생된 출처 값 | +| source_url | string\|null | `null` | 설치 소스 URL 또는 경로 | +| github_url | string\|null | `null` | GitHub 저장소 URL (manifest 파생) | +| github_changelog_url | string\|null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) | +| bundled_identifier | string\|null | `null` | 대응하는 번들 확장 식별자 | +| install_blocked_reason | string\|null | `null` | 설치가 차단된 사유 (차단 없으면 null) | +| target_name | string\|null | `null` | 대상 확장의 표시 이름 | +| installed_at | string\|null | `2026-07-05 10:41:24` | 설치 일시 (사용자 타임존) | +| activated_at | string\|null | `2026-07-05 10:41:24` | 활성화 일시 (사용자 타임존) | +| created_at | string\|null | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string\|null | `2026-07-08 10:41:24` | 최종 수정 일시 | +| has_update | boolean | `false` | `latest_version > version` 여부 | +| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall":true}` | 현재 사용자가 이 언어팩에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "언어팩을 활성화했습니다.", + "data": { + "id": 1, + "identifier": "apidoc-sample-lang", + "vendor": "apidoc", + "scope": "core", + "target_identifier": null, + "locale": "fr", + "locale_name": "French", + "locale_native_name": "Français", + "text_direction": "ltr", + "version": "1.0.0", + "latest_version": "1.0.0", + "target_version_constraint": null, + "target_version_mismatch": false, + "name": "API 문서 샘플 언어팩", + "license": "MIT", + "description": "문서 실측용 언어팩", + "status": "active", + "is_protected": false, + "source_type": "bundled", + "origin": "bundled", + "source_url": null, + "github_url": null, + "github_changelog_url": null, + "bundled_identifier": null, + "install_blocked_reason": null, + "target_name": null, + "installed_at": "2026-07-05 10:41:24", + "activated_at": "2026-07-05 10:41:24", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "has_update": false, + "abilities": { + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true + } + } +} +``` + +**슬롯 충돌(409) 응답** + +동일 슬롯(scope·target·locale)에 다른 활성 팩이 있고 `force=true` 가 아니면 409 로 응답하며, `errors` 에 현재 팩(`current`)과 대상 팩(`target`)이 위 필드 구성 그대로 담깁니다. + +```json +{ + "success": false, + "message": "같은 슬롯에 이미 활성 언어팩(:current)이 있습니다. (:target)으로 교체하려면 확인이 필요합니다.", + "errors": { + "current": { "id": 1, "identifier": "apidoc-sample-lang", "status": "active" }, + "target": { "id": 2, "identifier": "acme-core-fr", "status": "installed" } + } +} +``` **에러 응답** @@ -799,11 +1373,95 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (비활성화된 언어팩 = `LanguagePackResource::toArray()`). 필드 구성은 activate 응답과 동일하며 `status` 만 `inactive` 로 바뀝니다 (`activated_at` 은 마지막 활성화 시각 그대로 유지)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| identifier | string | `apidoc-sample-lang` | 언어팩 고유 식별자 | +| vendor | string | `apidoc` | 언어팩 제작자 식별자 | +| scope | string | `core` | 적용 대상 분류 (core/module/plugin/template) | +| target_identifier | string\|null | `null` | 대상 확장 식별자 (scope=core 일 때 null) | +| locale | string | `fr` | IETF BCP-47 locale 태그 | +| locale_name | string | `French` | 영문 언어명 | +| locale_native_name | string | `Français` | 원어 언어명 | +| text_direction | string | `ltr` | 텍스트 방향 (ltr/rtl) | +| version | string | `1.0.0` | 언어팩 버전 | +| latest_version | string\|null | `1.0.0` | 감지된 최신 배포 버전 | +| target_version_constraint | string\|null | `null` | 대상 확장 버전 제약 (semver) | +| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 | +| name | string\|null | `API 문서 샘플 언어팩` | manifest `name` 의 현재 로케일 해석값 | +| license | string\|null | `MIT` | 라이선스 | +| description | string\|null | `문서 실측용 언어팩` | 언어팩 설명 (다국어) | +| status | string | `inactive` | 비활성화 후 상태 | +| is_protected | boolean | `false` | 보호(protected) 팩 여부 (보호 팩은 비활성화 불가) | +| source_type | string | `bundled` | 설치 소스 유형 | +| origin | string\|null | `bundled` | source_type 에서 파생된 출처 값 | +| source_url | string\|null | `null` | 설치 소스 URL 또는 경로 | +| github_url | string\|null | `null` | GitHub 저장소 URL (manifest 파생) | +| github_changelog_url | string\|null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) | +| bundled_identifier | string\|null | `null` | 대응하는 번들 확장 식별자 | +| install_blocked_reason | string\|null | `null` | 설치가 차단된 사유 (차단 없으면 null) | +| target_name | string\|null | `null` | 대상 확장의 표시 이름 | +| installed_at | string\|null | `2026-07-05 10:41:24` | 설치 일시 (사용자 타임존) | +| activated_at | string\|null | `2026-07-05 10:41:24` | 마지막 활성화 일시 (비활성화 시에도 초기화되지 않음) | +| created_at | string\|null | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string\|null | `2026-07-08 10:41:24` | 최종 수정 일시 | +| has_update | boolean | `false` | `latest_version > version` 여부 | +| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall":true}` | 현재 사용자가 이 언어팩에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "언어팩을 비활성화했습니다.", + "data": { + "id": 1, + "identifier": "apidoc-sample-lang", + "vendor": "apidoc", + "scope": "core", + "target_identifier": null, + "locale": "fr", + "locale_name": "French", + "locale_native_name": "Français", + "text_direction": "ltr", + "version": "1.0.0", + "latest_version": "1.0.0", + "target_version_constraint": null, + "target_version_mismatch": false, + "name": "API 문서 샘플 언어팩", + "license": "MIT", + "description": "문서 실측용 언어팩", + "status": "inactive", + "is_protected": false, + "source_type": "bundled", + "origin": "bundled", + "source_url": null, + "github_url": null, + "github_changelog_url": null, + "bundled_identifier": null, + "install_blocked_reason": null, + "target_name": null, + "installed_at": "2026-07-05 10:41:24", + "activated_at": "2026-07-05 10:41:24", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "has_update": false, + "abilities": { + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true + } + } +} +``` + +> 보호된 언어팩은 비활성화할 수 없으며, 이 경우 500 (`언어팩 비활성화에 실패했습니다: 보호된 언어팩은 비활성화/제거할 수 없습니다.`) 으로 응답합니다. 같은 슬롯에 다른 후보 팩이 있으면 가장 최근 inactive/installed 팩이 자동으로 활성 승격됩니다. **에러 응답** @@ -842,11 +1500,95 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (업데이트된 언어팩 = `LanguagePackResource::toArray()`). 필드 구성은 activate/deactivate 응답과 동일하며 `version` 이 새 버전으로 갱신되고 `has_update` 가 `false` 가 됩니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `3` | 기본 키 (내부 식별자) | +| identifier | string | `acme-core-es` | 언어팩 고유 식별자 | +| vendor | string | `acme` | 언어팩 제작자 식별자 | +| scope | string | `core` | 적용 대상 분류 (core/module/plugin/template) | +| target_identifier | string\|null | `null` | 대상 확장 식별자 (scope=core 일 때 null) | +| locale | string | `es` | IETF BCP-47 locale 태그 | +| locale_name | string | `Spanish` | 영문 언어명 | +| locale_native_name | string | `Español` | 원어 언어명 | +| text_direction | string | `ltr` | 텍스트 방향 (ltr/rtl) | +| version | string | `1.1.0` | 업데이트 후 적용된 버전 | +| latest_version | string\|null | `1.1.0` | 감지된 최신 배포 버전 | +| target_version_constraint | string\|null | `null` | 대상 확장 버전 제약 (semver) | +| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 | +| name | string\|null | `스페인어 언어팩` | manifest `name` 의 현재 로케일 해석값 | +| license | string\|null | `MIT` | 라이선스 | +| description | string\|null | `—` | 언어팩 설명 (다국어) | +| status | string | `active` | 언어팩 상태 (업데이트 전 상태 유지) | +| is_protected | boolean | `false` | 보호(protected) 팩 여부 | +| source_type | string | `github` | 설치 소스 유형 (업데이트는 GitHub/번들 소스만 가능) | +| origin | string\|null | `github` | source_type 에서 파생된 출처 값 | +| source_url | string\|null | `https://github.com/acme/g7-core-es` | 설치 소스 URL 또는 경로 | +| github_url | string\|null | `https://github.com/acme/g7-core-es` | GitHub 저장소 URL (manifest 파생) | +| github_changelog_url | string\|null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) | +| bundled_identifier | string\|null | `null` | 대응하는 번들 확장 식별자 | +| install_blocked_reason | string\|null | `null` | 설치가 차단된 사유 (차단 없으면 null) | +| target_name | string\|null | `null` | 대상 확장의 표시 이름 | +| installed_at | string\|null | `2026-07-05 10:41:24` | 설치 일시 (사용자 타임존) | +| activated_at | string\|null | `2026-07-05 10:41:24` | 활성화 일시 (사용자 타임존) | +| created_at | string\|null | `2026-07-05 10:41:24` | 생성 일시 | +| updated_at | string\|null | `2026-07-09 09:12:00` | 최종 수정 일시 (업데이트 시각으로 갱신) | +| has_update | boolean | `false` | `latest_version > version` 여부 (업데이트 직후 false) | +| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall":true}` | 현재 사용자가 이 언어팩에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "언어팩을 업데이트했습니다.", + "data": { + "id": 3, + "identifier": "acme-core-es", + "vendor": "acme", + "scope": "core", + "target_identifier": null, + "locale": "es", + "locale_name": "Spanish", + "locale_native_name": "Español", + "text_direction": "ltr", + "version": "1.1.0", + "latest_version": "1.1.0", + "target_version_constraint": null, + "target_version_mismatch": false, + "name": "스페인어 언어팩", + "license": "MIT", + "description": null, + "status": "active", + "is_protected": false, + "source_type": "github", + "origin": "github", + "source_url": "https://github.com/acme/g7-core-es", + "github_url": "https://github.com/acme/g7-core-es", + "github_changelog_url": null, + "bundled_identifier": null, + "install_blocked_reason": null, + "target_name": null, + "installed_at": "2026-07-05 10:41:24", + "activated_at": "2026-07-05 10:41:24", + "created_at": "2026-07-05 10:41:24", + "updated_at": "2026-07-09 09:12:00", + "has_update": false, + "abilities": { + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true + } + } +} +``` + +> 업데이트 소스 정보가 없거나(`업데이트 소스 정보가 없습니다 (GitHub 소스 언어팩만 업데이트 가능).`) 이미 최신 버전이면(`이미 최신 버전입니다.`) 500 (`언어팩 업데이트에 실패했습니다: :error`) 으로 응답합니다. **에러 응답** diff --git a/docs/backend/api/layouts.md b/docs/backend/api/layouts.md index 335e14b6..17c494c5 100644 --- a/docs/backend/api/layouts.md +++ b/docs/backend/api/layouts.md @@ -67,7 +67,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| token | path | string | 예 | — | 인증/검증 토큰 | +| token | path | string | 예 | — | 미리보기 토큰(UUID). `LayoutPreviewService::createPreview` 가 발급하며 기본 30분 후 만료 | **요청 예시** @@ -80,17 +80,76 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `success`/`message`/`data` 봉투를 사용하지 않습니다. `LayoutPreviewController@serve` 가 `response()->json($layout)` 로 **병합된 레이아웃 JSON 객체를 최상위(root)에 그대로** 반환합니다 (상속 병합 + 모듈/플러그인 확장 적용 완료 상태). 아래는 레이아웃 JSON의 최상위 키이며, 실제 포함 여부는 저장된 레이아웃 content 에 따라 달라집니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| version | string | `"1.0"` | 레이아웃 스키마 버전 (저장 시 필수) | +| layout_name | string | `"product_list"` | 레이아웃 이름 (식별자) | +| extends | string\|null | `"_user_base"` | 상속할 베이스 레이아웃 이름 (미리보기 서빙 시 이미 병합됨) | +| slots | object | `{"content": [ ... ]}` | 베이스 레이아웃 슬롯에 삽입할 컴포넌트 배열 (extends 레이아웃) | +| components | array | `[{"type":"basic","name":"Div", ...}]` | 렌더링할 컴포넌트 트리 (병합·확장 적용 결과) | +| endpoint | string\|null | `"/api/products"` | 화면이 주로 fetch 하는 데이터 API 경로 (레거시·선택) | +| data_sources | array | `[{"id":"products","endpoint":"/api/products","method":"GET"}]` | 화면이 로드하는 API 데이터 소스 정의 | +| meta | object | `{"title":"상품 목록","auth_required":false}` | 메타 정보 (title, description, keywords, auth_required, is_base, guest_only, is_error_layout, error_code, seo) | +| permissions | array\|object | `["core.templates.layouts.edit"]` | 레이아웃 접근에 필요한 권한 (flat 배열=AND, 구조화 객체=OR/AND 중첩) | +| modals | object | `{}` | 모달 컴포넌트 정의 | +| state / init_state / initLocal / initGlobal / initIsolated / global_state | object | `{}` | 레이아웃 레벨 초기 상태 정의 | +| init_actions | array | `[{"handler":"apiCall", ...}]` | 레이아웃 로드 시 실행할 초기화 액션 | +| actions / named_actions | array\|object | `{}` | 재사용 가능한 액션 정의 | +| computed | object | `{}` | 계산된 값 정의 | +| defines | object | `{}` | 재사용 가능한 컴포넌트 조각 정의 | +| errorHandling | object | `{"404":{"handler":"navigate"}}` | 상태 코드별 에러 처리 정책 | +| globalHeaders | array | `[{"pattern":"*","headers":{"X-Foo":"bar"}}]` | API 호출 시 자동 적용되는 HTTP 헤더 규칙 | +| transition_overlay | object | `{"enabled":true,"style":"blur"}` | 페이지 전환 오버레이 설정 | +| routes | array | `[]` | 라우트 정의 | +| pageConfig / schema | object | `{}` | 플러그인 설정 레이아웃 전용 (설정 UI 안내/스키마) | **응답 예시** - +```json +{ + "version": "1.0", + "layout_name": "product_list", + "meta": { + "title": "상품 목록", + "auth_required": false + }, + "data_sources": [ + { + "id": "products", + "endpoint": "/api/products", + "method": "GET" + } + ], + "components": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-4" + }, + "children": [] + } + ] +} +``` + +404 응답은 봉투 형식입니다 (`BaseApiController::notFound` → `templates.layout_not_found`). + +```json +{ + "success": false, + "message": "레이아웃을 찾을 수 없습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 토큰에 해당하는 미리보기가 없거나 만료된 경우 (`templates.layout_not_found`) | @@ -148,8 +207,8 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| templateIdentifier | path | string | 예 | — | 대상 template의 식별자 | -| layoutName | path | string | 예 | — | 대상 layout의 이름 (식별자) | +| templateIdentifier | path | string | 예 | — | 레이아웃을 서빙할 활성 템플릿의 식별자 (예: `sirsoft-basic`) — 미존재/비활성 시 404 | +| layoutName | path | string | 예 | — | 서빙할 레이아웃 이름 (예: `product_list`) | **요청 예시** @@ -162,17 +221,78 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 는 병합된 레이아웃 JSON 객체입니다 (상속 병합 + 모듈/플러그인 확장 적용 + 사용자별 컴포넌트 권한 필터링 완료). 실제 포함 키는 저장된 레이아웃 content 에 따라 달라집니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| version | string | `"1.0"` | 레이아웃 스키마 버전 | +| layout_name | string | `"product_list"` | 레이아웃 이름 (식별자) | +| extends | string\|null | `"_user_base"` | 상속한 베이스 레이아웃 이름 (서빙 시 이미 병합 반영됨) | +| slots | object | `{"content": [ ... ]}` | 베이스 슬롯에 삽입되는 컴포넌트 배열 (extends 레이아웃) | +| components | array | `[{"type":"basic","name":"Div", ...}]` | 렌더링할 컴포넌트 트리 (병합·확장·권한 필터 적용 결과) | +| endpoint | string\|null | `"/api/products"` | 화면이 주로 fetch 하는 데이터 API 경로 (레거시·선택) | +| data_sources | array | `[{"id":"products","endpoint":"/api/products","method":"GET"}]` | 화면이 로드하는 API 데이터 소스 정의 | +| meta | object | `{"title":"상품 목록","auth_required":false}` | 메타 정보 (title, description, keywords, auth_required, is_base, guest_only, is_error_layout, error_code, seo) | +| permissions | array\|object | `["core.templates.layouts.edit"]` | 레이아웃 접근에 필요한 권한 (flat 배열=AND, 구조화 객체=OR/AND 중첩) | +| modals | object | `{}` | 모달 컴포넌트 정의 | +| state / init_state / initLocal / initGlobal / initIsolated / global_state | object | `{}` | 레이아웃 레벨 초기 상태 정의 | +| init_actions | array | `[{"handler":"apiCall", ...}]` | 레이아웃 로드 시 실행할 초기화 액션 | +| actions / named_actions | array\|object | `{}` | 재사용 가능한 액션 정의 | +| computed | object | `{}` | 계산된 값 정의 | +| defines | object | `{}` | 재사용 가능한 컴포넌트 조각 정의 | +| errorHandling | object | `{"404":{"handler":"navigate"}}` | 상태 코드별 에러 처리 정책 | +| globalHeaders | array | `[{"pattern":"*","headers":{"X-Foo":"bar"}}]` | API 호출 시 자동 적용되는 HTTP 헤더 규칙 | +| transition_overlay | object | `{"enabled":true,"style":"blur"}` | 페이지 전환 오버레이 설정 | +| routes | array | `[]` | 라우트 정의 | +| pageConfig / schema | object | `{}` | 플러그인 설정 레이아웃 전용 (설정 UI 안내/스키마) | +| lock_version | integer | `3` | 낙관적 잠금 버전. `with_source_meta=1` 일 때만 부착 (편집기 저장 시 `expected_lock_version` 으로 되돌려 보냄) | +| __editor | object | `{"original": { ... }}` | 자식 레이아웃의 저장 원본 content. `with_source_meta=1` 일 때만 부착 (편집기 전용) | +| __source (각 노드 내부) | object | `{"kind":"base","layout":"_user_base"}` | 각 컴포넌트/데이터소스 노드의 출처 메타 (`base` / `extension` / `partial` / `route`). `with_source_meta=1` 일 때만 부착 | + +응답 헤더: `ETag`(본문 md5), `Cache-Control: public, max-age=3600`, `Vary: Accept-Encoding, Accept-Language`. 클라이언트 `If-None-Match` 가 일치하면 본문 없이 `304 Not Modified` 를 반환합니다. **응답 예시** - +```json +{ + "success": true, + "message": "레이아웃이 제공되었습니다.", + "data": { + "version": "1.0", + "layout_name": "product_list", + "meta": { + "title": "상품 목록", + "auth_required": false + }, + "data_sources": [ + { + "id": "products", + "endpoint": "/api/products", + "method": "GET" + } + ], + "components": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-4" + }, + "children": [] + } + ] + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 304 | Not Modified | 요청의 `If-None-Match` 가 현재 ETag 와 일치 (본문 없음) | +| 401 | Unauthorized | 비회원이 `permissions` 요구 레이아웃에 접근 (`auth.layout_guest_permission_denied`), 또는 비회원이 `with_source_meta=1` 요청 | +| 403 | Forbidden | 회원이지만 레이아웃 `permissions` 미충족 (`auth.layout_permission_denied`), 또는 `with_source_meta=1` 요청 시 `core.templates.layouts.edit` 권한 없음 | +| 404 | Not Found | 템플릿이 없거나 비활성 상태, 또는 레이아웃/부모 레이아웃 미존재 (`templates.layout_not_found`) | diff --git a/docs/backend/api/locales.md b/docs/backend/api/locales.md index a9159777..21abfb42 100644 --- a/docs/backend/api/locales.md +++ b/docs/backend/api/locales.md @@ -69,7 +69,7 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +_이 엔드포인트는 도메인 에러를 반환하지 않습니다. 공개 조회이므로 인증 실패(401)가 없고, 요청 파라미터가 없어 검증 실패(422)도 발생하지 않으며, 컨트롤러(`LocaleController@active`)와 `LanguagePackService::getActiveLocales()` 경로에서 던지는 예외도 없습니다._ diff --git a/docs/backend/api/me.md b/docs/backend/api/me.md index fbe14598..0c4f08bf 100644 --- a/docs/backend/api/me.md +++ b/docs/backend/api/me.md @@ -38,11 +38,17 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```json +{ + "success": true, + "message": "회원 탈퇴가 완료되었습니다.", + "data": null +} +``` **에러 응답** @@ -309,11 +315,83 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`UserResource::toArray()` 산물 — GET /api/me 의 `toProfileArray()` 와 필드 구성이 다르다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `홍길동` | 사용자 이름 | +| nickname | string\|null | `gildong` | 닉네임 | +| email | string | `user@example.com` | 이메일 주소 | +| avatar | string\|null | `null` | 아바타 이미지 URL (`User::getAvatarUrl()` 산물, 미등록 시 null) | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: 영어) | +| language_label | string\|null | `한국어` | 언어 코드의 다국어 라벨 (`user.language.{code}` 번역문) | +| country | string\|null | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (active, inactive, blocked, withdrawn, pending_verification) | +| status_label | string\|null | `활성` | 상태의 사람이 읽는 라벨 (UserStatus::label() 산물) | +| status_variant | string\|null | `success` | 상태 표시 색상/스타일 변형 키 (UserStatus::variant() 산물 — UI 배지용) | +| is_admin | boolean | `false` | 관리자 역할 보유 여부 (`User::isAdmin()` — 역할 관계 기반 파생) | +| homepage | string\|null | `https://example.com` | 홈페이지 URL | +| mobile | string\|null | `010-1234-5678` | 휴대폰 번호 | +| phone | string\|null | `02-123-4567` | 전화번호 | +| zipcode | string\|null | `06234` | 우편번호 | +| address | string\|null | `서울특별시 강남구 테헤란로 1` | 기본 주소 | +| address_detail | string\|null | `101동 202호` | 상세 주소 | +| signature | string\|null | `null` | 서명 | +| bio | string\|null | `null` | 자기소개 | +| last_login_at | string\|null | `2026-07-07 10:41:24` | 마지막 로그인 일시 (사용자 시간대 기준 문자열) | +| email_verified_at | string\|null | `null` | 이메일 인증 일시 (미인증 시 null) | +| timezone | string\|null | `Asia/Seoul` | 사용자 시간대 (예: Asia/Seoul, UTC) | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 (사용자 시간대 기준 문자열) | +| updated_at | string | `2026-07-08 11:02:10` | 수정 일시 (사용자 시간대 기준 문자열) | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":false,"can_create":false,"can_update":false,"can_delete":false,"can_assign_roles":false}` | 현재 사용자의 이 리소스에 대한 권한 맵 (core.users.read/create/update/delete, core.permissions.update 기준. 슈퍼관리자 계정은 `can_delete` 가 항상 false) | + +관계형 필드(`modules`, `plugins`, `menus`, `roles`, `permissions`, `consents`, `terms_consent`, `privacy_consent`)와 카운트 필드(`modules_count`, `plugins_count`, `menus_count`)는 해당 관계가 로드된 경우에만 응답에 포함된다 (프로필 수정 응답에서는 로드하지 않으므로 나타나지 않는다). **응답 예시** - +```json +{ + "success": true, + "message": "사용자 정보가 성공적으로 업데이트되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "예시 이름", + "nickname": "예시 이름", + "email": "user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": false, + "homepage": "https://example.com", + "mobile": "010-1234-5678", + "phone": "010-1234-5678", + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "서울특별시 강남구 테헤란로 1", + "signature": "예시값", + "bio": "예시 내용입니다.", + "last_login_at": "2026-07-07 10:41:24", + "email_verified_at": null, + "timezone": "Asia/Seoul", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 11:02:10", + "is_owner": true, + "abilities": { + "can_read": false, + "can_create": false, + "can_update": false, + "can_delete": false, + "can_assign_roles": false + } + } +} +``` **에러 응답** diff --git a/docs/backend/api/menus.md b/docs/backend/api/menus.md index d3fd8844..822e0e83 100644 --- a/docs/backend/api/menus.md +++ b/docs/backend/api/menus.md @@ -481,11 +481,94 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_목록 응답: `data.data[]` 배열 항목의 필드 + 컬렉션 레벨 `data.abilities`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `15` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"게시판 관리","en":"Board Management"}` | 메뉴 이름 (다국어 JSON) | +| slug | string | `sirsoft-board` | 메뉴 슬러그 | +| url | string \| null | `null` | 메뉴 URL (그룹 메뉴는 null) | +| icon | string \| null | `fas fa-clipboard-list` | 메뉴 아이콘 (Font Awesome 클래스) | +| order | integer | `30` | 메뉴 순서 (작을수록 우선 — `order` 오름차순 정렬) | +| is_active | boolean | `true` | 활성 여부 | +| parent_id | integer \| null | `null` | 상위 메뉴 ID (최상위는 null) | +| extension_type | string | `module` | 확장 소유 타입 (`module` / `plugin`) | +| extension_identifier | string | `sirsoft-board` | 확장 식별자 (요청 path 의 `identifier` 와 동일) | +| parent | object \| null | `null` | 상위 항목 객체 (id/name/url/icon — parent 관계 파생) | +| children | array | `[{"id":16,"name":{"ko":"환경설정"},"slug":"sirsoft-board-settings", ...}]` | 하위 메뉴 배열 (order 오름차순 — children 관계 파생) | +| creator | object \| null | `null` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| created_at | string | `2026-07-08 10:44:35` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:35` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> `roles` 관계는 이 엔드포인트에서 eager-load 되지 않으므로(`MenuRepository::getMenusByExtension` 은 `creator`/`parent`/`children` 만 로드) 항목에 `roles` 키가 포함되지 않습니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "메뉴를 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "id": 15, + "name": { + "ko": "게시판 관리", + "en": "Board Management" + }, + "slug": "sirsoft-board", + "url": null, + "icon": "fas fa-clipboard-list", + "order": 30, + "is_active": true, + "parent_id": null, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "parent": null, + "children": [ + { + "id": 16, + "name": { + "ko": "환경설정", + "en": "Settings" + }, + "slug": "sirsoft-board-settings", + "url": "/admin/boards/settings", + "icon": "fas fa-cog", + "order": 1, + "is_active": true, + "parent_id": 15, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "roles": [] + } + ], + "creator": null, + "created_at": "2026-07-08 10:44:35", + "updated_at": "2026-07-08 10:44:35", + "is_owner": false, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -638,11 +721,21 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `success('menu.order_update_success')` 를 데이터 없이 호출)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "메뉴 순서가 성공적으로 업데이트되었습니다.", + "data": null +} +``` **에러 응답** @@ -650,7 +743,7 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `moved_items[].new_parent_id` 가 자기 자신·자손을 가리키는 순환 참조인 경우 포함 | @@ -682,7 +775,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `success('menu.delete_success')` 를 데이터 없이 호출)._ **응답 예시** @@ -928,11 +1021,81 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (갱신된 메뉴를 `MenuResource` 로 반환 — `creator`/`parent`/`children`/`roles` eager-load)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 메뉴","en":"API Doc Sample Menu"}` | 메뉴 이름 (다국어 JSON) | +| slug | string | `apidoc-sample-menu` | 메뉴 슬러그 | +| url | string \| null | `/admin/apidoc-sample` | 메뉴 URL | +| icon | string \| null | `fas fa-book` | 메뉴 아이콘 | +| order | integer | `35` | 메뉴 순서 | +| is_active | boolean | `true` | 활성 여부 | +| parent_id | integer \| null | `null` | 상위 메뉴 ID | +| extension_type | string \| null | `core` | 확장 소유 타입: core / module / plugin / null(사용자 정의) | +| extension_identifier | string \| null | `core` | 확장 식별자 | +| parent | object \| null | `null` | 상위 항목 객체 (id/name/url/icon — parent 관계 파생) | +| children | array | `[]` | 하위 항목 배열 (order 오름차순 — children 관계 파생) | +| creator | object \| null | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자","email":"apidoc-sample-user@example.com"}` | 생성자 정보 객체 (creator 관계 파생) | +| roles | array | `[{"id":1,"name":{"ko":"관리자","en":"Administrator"},"permission_type":"read"}]` | 이 메뉴 노출이 허용된 역할 목록 (요청 `roles` 로 교체된 결과) | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 12:20:11` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "메뉴가 성공적으로 업데이트되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 메뉴", + "en": "API Doc Sample Menu" + }, + "slug": "apidoc-sample-menu", + "url": "/admin/apidoc-sample", + "icon": "fas fa-book", + "order": 35, + "is_active": true, + "parent_id": null, + "extension_type": null, + "extension_identifier": null, + "parent": null, + "children": [], + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "roles": [ + { + "id": 1, + "name": { + "ko": "관리자", + "en": "Administrator" + }, + "permission_type": "read" + } + ], + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 12:20:11", + "is_owner": true, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** diff --git a/docs/backend/api/modules.md b/docs/backend/api/modules.md index dcbd440d..1a60240d 100644 --- a/docs/backend/api/modules.md +++ b/docs/backend/api/modules.md @@ -176,7 +176,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read| core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -216,19 +216,60 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`data.module` 은 목록과 동일한 `ModuleResource` 형태)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| module | object | `{"identifier":"sirsoft-board", ...}` | 활성화된 모듈 정보 (`ModuleResource` — 목록 응답 항목과 동일 필드 구성) | +| pending_language_packs | array | `[]` | 이전 비활성화 시 cascade 로 함께 비활성화됐던 번들 언어팩 목록 (재활성화 안내 모달용). 각 항목: `id`, `identifier`, `locale`, `locale_native_name`. 없으면 빈 배열 | **응답 예시** - +```json +{ + "success": true, + "message": "모듈이 성공적으로 활성화되었습니다.", + "data": { + "module": { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.0", + "description": "게시판 관리를 위한 모듈", + "dependencies": [], + "status": "active", + "assets": null, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-board", + "github_changelog_url": "https://github.com/gnuboard/g7-module-sirsoft-board/releases", + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + }, + "pending_language_packs": [] + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 409 | Conflict | `force` 없이 호출했고 필요한 의존 확장이 미충족인 경우 (`error` 에 `warning`, `missing_modules`, `missing_plugins` 포함) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 활성화 처리 중 예외 발생 (`module.activate_failed`) | @@ -256,19 +297,45 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `1` | 업데이트가 가능한 것으로 감지된 모듈 수 | +| details | array | `[{"identifier":"sirsoft-board", ...}]` | 업데이트 가능한 모듈만 담은 목록 (아래 하위 필드) | +| details[].identifier | string | `sirsoft-board` | 모듈 식별자 | +| details[].current_version | string | `1.0.0` | 현재 설치된 버전 | +| details[].latest_version | string | `1.0.1` | 감지된 최신 버전 | +| details[].update_source | string | `github` | 업데이트 감지 출처 (`github` \| `bundled`) | **응답 예시** - +```json +{ + "success": true, + "message": "업데이트 확인이 완료되었습니다.", + "data": { + "updated_count": 1, + "details": [ + { + "identifier": "sirsoft-board", + "current_version": "1.0.0", + "latest_version": "1.0.1", + "update_source": "github" + } + ] + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 업데이트 확인 처리가 실패한 경우 (`modules.check_updates_failed`) | +| 500 | Internal Server Error | 업데이트 확인 중 예외 발생 | @@ -307,19 +374,75 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 는 비활성화된 모듈의 `ModuleResource` 객체 (목록 응답 항목과 동일 필드 구성)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-board` | 모듈 고유 식별자 (vendor-module 형식) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `게시판` | 모듈 이름 (현재 로케일로 해석) | +| version | string | `1.0.0` | 모듈 버전 | +| description | string | `게시판 관리를 위한 모듈` | 모듈 설명 (현재 로케일로 해석) | +| dependencies | array | `[]` | 의존 확장 목록 (manifest 파생) | +| status | string | `inactive` | 비활성화 후 상태 | +| assets | object\|null | `null` | 프론트엔드 에셋 매니페스트 (js/css/priority) | +| update_available | boolean | `false` | 최신 버전 대비 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string\|null | `https://github.com/gnuboard/g7-module-sirsoft-board` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `https://github.com/gnuboard/g7-module-sirsoft-board/releases` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소 항목 여부 | +| is_bundled | boolean | `false` | 코어 선탑재 번들 확장 여부 | +| deactivated_reason | string\|null | `manual` | 비활성화 사유 (`manual` \| `incompatible_core`) | +| deactivated_at | string\|null | `2026-07-14 10:00:00` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 요구 코어 버전 미충족 시 필요한 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자가 수행 가능한 작업 불리언 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "모듈이 성공적으로 비활성화되었습니다.", + "data": { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.0", + "description": "게시판 관리를 위한 모듈", + "dependencies": [], + "status": "inactive", + "assets": null, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-board", + "github_changelog_url": "https://github.com/gnuboard/g7-module-sirsoft-board/releases", + "is_pending": false, + "is_bundled": false, + "deactivated_reason": "manual", + "deactivated_at": "2026-07-14 10:00:00", + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 409 | Conflict | `force` 없이 호출했고 이 모듈에 의존하는 활성 확장이 있는 경우 (`error` 에 `warning`, `dependent_templates`, `dependent_modules`, `dependent_plugins` 포함) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 비활성화 처리 중 예외 발생 (`module.deactivate_failed`) | @@ -366,19 +489,80 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 는 설치된 모듈의 `ModuleResource` 필드 + `language_pack_failures` (HTTP 201)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-board` | 설치된 모듈 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `게시판` | 모듈 이름 (현재 로케일로 해석) | +| version | string | `1.0.0` | 설치된 버전 | +| description | string | `게시판 관리를 위한 모듈` | 모듈 설명 | +| dependencies | array | `[]` | 의존 확장 목록 (enriched — identifier/name/type/required_version/is_met 등) | +| status | string | `inactive` | 설치 직후 상태 (설치만으로 활성화되지 않음) | +| assets | object\|null | `null` | 프론트엔드 에셋 매니페스트 (js/css/priority) | +| update_available | boolean | `false` | 업데이트 가능 여부 (설치 직후 기본 false) | +| update_source | string\|null | `null` | 업데이트 감지 출처 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `null` | 설치된 파일 manifest 버전 | +| github_url | string\|null | `https://github.com/gnuboard/g7-module-sirsoft-board` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소 항목 여부 | +| is_bundled | boolean | `false` | 코어 선탑재 번들 확장 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 요구 코어 버전 미충족 시 필요한 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자가 수행 가능한 작업 불리언 맵 | +| language_pack_failures | array | `[]` | cascade 2단계(번들 언어팩 best-effort 설치)에서 실패한 항목. 각 항목: `identifier`, `reason`. 실패 없으면 빈 배열 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "모듈이 성공적으로 설치되었습니다.", + "data": { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.0", + "description": "게시판 관리를 위한 모듈", + "dependencies": [], + "status": "inactive", + "assets": null, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-board", + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + }, + "language_pack_failures": [] + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 설치 파이프라인이 던진 검증 오류 (의존 확장 cascade 설치 실패·이미 설치됨 등 — `error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`modules.installation_failed`) | @@ -418,19 +602,78 @@ Content-Type: application/octet-stream **응답 필드** (`data` 내부) - +_단건 응답: `data` 는 설치된 모듈의 `ModuleResource` 객체 (HTTP 201 — 목록 응답 항목과 동일 필드 구성)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-board` | 설치된 모듈 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `게시판` | 모듈 이름 (현재 로케일로 해석) | +| version | string | `1.0.0` | 설치된 버전 | +| description | string | `게시판 관리를 위한 모듈` | 모듈 설명 | +| dependencies | array | `[]` | 의존 확장 목록 (enriched) | +| status | string | `inactive` | 설치 직후 상태 | +| assets | object\|null | `null` | 프론트엔드 에셋 매니페스트 | +| update_available | boolean | `false` | 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `null` | 설치된 파일 manifest 버전 | +| github_url | string\|null | `https://github.com/gnuboard/g7-module-sirsoft-board` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소 항목 여부 | +| is_bundled | boolean | `false` | 코어 선탑재 번들 확장 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 요구 코어 버전 미충족 시 필요한 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자가 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "모듈이 성공적으로 설치되었습니다.", + "data": { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.0", + "description": "게시판 관리를 위한 모듈", + "dependencies": [], + "status": "inactive", + "assets": null, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-board", + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 파일 검증 실패(ZIP 아님·50MB 초과), 또는 ZIP 처리 오류 (module.json 미존재/형식 오류·식별자 누락·이미 설치됨) | +| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`module.install_failed`) | @@ -467,19 +710,78 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 는 설치된 모듈의 `ModuleResource` 객체 (HTTP 201 — 목록 응답 항목과 동일 필드 구성)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-board` | 설치된 모듈 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `게시판` | 모듈 이름 (현재 로케일로 해석) | +| version | string | `1.0.0` | 설치된 버전 | +| description | string | `게시판 관리를 위한 모듈` | 모듈 설명 | +| dependencies | array | `[]` | 의존 확장 목록 (enriched) | +| status | string | `inactive` | 설치 직후 상태 | +| assets | object\|null | `null` | 프론트엔드 에셋 매니페스트 | +| update_available | boolean | `false` | 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `null` | 설치된 파일 manifest 버전 | +| github_url | string\|null | `https://github.com/gnuboard/g7-module-sirsoft-board` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소 항목 여부 | +| is_bundled | boolean | `false` | 코어 선탑재 번들 확장 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 요구 코어 버전 미충족 시 필요한 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자가 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "모듈이 성공적으로 설치되었습니다.", + "data": { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.0", + "description": "게시판 관리를 위한 모듈", + "dependencies": [], + "status": "inactive", + "assets": null, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-board", + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 422 | Unprocessable Entity | URL 형식 검증 실패, 또는 GitHub 처리 오류 (저장소 미존재·다운로드 실패·module.json 형식 오류·이미 설치됨) | +| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`module.install_failed`) | @@ -620,7 +922,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -659,19 +961,48 @@ Content-Type: application/octet-stream **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| manifest | object\|null | `{"identifier":"sirsoft-board","version":"1.0.0", ...}` | ZIP 에서 추출한 module.json 원본 내용. 추출 실패 시 `null` | +| validation | object | `{"errors":[],"is_valid":true, ...}` | 검증 결과 묶음 (아래 하위 필드) | +| validation.errors | array | `[]` | 추출/검증 과정에서 발생한 오류 메시지 목록 (없으면 빈 배열) | +| validation.is_valid | boolean | `true` | 오류가 없고 manifest 추출에 성공했는지 여부 | +| validation.already_installed | boolean | `false` | 해당 식별자의 모듈이 이미 설치되어 있는지 여부 | +| validation.existing_version | string\|null | `1.0.0` | 이미 설치된 경우 그 버전 (미설치 시 `null`) | **응답 예시** - +```json +{ + "success": true, + "message": "manifest 미리보기를 완료했습니다.", + "data": { + "manifest": { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.0", + "g7_version": "7.0.0" + }, + "validation": { + "errors": [], + "is_valid": true, + "already_installed": false, + "existing_version": null + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 파일 검증 실패(ZIP 아님·50MB 초과), 또는 미리보기 처리 실패 (`module.preview_failed` — `error.error` 에 사유) | @@ -708,19 +1039,74 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 는 갱신된 모듈의 `ModuleResource` 객체 (목록 응답 항목과 동일 필드 구성)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-board` | 모듈 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `게시판` | 모듈 이름 (현재 로케일로 해석) | +| version | string | `1.0.0` | 모듈 버전 | +| description | string | `게시판 관리를 위한 모듈` | 모듈 설명 | +| dependencies | array | `[]` | 의존 확장 목록 (enriched) | +| status | string | `active` | 모듈 상태 (레이아웃 갱신은 활성 모듈만 가능) | +| assets | object\|null | `null` | 프론트엔드 에셋 매니페스트 | +| update_available | boolean | `false` | 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `null` | 설치된 파일 manifest 버전 | +| github_url | string\|null | `https://github.com/gnuboard/g7-module-sirsoft-board` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소 항목 여부 | +| is_bundled | boolean | `false` | 코어 선탑재 번들 확장 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 요구 코어 버전 미충족 시 필요한 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자가 수행 가능한 작업 불리언 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "모듈 레이아웃이 성공적으로 갱신되었습니다.", + "data": { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.0", + "description": "게시판 관리를 위한 모듈", + "dependencies": [], + "status": "active", + "assets": null, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-board", + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 레이아웃 갱신 실패 (모듈 미존재·비활성 상태 — `modules.refresh_layouts_failed`) | +| 500 | Internal Server Error | 갱신 처리 중 예외 발생 (`module.refresh_layouts_failed`) | @@ -753,19 +1139,25 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `success('module.uninstall_success')` 를 데이터 인자 없이 호출합니다._ **응답 예시** - +```json +{ + "success": true, + "message": "모듈이 성공적으로 제거되었습니다." +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 제거 처리 중 예외 발생 (`module.uninstall_failed`) | @@ -850,7 +1242,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -908,7 +1300,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -944,12 +1336,12 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| identifier | string | `sirsoft-basic` | 모듈 고유 식별자 (vendor-module 형식) | -| name | string | `Basic` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| version | string | `1.0.4` | 모듈 버전 | -| type | string | `user` | | -| status | string | `active` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | -| required_version | string | `>=1.0.5` | | +| identifier | string | `gnuboard7-hello_module` | 모듈 고유 식별자 (vendor-module 형식) | +| name | string | `Hello 모듈` | 모듈 이름 (다국어 JSON) | +| version | string | `0.1.0` | 모듈 버전 | +| type | string | `user` | 의존 템플릿의 타입 (admin: 관리자 템플릿 / user: 사용자 템플릿) | +| status | string | `uninstalled` | 상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중) | +| required_version | string | `>=1.0.0` | 요구되는 최소 버전 | **응답 예시** @@ -982,7 +1374,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -1041,7 +1433,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -1073,20 +1465,94 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ModuleResource::toDetailArray()` + 주입된 `language_packs`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-board` | 모듈 고유 식별자 (vendor-module 형식) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `게시판` | 모듈 이름 (현재 로케일로 해석) | +| version | string | `1.0.0` | 모듈 버전 | +| description | string | `게시판 관리를 위한 모듈` | 모듈 설명 | +| github_url | string\|null | `https://github.com/gnuboard/g7-module-sirsoft-board` | GitHub 저장소 URL | +| requires_core | string\|null | `7.0.0` | 요구하는 코어 최소 버전 (manifest `g7_version`) | +| dependencies | array | `[]` | 의존 확장 목록 (enriched — identifier/name/type/required_version/installed_version/is_active/is_met) | +| status | string | `active` | 상태 (`active` \| `inactive` \| `installing` \| `uninstalling` \| `updating` \| `uninstalled`) | +| is_installed | boolean | `true` | 활성 디렉토리에 설치되어 DB 레코드가 있는지 여부 | +| permissions | array | `[]` | manifest 가 선언한 권한 카테고리 목록 | +| roles | array | `[]` | manifest 가 선언한 역할 목록 | +| admin_menus | array | `[]` | manifest 가 선언한 관리자 메뉴 정의 | +| license | string\|null | `MIT` | 라이선스 식별자 | +| layouts_count | integer | `12` | 모듈이 제공하는 레이아웃 파일 수 | +| config | object | `{}` | manifest 의 config 블록 | +| metadata | object | `{"identifier":"sirsoft-board", ...}` | manifest 원본 메타데이터 | +| update_available | boolean | `false` | 최신 버전 대비 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 (`github` \| `bundled`) | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `null` | 설치된 파일의 manifest 버전 | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소 항목 여부 | +| is_bundled | boolean | `false` | 코어 선탑재 번들 확장 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 (`manual` \| `incompatible_core`) | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 요구 코어 버전 미충족 시 필요한 버전 | +| created_at | string\|null | `2026-07-01 09:00:00` | 설치(레코드 생성) 일시 (사용자 타임존 변환) | +| updated_at | string\|null | `2026-07-10 11:20:00` | 최종 갱신 일시 (사용자 타임존 변환) | +| language_packs | array | `[]` | 이 모듈을 대상으로 하는 언어팩 목록 (`LanguagePackResource` — identifier/locale/version/status 등). 없으면 빈 배열 | **응답 예시** - +```json +{ + "success": true, + "message": "모듈을 성공적으로 가져왔습니다.", + "data": { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.0", + "description": "게시판 관리를 위한 모듈", + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-board", + "requires_core": "7.0.0", + "dependencies": [], + "status": "active", + "is_installed": true, + "permissions": [], + "roles": [], + "admin_menus": [], + "license": "MIT", + "layouts_count": 12, + "config": {}, + "metadata": { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "version": "1.0.0" + }, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "created_at": "2026-07-01 09:00:00", + "updated_at": "2026-07-10 11:20:00", + "language_packs": [] + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) | +| 500 | Internal Server Error | 조회 중 예외 발생 (`module.fetch_failed`) | @@ -1116,20 +1582,48 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| has_modified_layouts | boolean | `true` | 사용자가 수정한 레이아웃이 하나라도 있는지 여부 (원본 content 해시와 현재 해시 비교) | +| modified_count | integer | `2` | 수정된 레이아웃 수 | +| modified_layouts | array | `[{"id":31, ...}]` | 수정된 레이아웃 목록 (아래 하위 필드) | +| modified_layouts[].id | integer | `31` | 레이아웃 ID | +| modified_layouts[].name | string | `admin/board/index` | 레이아웃 이름 | +| modified_layouts[].updated_at | string\|null | `2026-07-10 11:20:00` | 최종 수정 일시 (`Y-m-d H:i:s`) | +| modified_layouts[].size_diff | integer | `128` | 원본 대비 현재 content 크기 차이(바이트, 음수 가능) | **응답 예시** - +```json +{ + "success": true, + "message": "수정된 레이아웃 확인이 완료되었습니다.", + "data": { + "has_modified_layouts": true, + "modified_count": 1, + "modified_layouts": [ + { + "id": 31, + "name": "admin/board/index", + "updated_at": "2026-07-10 11:20:00", + "size_diff": 128 + } + ] + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 | -| 404 | Not Found | 해당 식별자의 모듈이 설치되어 있지 않은 경우 (레이아웃 0건과 구분됨) | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) | +| 422 | Unprocessable Entity | 수정 레이아웃 확인 실패 (`modules.check_modified_layouts_failed` — `error.errors.module_name`) | +| 500 | Internal Server Error | 확인 처리 중 예외 발생 | @@ -1159,20 +1653,84 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`target` + `dependencies[]` + `language_packs[]`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| target.identifier | string | `sirsoft-ecommerce` | 설치 대상 모듈 식별자 | +| target.name | string\|null | `이커머스` | 설치 대상 모듈 이름 (현재 로케일) | +| target.version | string\|null | `1.0.1` | 설치 대상 모듈 버전 | +| dependencies | array | `[{"type":"module", ...}]` | 의존 확장 cascade 후보 목록 (아래 하위 필드) | +| dependencies[].type | string | `module` | 의존 확장 유형 (`module` \| `plugin`) | +| dependencies[].identifier | string | `sirsoft-board` | 의존 확장 식별자 | +| dependencies[].name | string\|null | `게시판` | 의존 확장 이름 | +| dependencies[].required_version | string\|null | `^1.0` | manifest 가 요구하는 버전 제약 | +| dependencies[].installed_version | string\|null | `null` | 현재 설치된 버전 (미설치면 null) | +| dependencies[].is_installed | boolean | `false` | 설치 여부 | +| dependencies[].is_active | boolean | `false` | 활성 여부 | +| dependencies[].is_met | boolean | `false` | 의존 조건 충족 여부 | +| dependencies[].available | boolean | `true` | cascade 후보로 선택 가능한지 (미충족 의존성만 true) | +| dependencies[].default_selected | boolean | `true` | 체크리스트 기본 선택 여부 (미충족 + 미설치 시 true) | +| language_packs | array | `[{"bundled_identifier":"g7-module-sirsoft-ecommerce-ja", ...}]` | 함께 설치 가능한 미설치 번들 언어팩 목록 (아래 하위 필드) | +| language_packs[].bundled_identifier | string | `g7-module-sirsoft-ecommerce-ja` | 번들 언어팩 식별자 (install API 의 `language_packs[]` 값) | +| language_packs[].locale | string | `ja` | 언어 코드 | +| language_packs[].locale_native_name | string\|null | `日本語` | 현지 표기 언어명 | +| language_packs[].locale_name | string\|null | `일본어` | 언어명 | +| language_packs[].version | string | `1.0.0` | 언어팩 버전 | +| language_packs[].depends_on_extension | string\|null | `null` | 이 언어팩이 귀속된 의존 확장 식별자 (본 확장용이면 null) | +| language_packs[].available | boolean | `true` | 선택 가능 여부 (항상 true — 미설치 항목만 수집) | +| language_packs[].default_selected | boolean | `true` | 체크리스트 기본 선택 여부 | **응답 예시** - +```json +{ + "success": true, + "message": "모듈을 성공적으로 가져왔습니다.", + "data": { + "target": { + "identifier": "sirsoft-ecommerce", + "name": "이커머스", + "version": "1.0.1" + }, + "dependencies": [ + { + "type": "module", + "identifier": "sirsoft-board", + "name": "게시판", + "required_version": "^1.0", + "installed_version": null, + "is_installed": false, + "is_active": false, + "is_met": false, + "available": true, + "default_selected": true + } + ], + "language_packs": [ + { + "bundled_identifier": "g7-module-sirsoft-ecommerce-ja", + "locale": "ja", + "locale_native_name": "日本語", + "locale_name": "일본어", + "version": "1.0.0", + "depends_on_extension": null, + "available": true, + "default_selected": true + } + ] + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) | +| 500 | Internal Server Error | 대상 확장을 찾을 수 없거나 프리뷰 빌드 중 예외 발생 (`module.fetch_failed`) | @@ -1202,20 +1760,70 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| tables | array | `[{"name":"board_posts", ...}]` | 모듈이 소유한 DB 테이블 목록 (마이그레이션 정적 추출 + 동적 테이블 병합). 각 항목: `name`, `size_bytes`(MySQL 외 드라이버는 null), `size_formatted` | +| storage_directories | array | `[{"name":"attachments", ...}]` | `storage/app/modules/{identifier}` 하위 1-depth 디렉토리 목록. 각 항목: `name`, `size_bytes`, `size_formatted` | +| vendor_directory | object\|null | `{"items":[...],"total_size_bytes":10485760, ...}` | Composer vendor 디렉토리·composer.lock 정보 (둘 다 없으면 null). `items[]` 각 항목: `name`, `size_bytes`, `size_formatted` | +| extension_directory | object\|null | `{"path":"modules/sirsoft-board", ...}` | 모듈 설치 디렉토리 정보 (`path`, `size_bytes`, `size_formatted`). 디렉토리 없으면 null | +| shared_records | array | `[{"table":"layouts","label_key":"layouts","count":12}]` | 코어 공유 테이블에 적재된 이 모듈의 레코드 수 (`delete_data=true` 시 정리 대상). 0건 항목은 제외 | +| total_table_size_bytes | integer | `2097152` | 모듈 테이블 용량 합계(바이트) | +| total_table_size_formatted | string | `2 MB` | 모듈 테이블 용량 합계(사람이 읽는 형식) | +| total_storage_size_bytes | integer | `524288` | 스토리지 디렉토리 용량 합계(바이트) | +| total_storage_size_formatted | string | `512 KB` | 스토리지 디렉토리 용량 합계(사람이 읽는 형식) | **응답 예시** - +```json +{ + "success": true, + "message": "모듈 삭제 정보를 성공적으로 조회했습니다.", + "data": { + "tables": [ + { + "name": "board_posts", + "size_bytes": 2097152, + "size_formatted": "2 MB" + } + ], + "storage_directories": [ + { + "name": "attachments", + "size_bytes": 524288, + "size_formatted": "512 KB" + } + ], + "vendor_directory": null, + "extension_directory": { + "path": "modules/sirsoft-board", + "size_bytes": 1048576, + "size_formatted": "1 MB" + }, + "shared_records": [ + { + "table": "layouts", + "label_key": "layouts", + "count": 12 + } + ], + "total_table_size_bytes": 2097152, + "total_table_size_formatted": "2 MB", + "total_storage_size_bytes": 524288, + "total_storage_size_formatted": "512 KB" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 404 | Not Found | 해당 식별자의 모듈을 찾을 수 없는 경우 (`module.not_found`) | +| 500 | Internal Server Error | 삭제 정보 조회 중 예외 발생 (`module.uninstall_info_failed`) | @@ -1259,20 +1867,75 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 는 업데이트된 모듈의 `ModuleResource` 객체 (목록 응답 항목과 동일 필드 구성)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-board` | 모듈 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `게시판` | 모듈 이름 (현재 로케일로 해석) | +| version | string | `1.0.1` | 업데이트 후 버전 | +| description | string | `게시판 관리를 위한 모듈` | 모듈 설명 | +| dependencies | array | `[]` | 의존 확장 목록 (enriched) | +| status | string | `active` | 업데이트 후 복원된 상태 | +| assets | object\|null | `null` | 프론트엔드 에셋 매니페스트 | +| update_available | boolean | `false` | 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `null` | 설치된 파일 manifest 버전 | +| github_url | string\|null | `https://github.com/gnuboard/g7-module-sirsoft-board` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소 항목 여부 | +| is_bundled | boolean | `false` | 코어 선탑재 번들 확장 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 요구 코어 버전 미충족 시 필요한 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자가 수행 가능한 작업 불리언 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "모듈이 성공적으로 업데이트되었습니다.", + "data": { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.1", + "description": "게시판 관리를 위한 모듈", + "dependencies": [], + "status": "active", + "assets": null, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-board", + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | +| 404 | Not Found | 해당 식별자의 모듈을 찾을 수 없는 경우 (`module.not_found`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 업데이트 실패 (업데이트 소스 없음·다운그레이드 차단·코어 버전 비호환 — `error.errors.module_name` 에 사유) | +| 500 | Internal Server Error | 업데이트 처리 중 예외 발생 (`modules.errors.update_failed`) | @@ -1343,20 +2006,27 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — 요청한 에셋 파일의 원본 바이트를 그대로 서빙합니다 (`Content-Type` 은 파일 MIME, `ETag` + `Cache-Control: max-age=31536000`). 에러 시에만 표준 JSON 에러 봉투를 반환합니다._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: text/javascript +ETag: "a1b2c3d4e5f6" +Cache-Control: public, max-age=31536000 + +(에셋 파일 원본 내용) +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 허용되지 않은 파일 확장자를 요청한 경우 (`modules.errors.file_type_not_allowed`) | +| 404 | Not Found | 모듈이 없거나 비활성 상태인 경우, 또는 요청한 파일이 없는 경우 (`modules.errors.not_found` / `modules.errors.file_not_found`) | +| 422 | Unprocessable Entity | 경로 검증 실패 (경로 탈출 시도 등 — FormRequest 검증) | +| 500 | Internal Server Error | 알 수 없는 오류 (`modules.errors.unknown_error`) | @@ -1383,19 +2053,22 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — 활성 모듈 CSS 를 병합한 원본 텍스트를 `text/css` 로 서빙합니다 (`ETag` + 환경별 `Cache-Control`). 활성 global 모듈 에셋이 없으면 빈 본문의 200 응답._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: text/css +ETag: "a1b2c3d4e5f6" +Cache-Control: public, max-age=31536000 + +(활성 모듈 CSS 병합 내용) +``` **에러 응답** -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +_에러 응답 없음 — 공개 엔드포인트이며, 병합 대상이 없어도 빈 본문의 200(text/css)을 반환합니다._ @@ -1422,19 +2095,22 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — 활성 모듈 IIFE JS 를 병합한 원본 텍스트를 `text/javascript` 로 서빙합니다 (`ETag` + 환경별 `Cache-Control`). 활성 global 모듈 에셋이 없으면 빈 본문의 200 응답._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: text/javascript +ETag: "a1b2c3d4e5f6" +Cache-Control: public, max-age=31536000 + +(활성 모듈 IIFE JS 병합 내용) +``` **에러 응답** -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +_에러 응답 없음 — 공개 엔드포인트이며, 병합 대상이 없어도 빈 본문의 200(text/javascript)을 반환합니다._ @@ -1589,7 +2265,19 @@ Accept: application/json **응답 필드** (`data` 내부) - + + +_이 엔드포인트는 표준 `success/message/data` 봉투를 사용하지 않습니다 — 모듈의 `components.json` 파일 내용을 최상위 JSON 으로 그대로 반환합니다 (1시간 캐시). 파일 미생성(구버전 모듈) 시 빈 객체로 폴백._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| $schema | string | `https://json-schema.org/draft/2020-12/schema` | 컴포넌트 매니페스트 JSON 스키마 URL | +| identifier | string | `sirsoft-ecommerce` | 컴포넌트를 제공하는 모듈 식별자 (네임스페이스 병합 키) | +| version | string | `1.0.0-beta.5` | 컴포넌트 매니페스트 버전 | +| components | object | `{"basic":[],"composite":[],"layout":[]}` | 타입별 컴포넌트 정의 묶음 | +| components.basic | array | `[]` | Basic 타입 컴포넌트 정의 목록 | +| components.composite | array | `[]` | Composite 타입 컴포넌트 정의 목록 | +| components.layout | array | `[]` | Layout 타입 컴포넌트 정의 목록 | **응답 예시** @@ -1600,7 +2288,7 @@ Accept: application/json | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -1693,7 +2381,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.modules.read | core.menus.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | diff --git a/docs/backend/api/notification-definitions.md b/docs/backend/api/notification-definitions.md index 3e2cec82..9e8d89af 100644 --- a/docs/backend/api/notification-definitions.md +++ b/docs/backend/api/notification-definitions.md @@ -369,11 +369,95 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 수정된 알림 정의를 `templates` 관계와 함께 반환합니다 (`NotificationDefinitionResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| type | string | `apidoc-sample.event` | 알림 타입 (welcome, order_confirmed 등) | +| hook_prefix | string | `core` | 훅 접두사 (core.auth, sirsoft-ecommerce 등) | +| extension_type | string | `core` | 이 정의를 소유한 확장의 타입 (core/module/plugin) | +| extension_identifier | string | `` | 이 정의를 소유한 확장의 식별자 (코어는 빈 문자열) | +| name | object | `{"ko":"API 문서 샘플 알림","en":"API Doc Sample Notification"}` | 다국어 이름 (로케일별 값 객체) | +| description | object | `{"ko":"문서 실측용 알림 정의","en":"Sample notification"}` | 다국어 설명 (로케일별 값 객체) | +| variables | array | `[]` | 사용 가능 변수 메타데이터 ([{key, description}]) | +| channels | array | `["database","mail"]` | 수정 후의 활성 채널 (["mail", "database"]) | +| hooks | array | `[]` | 수정 후의 트리거 훅 목록 (["core.auth.after_register"]) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `false` | default 여부 (사용자 수정 시 false) | +| templates | array | `[{"id":1,"definition_id":1,"channel":"mail", ...}]` | 채널별 알림 템플릿 목록 (`templates` 관계를 로드해 반환 — NotificationTemplateResource 배열) | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:43` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update/can_delete — 모두 `core.settings.update` 기준) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 정의가 수정되었습니다.", + "data": { + "id": 1, + "type": "apidoc-sample.event", + "hook_prefix": "core", + "extension_type": "core", + "extension_identifier": "", + "name": { + "ko": "API 문서 샘플 알림", + "en": "API Doc Sample Notification" + }, + "description": { + "ko": "문서 실측용 알림 정의", + "en": "Sample notification" + }, + "variables": [], + "channels": [ + "database", + "mail" + ], + "hooks": [], + "is_active": true, + "is_default": false, + "templates": [ + { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": "API 문서 샘플 템플릿 제목", + "body": "안녕하세요 {{name}} 님, 문서 실측용 본문입니다.", + "click_url": "/admin/apidoc-sample", + "recipients": [ + { + "type": "role", + "value": "admin", + "display_name": "관리자" + } + ], + "is_active": true, + "is_default": false, + "user_overrides": null, + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 12:14:43", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** diff --git a/docs/backend/api/notification-logs.md b/docs/backend/api/notification-logs.md index 2b067ab4..6075d782 100644 --- a/docs/backend/api/notification-logs.md +++ b/docs/backend/api/notification-logs.md @@ -208,11 +208,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `3` | 실제로 삭제된 알림 발송 이력 건수 (요청한 `ids` 중 존재하는 행만 카운트) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "선택한 알림 발송 이력이 삭제되었습니다.", + "data": { + "deleted_count": 3 + } +} +``` **에러 응답** @@ -250,9 +266,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `success(__('notification_log.delete_success'))` 만 호출하므로 `data` 는 `null`)._ **응답 예시** diff --git a/docs/backend/api/notification-templates.md b/docs/backend/api/notification-templates.md index a2078c1b..2a15be54 100644 --- a/docs/backend/api/notification-templates.md +++ b/docs/backend/api/notification-templates.md @@ -55,11 +55,25 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| subject | string | `[사이트명] 회원가입을 환영합니다` | 요청 `locale`(미지정 시 현재 앱 로케일) 의 제목에서 `{변수}` 를 정의(`definition_id`)의 변수 설명(`[설명]`)으로 치환한 결과 | +| body | string | `

[회원 이름]님, 환영합니다!

...` | 동일 로케일 본문에서 `{변수}` 를 정의의 변수 설명으로 치환한 결과. 해당 로케일 값이 없으면 빈 문자열 | **응답 예시** - +```json +{ + "success": true, + "message": "미리보기를 생성했습니다.", + "data": { + "subject": "[사이트명] 회원가입을 환영합니다", + "body": "

[회원 이름]님, 환영합니다!

[사이트명]에 가입해 주셔서 감사합니다.

" + } +} +``` **에러 응답** @@ -119,11 +133,67 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`NotificationTemplateResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| definition_id | integer | `1` | 소속 알림 정의 식별자 | +| channel | string | `mail` | 발송 채널 (`mail`, `database`, `fcm`) | +| subject | object | `{"ko":"[{app_name}] 회원가입을 환영합니다","en":"..."}` | 다국어 제목 (로케일 키 → 문자열, 로케일당 최대 500자) | +| body | object | `{"ko":"

{name}님, 환영합니다!

...","en":"..."}` | 다국어 본문 (로케일 키 → 문자열, 로케일당 최대 65535자) | +| click_url | string \| null | `null` | 알림 클릭 시 이동할 대상 URL (미설정 시 `null`) | +| recipients | array | `[{"type":"role","value":"admin","display_name":"관리자"}]` | 수신자 규칙 목록. `type` 은 `trigger_user`/`related_user`/`role`/`specific_users`. `role` 이면 `display_name`, `specific_users` 면 `display_names` 가 표시용으로 부가됨 | +| is_active | boolean | `true` | 활성 여부 (false 면 해당 채널 발송 중단) | +| is_default | boolean | `false` | 기본값 상태 여부 (수정 시 항상 `false` 로 전환됨) | +| user_overrides | array \| null | `["subject","body"]` | 사용자가 직접 수정한 필드명 목록 (`HasUserOverrides`) | +| updated_by | string \| null | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 최종 수정한 사용자 UUID (없으면 `null`) | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:43` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (모두 `core.settings.update` 권한 기반) | **응답 예시** - +```json +{ + "success": true, + "message": "알림 템플릿이 수정되었습니다.", + "data": { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": { + "ko": "[{app_name}] 회원가입을 환영합니다", + "en": "[{app_name}] Welcome to Our Service" + }, + "body": { + "ko": "

{name}님, 환영합니다!

", + "en": "

Welcome, {name}!

" + }, + "click_url": "/admin/notifications", + "recipients": [ + { + "type": "role", + "value": "admin", + "display_name": "관리자" + } + ], + "is_active": true, + "is_default": false, + "user_overrides": [ + "subject", + "body" + ], + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 12:14:43", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** diff --git a/docs/backend/api/notifications.md b/docs/backend/api/notifications.md index 30e77604..6326e24a 100644 --- a/docs/backend/api/notifications.md +++ b/docs/backend/api/notifications.md @@ -49,16 +49,16 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `23` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | -| id | string | `518b9c94-5853-4b68-8193-83af1851bbc6` | 기본 키 (내부 식별자) | -| type | string | `inquiry_received` | | -| type_label | string | `상품 문의 접수` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| subject | string | `새로운 상품 문의가 접수되었습니다` | | -| body | string | `옥혜진님이 "면 손수건 3매입 #1" 상품에 문의를 남겼습니다.` | | -| url | string | `https://g7_2.dev/admin/ecommerce/prod…` | | -| data | object | `{"type":"inquiry_received","subject":"새로운 상품 문의가 접수되었습니다"…` | | -| read_at | string | `2026-07-31 22:06:53` | read 일시 | -| created_at | string | `2026-07-31 21:41:58` | 생성 일시 | +| number | integer | `92` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | string | `02ee1121-ec63-4812-b77a-a0ffb4040acb` | 기본 키 (내부 식별자) | +| type | string | `new_order_admin` | 알림 유형 식별자 (알림 정의의 type — 발송 트리거를 구분) | +| type_label | string | `신규 주문 관리자 알림` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| subject | string | `새로운 주문이 접수되었습니다` | 알림 제목 (알림 템플릿의 subject 를 변수 치환해 렌더한 문자열) | +| body | string | `소경은님이 주문번호 20260713-0038038875 (결제금액:…` | 알림 본문 (알림 템플릿의 body 를 변수 치환해 렌더한 문자열) | +| url | string | `https://g7.dev/admin/ecommerce/orders…` | 알림 클릭 시 이동할 URL (템플릿의 click_url 우선, 없으면 메일 CTA action_url) | +| data | object | `{"type":"new_order_admin","subject":"새로운 주문이 접수되었습니다","bo…` | 알림 원본 페이로드 객체 (type/subject/body/click_url 과 발송 시 전달된 변수 data 를 포함) | +| read_at | null | `null` | read 일시 | +| created_at | string | `2026-07-13 09:38:04` | 생성 일시 | **응답 예시** @@ -302,11 +302,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| marked_count | integer | `2` | 실제로 읽음 처리된 알림 건수 (요청한 `ids` 중 본인 소유이면서 미읽음 상태였던 항목만 집계) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림을 읽음 처리했습니다.", + "data": { + "marked_count": 2 + } +} +``` **에러 응답** @@ -404,11 +420,21 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림이 삭제되었습니다.", + "data": null +} +``` **에러 응답** @@ -449,11 +475,51 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (읽음 처리 후 갱신된 알림 1건 — `UserNotificationResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | string | `02ee1121-ec63-4812-b77a-a0ffb4040acb` | 알림 기본 키 (UUID) | +| type | string | `new_order_admin` | 알림 유형 식별자 (알림 정의의 type — 발송 트리거를 구분) | +| type_label | string | `신규 주문 관리자 알림` | `type` 값의 사람이 읽는 라벨 (알림 정의의 현지화 이름, 정의 미존재 시 빈 문자열) | +| subject | string | `새로운 주문이 접수되었습니다` | 알림 제목 (알림 템플릿의 subject 를 변수 치환해 렌더한 문자열) | +| body | string | `소경은님이 주문번호 20260713-0038038875 (결제금액:…` | 알림 본문 (알림 템플릿의 body 를 변수 치환해 렌더한 문자열) | +| url | string \| null | `https://g7.dev/admin/ecommerce/orders/1` | 알림 클릭 시 이동할 URL (템플릿의 `click_url` 우선, 없으면 메일 CTA `action_url`, 둘 다 없으면 `null`) | +| data | object | `{"type":"new_order_admin","subject":"...","body":"...","click_url":"..."}` | 알림 원본 페이로드 객체 (type/subject/body/click_url 과 발송 시 전달된 변수 data 를 포함) | +| read_at | string \| null | `2026-07-13 09:40:11` | 읽음 처리 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) — 이 엔드포인트 성공 시 항상 값이 채워짐 | +| created_at | string \| null | `2026-07-13 09:38:04` | 알림 생성 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림을 읽음 처리했습니다.", + "data": { + "id": "02ee1121-ec63-4812-b77a-a0ffb4040acb", + "type": "new_order_admin", + "type_label": "신규 주문 관리자 알림", + "subject": "새로운 주문이 접수되었습니다", + "body": "소경은님이 주문번호 20260713-0038038875 주문을 접수했습니다.", + "url": "https://g7.dev/admin/ecommerce/orders/1", + "data": { + "type": "new_order_admin", + "subject": "새로운 주문이 접수되었습니다", + "body": "소경은님이 주문번호 20260713-0038038875 주문을 접수했습니다.", + "click_url": "https://g7.dev/admin/ecommerce/orders/1", + "data": { + "action_url": "https://g7.dev/admin/ecommerce/orders/1" + } + }, + "read_at": "2026-07-13 09:40:11", + "created_at": "2026-07-13 09:38:04" + } +} +``` **에러 응답** @@ -503,16 +569,16 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `23` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | -| id | string | `518b9c94-5853-4b68-8193-83af1851bbc6` | 기본 키 (내부 식별자) | -| type | string | `inquiry_received` | | -| type_label | string | `상품 문의 접수` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| subject | string | `새로운 상품 문의가 접수되었습니다` | | -| body | string | `옥혜진님이 "면 손수건 3매입 #1" 상품에 문의를 남겼습니다.` | | -| url | string | `https://g7_2.dev/admin/ecommerce/prod…` | | -| data | object | `{"type":"inquiry_received","subject":"새로운 상품 문의가 접수되었습니다"…` | | -| read_at | string | `2026-07-31 22:06:53` | read 일시 | -| created_at | string | `2026-07-31 21:41:58` | 생성 일시 | +| number | integer | `92` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | string | `02ee1121-ec63-4812-b77a-a0ffb4040acb` | 기본 키 (내부 식별자) | +| type | string | `new_order_admin` | 알림 유형 식별자 (알림 정의의 type — 발송 트리거를 구분) | +| type_label | string | `신규 주문 관리자 알림` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| subject | string | `새로운 주문이 접수되었습니다` | 알림 제목 (알림 템플릿의 subject 를 변수 치환해 렌더한 문자열) | +| body | string | `소경은님이 주문번호 20260713-0038038875 (결제금액:…` | 알림 본문 (알림 템플릿의 body 를 변수 치환해 렌더한 문자열) | +| url | string | `https://g7.dev/admin/ecommerce/orders…` | 알림 클릭 시 이동할 URL (템플릿의 click_url 우선, 없으면 메일 CTA action_url) | +| data | object | `{"type":"new_order_admin","subject":"새로운 주문이 접수되었습니다","bo…` | 알림 원본 페이로드 객체 (type/subject/body/click_url 과 발송 시 전달된 변수 data 를 포함) | +| read_at | null | `null` | read 일시 | +| created_at | string | `2026-07-13 09:38:04` | 생성 일시 | **응답 예시** @@ -756,11 +822,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| marked_count | integer | `2` | 실제로 읽음 처리된 알림 건수 (요청한 `ids` 중 본인 소유이면서 미읽음 상태였던 항목만 집계) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림을 읽음 처리했습니다.", + "data": { + "marked_count": 2 + } +} +``` **에러 응답** @@ -858,11 +940,21 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림이 삭제되었습니다.", + "data": null +} +``` **에러 응답** @@ -903,11 +995,51 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (읽음 처리 후 갱신된 알림 1건 — `UserNotificationResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | string | `9f2c1a44-1b6e-4a55-8b0e-4c8a3f1d90ab` | 알림 기본 키 (UUID) | +| type | string | `order_shipped` | 알림 유형 식별자 (알림 정의의 type — 발송 트리거를 구분) | +| type_label | string | `배송 시작 알림` | `type` 값의 사람이 읽는 라벨 (알림 정의의 현지화 이름, 정의 미존재 시 빈 문자열) | +| subject | string | `주문하신 상품이 발송되었습니다` | 알림 제목 (알림 템플릿의 subject 를 변수 치환해 렌더한 문자열) | +| body | string | `주문번호 20260713-0038038875 상품이 발송되었습니다.` | 알림 본문 (알림 템플릿의 body 를 변수 치환해 렌더한 문자열) | +| url | string \| null | `https://g7.dev/mypage/orders/1` | 알림 클릭 시 이동할 URL (템플릿의 `click_url` 우선, 없으면 메일 CTA `action_url`, 둘 다 없으면 `null`) | +| data | object | `{"type":"order_shipped","subject":"...","body":"...","click_url":"..."}` | 알림 원본 페이로드 객체 (type/subject/body/click_url 과 발송 시 전달된 변수 data 를 포함) | +| read_at | string \| null | `2026-07-13 10:02:33` | 읽음 처리 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) — 이 엔드포인트 성공 시 항상 값이 채워짐 | +| created_at | string \| null | `2026-07-13 09:58:12` | 알림 생성 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림을 읽음 처리했습니다.", + "data": { + "id": "9f2c1a44-1b6e-4a55-8b0e-4c8a3f1d90ab", + "type": "order_shipped", + "type_label": "배송 시작 알림", + "subject": "주문하신 상품이 발송되었습니다", + "body": "주문번호 20260713-0038038875 상품이 발송되었습니다.", + "url": "https://g7.dev/mypage/orders/1", + "data": { + "type": "order_shipped", + "subject": "주문하신 상품이 발송되었습니다", + "body": "주문번호 20260713-0038038875 상품이 발송되었습니다.", + "click_url": "https://g7.dev/mypage/orders/1", + "data": { + "action_url": "https://g7.dev/mypage/orders/1" + } + }, + "read_at": "2026-07-13 10:02:33", + "created_at": "2026-07-13 09:58:12" + } +} +``` **에러 응답** diff --git a/docs/backend/api/password.md b/docs/backend/api/password.md index ddc9b676..81b60526 100644 --- a/docs/backend/api/password.md +++ b/docs/backend/api/password.md @@ -51,11 +51,17 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```json +{ + "success": true, + "message": "비밀번호가 성공적으로 변경되었습니다.", + "data": null +} +``` **에러 응답** diff --git a/docs/backend/api/plugins.md b/docs/backend/api/plugins.md index 83195078..6cc66aad 100644 --- a/docs/backend/api/plugins.md +++ b/docs/backend/api/plugins.md @@ -237,11 +237,64 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| plugin | object | `{"identifier":"sirsoft-daum_postcode","status":"active", …}` | 활성화된 플러그인 리소스 (PluginResource — 목록 항목과 동일 필드 구성) | +| pending_language_packs | array | `[]` | 이 플러그인 비활성화 시 cascade 로 함께 비활성화됐던 번들 언어팩 목록 (재활성화 대기 후보) | **응답 예시** - +```json +{ + "success": true, + "message": "플러그인이 성공적으로 활성화되었습니다.", + "data": { + "plugin": { + "id": 3, + "identifier": "sirsoft-daum_postcode", + "vendor": "sirsoft", + "name": "Daum 우편번호", + "version": "1.0.0", + "description": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.", + "dependencies": [], + "permissions": [], + "roles": [], + "config": [], + "hooks": [], + "status": "active", + "is_installed": true, + "has_settings": true, + "settings_route": "/admin/plugins/sirsoft-daum_postcode/settings", + "assets": { + "js": "/api/plugins/assets/sirsoft-daum_postcode/dist/js/plugin.iife.js", + "css": null, + "priority": 100 + }, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": true, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + }, + "pending_language_packs": [] + } +} +``` + +의존 확장이 미충족인 상태에서 `force` 없이 호출하면 409 로 경고가 반환됩니다 (`error.warning`, `error.missing_modules`, `error.missing_plugins`, `error.message`). **에러 응답** @@ -249,7 +302,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | +| 409 | Conflict | 필요한 의존 확장(모듈/플러그인)이 설치·활성화되지 않은 경우 (`force: true` 로 우회 가능) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 활성화 처리 중 예외 발생 (`plugins.activate_error`) | @@ -277,11 +332,36 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `1` | 업데이트 가능으로 감지된 플러그인 개수 | +| details | array | `[{"identifier":"sirsoft-daum_postcode", …}]` | 업데이트 가능한 플러그인별 상세 배열 (아래 필드) | +| details[].identifier | string | `sirsoft-daum_postcode` | 플러그인 고유 식별자 | +| details[].current_version | string | `1.0.0` | 현재 설치된 버전 | +| details[].latest_version | string | `1.1.0` | 감지된 최신 배포 버전 | +| details[].update_source | string | `github` | 업데이트 감지 출처 (`github` \| `bundled`) | **응답 예시** - +```json +{ + "success": true, + "message": "업데이트 확인이 완료되었습니다.", + "data": { + "updated_count": 1, + "details": [ + { + "identifier": "sirsoft-daum_postcode", + "current_version": "1.0.0", + "latest_version": "1.1.0", + "update_source": "github" + } + ] + } +} +``` **에러 응답** @@ -290,6 +370,7 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 업데이트 확인 중 예외 발생 (`plugins.check_updates_failed`) | @@ -328,11 +409,71 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 — 비활성화된 플러그인 리소스(PluginResource) 를 그대로 반환합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer\|null | `3` | 기본 키 (내부 식별자) | +| identifier | string | `sirsoft-daum_postcode` | 플러그인 고유 식별자 (vendor-plugin 형식) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Daum 우편번호` | 플러그인 이름 (현재 로케일로 해석) | +| version | string | `1.0.0` | 플러그인 버전 | +| status | string | `inactive` | 비활성화 후 상태 (`inactive`) | +| is_installed | boolean | `true` | 설치 여부 | +| deactivated_reason | string\|null | `manual` | 비활성화 사유 (manual: 사용자 수동 \| incompatible_core: 코어 버전 비호환) | +| deactivated_at | string\|null | `2026-07-14T05:12:33.000000Z` | 비활성화 일시 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자의 수행 가능 작업 맵 | + +나머지 필드는 목록(`GET /api/admin/plugins`) 응답 항목과 동일합니다 (PluginResource 단일 정의). **응답 예시** - +```json +{ + "success": true, + "message": "플러그인이 성공적으로 비활성화되었습니다.", + "data": { + "id": 3, + "identifier": "sirsoft-daum_postcode", + "vendor": "sirsoft", + "name": "Daum 우편번호", + "version": "1.0.0", + "description": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.", + "dependencies": [], + "permissions": [], + "roles": [], + "config": [], + "hooks": [], + "status": "inactive", + "is_installed": true, + "has_settings": true, + "settings_route": "/admin/plugins/sirsoft-daum_postcode/settings", + "assets": { + "js": "/api/plugins/assets/sirsoft-daum_postcode/dist/js/plugin.iife.js", + "css": null, + "priority": 100 + }, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": true, + "deactivated_reason": "manual", + "deactivated_at": "2026-07-14T05:12:33.000000Z", + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` + +의존 확장이 있는 상태에서 `force` 없이 호출하면 409 로 경고가 반환됩니다 (`error.warning`, `error.dependent_templates`, `error.dependent_modules`, `error.dependent_plugins`, `error.message`). **에러 응답** @@ -340,7 +481,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | +| 409 | Conflict | 이 플러그인에 의존하는 활성 템플릿/모듈/플러그인이 있는 경우 (`force: true` 로 우회 가능) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 비활성화 처리 중 예외 발생 (`plugins.deactivate_error`) | @@ -387,11 +530,70 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 — 설치된 플러그인 리소스(PluginResource) + `language_pack_failures`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-daum_postcode` | 설치된 플러그인 고유 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Daum 우편번호` | 플러그인 이름 (현재 로케일로 해석) | +| version | string | `1.0.0` | 설치된 버전 | +| status | string | `inactive` | 설치 직후 상태 (설치만 수행 — 활성화는 별도 호출) | +| is_installed | boolean | `true` | 설치 여부 | +| has_settings | boolean | `true` | 설정 UI 제공 여부 | +| settings_route | string\|null | `/admin/plugins/sirsoft-daum_postcode/settings` | 설정 페이지 경로 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자의 수행 가능 작업 맵 | +| language_pack_failures | array | `[]` | cascade 2단계(번들 언어팩 best-effort 설치) 에서 실패한 항목 목록 (성공 시 빈 배열) | + +나머지 필드는 목록(`GET /api/admin/plugins`) 응답 항목과 동일합니다 (PluginResource 단일 정의). **응답 예시** - +```json +{ + "success": true, + "message": "플러그인이 성공적으로 설치되었습니다.", + "data": { + "id": 3, + "identifier": "sirsoft-daum_postcode", + "vendor": "sirsoft", + "name": "Daum 우편번호", + "version": "1.0.0", + "description": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.", + "dependencies": [], + "permissions": [], + "roles": [], + "config": [], + "hooks": [], + "status": "inactive", + "is_installed": true, + "has_settings": true, + "settings_route": "/admin/plugins/sirsoft-daum_postcode/settings", + "assets": { + "js": "/api/plugins/assets/sirsoft-daum_postcode/dist/js/plugin.iife.js", + "css": null, + "priority": 100 + }, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": true, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + }, + "language_pack_failures": [] + } +} +``` **에러 응답** @@ -399,7 +601,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패 또는 설치 실패 (`plugin_name` 에 번역된 설치 실패 사유 — 이미 설치됨/대기소 미존재/의존성 미충족 등) | +| 500 | Server Error | 설치 처리 중 예외 발생 (`plugins.installation_failed`) | @@ -439,11 +642,66 @@ Content-Type: application/octet-stream **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 — 설치된 플러그인 리소스(PluginResource). 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-daum_postcode` | 설치된 플러그인 고유 식별자 (ZIP 내 plugin.json 기준) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Daum 우편번호` | 플러그인 이름 | +| version | string | `1.0.0` | 설치된 버전 | +| status | string | `inactive` | 설치 직후 상태 | +| is_installed | boolean | `true` | 설치 여부 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자의 수행 가능 작업 맵 | + +나머지 필드는 목록(`GET /api/admin/plugins`) 응답 항목과 동일합니다 (PluginResource 단일 정의). **응답 예시** - +```json +{ + "success": true, + "message": "플러그인이 성공적으로 설치되었습니다.", + "data": { + "id": 3, + "identifier": "sirsoft-daum_postcode", + "vendor": "sirsoft", + "name": "Daum 우편번호", + "version": "1.0.0", + "description": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.", + "dependencies": [], + "permissions": [], + "roles": [], + "config": [], + "hooks": [], + "status": "inactive", + "is_installed": true, + "has_settings": true, + "settings_route": "/admin/plugins/sirsoft-daum_postcode/settings", + "assets": { + "js": "/api/plugins/assets/sirsoft-daum_postcode/dist/js/plugin.iife.js", + "css": null, + "priority": 100 + }, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` **에러 응답** @@ -451,7 +709,8 @@ Content-Type: application/octet-stream | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 파일 검증 실패(ZIP 아님/50MB 초과) 또는 ZIP 처리 실패 (plugin.json 미존재/형식 오류/식별자 누락/이미 설치됨) | +| 500 | Server Error | 설치 처리 중 예외 발생 (`plugins.install_failed`) | @@ -488,11 +747,67 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 — 설치된 플러그인 리소스(PluginResource). 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-daum_postcode` | 설치된 플러그인 고유 식별자 (저장소 plugin.json 기준) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Daum 우편번호` | 플러그인 이름 | +| version | string | `1.0.0` | 설치된 버전 (GitHub 릴리스 기준) | +| status | string | `inactive` | 설치 직후 상태 | +| is_installed | boolean | `true` | 설치 여부 | +| github_url | string\|null | `https://github.com/gnuboard/g7-plugin-daum_postcode` | 설치 출처 GitHub 저장소 URL | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자의 수행 가능 작업 맵 | + +나머지 필드는 목록(`GET /api/admin/plugins`) 응답 항목과 동일합니다 (PluginResource 단일 정의). **응답 예시** - +```json +{ + "success": true, + "message": "플러그인이 성공적으로 설치되었습니다.", + "data": { + "id": 3, + "identifier": "sirsoft-daum_postcode", + "vendor": "sirsoft", + "name": "Daum 우편번호", + "version": "1.0.0", + "description": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.", + "dependencies": [], + "permissions": [], + "roles": [], + "config": [], + "hooks": [], + "status": "inactive", + "is_installed": true, + "has_settings": true, + "settings_route": "/admin/plugins/sirsoft-daum_postcode/settings", + "assets": { + "js": "/api/plugins/assets/sirsoft-daum_postcode/dist/js/plugin.iife.js", + "css": null, + "priority": 100 + }, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": "https://github.com/gnuboard/g7-plugin-daum_postcode", + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` **에러 응답** @@ -500,7 +815,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | URL 형식 오류(GitHub 저장소 URL 아님) 또는 다운로드/압축 해제/검증 실패 (저장소 미존재·ZIP URL 미발견·plugin.json 오류·이미 설치됨) | +| 500 | Server Error | 설치 처리 중 예외 발생 (`plugins.install_failed`) | @@ -706,11 +1022,40 @@ Content-Type: application/octet-stream **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| manifest | object\|null | `{"identifier":"sirsoft-daum_postcode","version":"1.0.0", …}` | ZIP 내 plugin.json 원문 (파싱 실패 시 null) | +| validation | object | `{"errors":[],"is_valid":true, …}` | 검증 결과 객체 (아래 필드) | +| validation.errors | array | `[]` | 압축 해제/manifest 검증 실패 사유 문자열 목록 (성공 시 빈 배열) | +| validation.is_valid | boolean | `true` | 검증 통과 여부 (errors 가 비었고 manifest 파싱 성공) | +| validation.already_installed | boolean | `false` | 동일 식별자 플러그인이 이미 설치되어 있는지 여부 | +| validation.existing_version | string\|null | `1.0.0` | 이미 설치된 경우 그 버전 (미설치 시 null) | **응답 예시** - +```json +{ + "success": true, + "message": "manifest 미리보기를 완료했습니다.", + "data": { + "manifest": { + "identifier": "sirsoft-daum_postcode", + "vendor": "sirsoft", + "name": "Daum 우편번호", + "version": "1.0.0", + "description": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다." + }, + "validation": { + "errors": [], + "is_valid": true, + "already_installed": false, + "existing_version": null + } + } +} +``` **에러 응답** @@ -718,7 +1063,7 @@ Content-Type: application/octet-stream | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 파일 검증 실패(ZIP 아님/50MB 초과) 또는 미리보기 처리 실패 (`plugins.preview_failed`) | @@ -755,11 +1100,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| layouts_refreshed | integer | `3` | 갱신 처리된 레이아웃 총 건수 | +| created | integer | `1` | 신규 등록된 레이아웃 건수 | +| updated | integer | `2` | 내용이 변경되어 갱신된 레이아웃 건수 | +| deleted | integer | `0` | 파일에서 사라져 DB 에서도 삭제된 레이아웃 건수 | +| unchanged | integer | `0` | 변경 없이 그대로 유지된 레이아웃 건수 | **응답 예시** - +```json +{ + "success": true, + "message": "플러그인 레이아웃이 성공적으로 갱신되었습니다.", + "data": { + "layouts_refreshed": 3, + "created": 1, + "updated": 2, + "deleted": 0, + "unchanged": 0 + } +} +``` **에러 응답** @@ -767,7 +1132,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패 또는 레이아웃 갱신 실패 (`plugin_name` 에 레이아웃 JSON 파싱/검증 오류 사유) | +| 500 | Server Error | 갱신 처리 중 예외 발생 (`plugins.refresh_layouts_error`) | @@ -800,11 +1166,17 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만)._ **응답 예시** - +```json +{ + "success": true, + "message": "플러그인이 성공적으로 제거되었습니다.", + "data": null +} +``` **에러 응답** @@ -812,7 +1184,8 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패 또는 제거 실패 (`plugin_name` 에 번역된 제거 실패 사유 — 미설치/진행 중 상태 등) | +| 500 | Server Error | 제거 처리 중 예외 발생 (`plugins.uninstall_error`) | @@ -1032,12 +1405,12 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| identifier | string | `sirsoft-basic` | 플러그인 고유 식별자 (vendor-plugin 형식) | -| name | string | `Basic` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| version | string | `1.0.4` | 플러그인 버전 | -| type | string | `user` | | -| status | string | `active` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | -| required_version | string | `>=1.0.0` | | +| identifier | string | `sirsoft-ckeditor5` | 플러그인 고유 식별자 (vendor-plugin 형식) | +| name | string | `CKEditor 5 WYSIWYG 에디터` | 플러그인 이름 (다국어 JSON) | +| version | string | `1.0.0` | 플러그인 버전 | +| type | string | `user` | 의존 템플릿의 타입 (admin: 관리자 템플릿 / user: 사용자 템플릿) | +| status | string | `active` | 상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중) | +| required_version | string | `>=1.0.0` | 요구되는 최소 버전 | **응답 예시** @@ -1169,10 +1542,10 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| display_mode | string | `layer` | | -| popup_width | integer | `500` | | -| popup_height | integer | `600` | | -| theme_color | string | `#1D4ED8` | | +| display_mode | string | `layer` | 표시 방식 (렌더/노출 모드 구분 값) | +| popup_width | integer | `500` | 팝업 창 너비 (px) | +| popup_height | integer | `600` | 팝업 창 높이 (px) | +| theme_color | string | `#1D4ED8` | 테마 대표 색상 | **응답 예시** @@ -1185,7 +1558,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": { "display_mode": "layer", "popup_width": 500, @@ -1232,11 +1605,29 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 는 저장 후 다시 조회한 설정 값 맵입니다. 필드 구성은 플러그인마다 다르며 각 플러그인의 설정 스키마(`GET /api/admin/plugins/{identifier}/settings/layout` 의 `schema`)를 따릅니다. 아래는 `sirsoft-daum_postcode` 예._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| display_mode | string | `layer` | 주소 검색창 표시 방식 (popup: 팝업 창 \| layer: 레이어) | +| popup_width | integer | `500` | 팝업 창 너비 (px) | +| popup_height | integer | `600` | 팝업 창 높이 (px) | +| theme_color | string | `#1D4ED8` | 검색창 테마 대표 색상 | **응답 예시** - +```json +{ + "success": true, + "message": "플러그인 설정이 성공적으로 업데이트되었습니다.", + "data": { + "display_mode": "layer", + "popup_width": 500, + "popup_height": 600, + "theme_color": "#1D4ED8" + } +} +``` **에러 응답** @@ -1246,6 +1637,7 @@ Authorization: Bearer {YOUR_TOKEN} | 403 | Forbidden | 요구 권한(`core.plugins.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 설정 저장 실패 (`plugins.settings.update_failed`) | @@ -1280,14 +1672,14 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | version | string | `1.0.0` | 플러그인 버전 | -| layout_name | string | `plugin_settings` | | -| permissions | array | `["core.plugins.update"]` | 권한 목록 (각 원소 id/identifier/name — permissions 관계 또는 역할 경유 파생) | -| extends | string | `_admin_base` | | -| meta | object | `{"title":"$t:sirsoft-daum_postcode.settings.title","descr…` | | -| data_sources | array | `[{"id":"settings","label_key":"$t:sirsoft-daum_postcode.e…` | | -| slots | object | `{"content":[{"id":"plugin_settings_content","type":"basic…` | | -| pageConfig | object | `{"notice":"$t:sirsoft-daum_postcode.settings.notice","gui…` | | -| schema | object | `{"display_mode":{"type":"enum","options":["popup","layer"…` | | +| layout_name | string | `plugin_settings` | 레이아웃 식별자 (파일 경로 기반 — 예: board/popular) | +| permissions | array | `[]` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| extends | string | `_admin_base` | 상속하는 베이스 레이아웃 이름 (미상속 시 null) | +| meta | object | `{"title":"$t:sirsoft-daum_postcode.settings.title","descr…` | 메타 정보 객체 (title/description/seo 등) | +| data_sources | array | `[{"id":"settings","label_key":"$t:sirsoft-daum_postcode.e…` | API 데이터 소스 정의 배열 (id/endpoint/method) | +| slots | object | `{"content":[{"id":"plugin_settings_content","type":"basic…` | 슬롯별 삽입 콘텐츠 맵 (베이스 레이아웃의 slot 위치에 주입) | +| pageConfig | object | `{"notice":"$t:sirsoft-daum_postcode.settings.notice","gui…` | 페이지 단위 설정 객체 | +| schema | object | `{"display_mode":{"type":"enum","options":["popup","layer"…` | 스키마 정의 객체 | **응답 예시** @@ -1300,7 +1692,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": { "version": "1.0.0", "layout_name": "plugin_settings", @@ -1354,11 +1746,85 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (PluginResource::toDetailArray + 언어팩 주입)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer\|null | `3` | 기본 키 (내부 식별자, 미설치 시 null) | +| identifier | string | `sirsoft-daum_postcode` | 플러그인 고유 식별자 (vendor-plugin 형식) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Daum 우편번호` | 플러그인 이름 (현재 로케일로 해석) | +| version | string | `1.0.0` | 플러그인 버전 | +| description | string | `Daum 우편번호 서비스를 통한 주소 검색 기능을 …` | 플러그인 설명 (현재 로케일로 해석) | +| github_url | string\|null | `https://github.com/gnuboard/g7-plugin-daum_postcode` | GitHub 저장소 URL | +| requires_core | string\|null | `>=7.0.0-beta.2` | 요구되는 코어 최소 버전 (manifest `requires.g7_version`) | +| dependencies | array | `[]` | 의존하는 확장 목록 (manifest 파생) | +| status | string | `active` | 상태 (active/inactive/uninstalled/installing/uninstalling/updating) | +| is_installed | boolean | `true` | 설치 여부 | +| has_settings | boolean | `true` | 설정 UI 제공 여부 | +| settings_route | string\|null | `/admin/plugins/sirsoft-daum_postcode/settings` | 설정 페이지 경로 | +| permissions | array | `[]` | 연결된 권한 목록 | +| roles | array | `[]` | 플러그인이 정의한 역할 목록 | +| hooks | array | `[]` | 훅 설정 정보 | +| config | array | `[]` | 플러그인 설정 값 맵 | +| license | string\|null | `MIT` | 라이선스 식별자 (manifest `license`) | +| metadata | array | `[]` | manifest 부가 메타데이터 | +| update_available | boolean | `false` | 업데이트 가능 여부 | +| update_source | string\|null | `github` | 업데이트 감지 출처 (github \| bundled) | +| latest_version | string\|null | `1.0.0` | 감지된 최신 배포 버전 | +| file_version | string\|null | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_changelog_url | string\|null | `https://github.com/gnuboard/g7-plugin-daum_postcode/releases` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소에 있어 설치 대기 중인지 여부 | +| is_bundled | boolean | `true` | 코어에 선탑재된 번들 확장인지 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 (manual \| incompatible_core \| null) | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 코어 버전 미충족 시 필요한 버전 (호환되면 null) | +| created_at | string\|null | `2026-07-01T02:11:03.000000Z` | 설치(레코드 생성) 일시 | +| updated_at | string\|null | `2026-07-10T08:24:51.000000Z` | 최종 갱신 일시 | +| language_packs | array | `[]` | 이 플러그인이 지원하는 번들 언어팩 목록 (설치/설치가능 슬롯) | **응답 예시** - +```json +{ + "success": true, + "message": "플러그인 정보를 성공적으로 가져왔습니다.", + "data": { + "id": 3, + "identifier": "sirsoft-daum_postcode", + "vendor": "sirsoft", + "name": "Daum 우편번호", + "version": "1.0.0", + "description": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.", + "github_url": null, + "requires_core": ">=7.0.0-beta.2", + "dependencies": [], + "status": "active", + "is_installed": true, + "has_settings": true, + "settings_route": "/admin/plugins/sirsoft-daum_postcode/settings", + "permissions": [], + "roles": [], + "hooks": [], + "config": [], + "license": "MIT", + "metadata": [], + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_changelog_url": null, + "is_pending": false, + "is_bundled": true, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "created_at": "2026-07-01T02:11:03.000000Z", + "updated_at": "2026-07-10T08:24:51.000000Z", + "language_packs": [] + } +} +``` **에러 응답** @@ -1366,8 +1832,9 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해당 식별자의 플러그인이 존재하지 않는 경우 (`plugins.not_found`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 조회 처리 중 예외 발생 (`plugins.fetch_failed`) | @@ -1397,11 +1864,38 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| has_modified_layouts | boolean | `true` | 사용자가 수정한 레이아웃이 하나라도 있는지 여부 | +| modified_count | integer | `1` | 수정된 레이아웃 건수 | +| modified_layouts | array | `[{"id":12,"name":"plugin_settings", …}]` | 수정된 레이아웃 상세 배열 (아래 필드) | +| modified_layouts[].id | integer | `12` | 레이아웃 레코드 기본 키 | +| modified_layouts[].name | string | `plugin_settings` | 레이아웃 이름 (파일 경로 기반) | +| modified_layouts[].updated_at | string\|null | `2026-07-10 08:24:51` | 최종 수정 일시 (Y-m-d H:i:s) | +| modified_layouts[].size_diff | integer | `128` | 원본 대비 콘텐츠 크기 증감 (바이트, 음수 가능) | **응답 예시** - +```json +{ + "success": true, + "message": "수정된 레이아웃 확인이 완료되었습니다.", + "data": { + "has_modified_layouts": true, + "modified_count": 1, + "modified_layouts": [ + { + "id": 12, + "name": "plugin_settings", + "updated_at": "2026-07-10 08:24:51", + "size_diff": 128 + } + ] + } +} +``` **에러 응답** @@ -1409,8 +1903,9 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 404 | Not Found | 해당 식별자의 플러그인이 설치되어 있지 않은 경우 (레이아웃 0건과 구분됨) | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 확인 처리 실패 (`plugin_name` 에 실패 사유 — `plugins.check_modified_layouts_failed`) | +| 500 | Server Error | 확인 처리 중 예외 발생 | @@ -1440,11 +1935,60 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| target | object | `{"identifier":"sirsoft-daum_postcode","name":"Daum 우편번호","version":"1.0.0"}` | 설치 대상 플러그인 요약 (identifier/name/version) | +| dependencies | array | `[]` | 의존 확장 cascade 후보 목록 (아래 필드) | +| dependencies[].type | string | `module` | 의존 확장 유형 (module \| plugin) | +| dependencies[].identifier | string | `sirsoft-ecommerce` | 의존 확장 식별자 | +| dependencies[].name | string\|null | `이커머스` | 의존 확장 이름 | +| dependencies[].required_version | string\|null | `>=1.0.0` | 요구되는 최소 버전 | +| dependencies[].installed_version | string\|null | `null` | 현재 설치된 버전 (미설치 시 null) | +| dependencies[].is_installed | boolean | `false` | 설치 여부 | +| dependencies[].is_active | boolean | `false` | 활성화 여부 | +| dependencies[].is_met | boolean | `false` | 의존 조건 충족 여부 | +| dependencies[].available | boolean | `true` | cascade 선택 후보 여부 (미충족 항목만 true) | +| dependencies[].default_selected | boolean | `true` | 체크리스트 기본 선택 여부 (미충족 + 미설치) | +| language_packs | array | `[]` | 함께 설치 가능한 미설치 번들 언어팩 목록 (아래 필드) | +| language_packs[].bundled_identifier | string | `g7-plugin-daum_postcode-ja` | 번들 언어팩 식별자 | +| language_packs[].locale | string | `ja` | 로케일 코드 | +| language_packs[].locale_native_name | string | `日本語` | 로케일 원어 표기 | +| language_packs[].locale_name | string | `일본어` | 로케일 이름 (현재 로케일 표기) | +| language_packs[].version | string | `1.0.0` | 언어팩 버전 | +| language_packs[].depends_on_extension | string\|null | `null` | 이 언어팩이 귀속된 의존 확장 식별자 (본 확장 소유이면 null) | +| language_packs[].available | boolean | `true` | 함께 설치 가능한지 여부 | +| language_packs[].default_selected | boolean | `true` | 체크리스트 기본 선택 여부 | **응답 예시** - +```json +{ + "success": true, + "message": "플러그인 정보를 성공적으로 가져왔습니다.", + "data": { + "target": { + "identifier": "sirsoft-daum_postcode", + "name": "Daum 우편번호", + "version": "1.0.0" + }, + "dependencies": [], + "language_packs": [ + { + "bundled_identifier": "g7-plugin-daum_postcode-ja", + "locale": "ja", + "locale_native_name": "日本語", + "locale_name": "일본어", + "version": "1.0.0", + "depends_on_extension": null, + "available": true, + "default_selected": true + } + ] + } +} +``` **에러 응답** @@ -1454,6 +1998,7 @@ Authorization: Bearer {YOUR_TOKEN} | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 대상 확장을 찾을 수 없거나 프리뷰 빌드 중 예외 발생 (`plugins.fetch_failed`) | @@ -1483,11 +2028,52 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| tables | array | `[{"name":"plugin_daum_logs","size_bytes":16384, …}]` | 삭제 대상 DB 테이블 목록 (마이그레이션 정적 테이블 + 동적 테이블) | +| tables[].name | string | `plugin_daum_logs` | 테이블명 (prefix 제외) | +| tables[].size_bytes | integer\|null | `16384` | 테이블 용량 (바이트). 용량 조회 미지원 DB 에서는 null | +| tables[].size_formatted | string | `16 KB` | 사람이 읽는 용량 표기 | +| storage_directories | array | `[]` | `storage/app/plugins/{identifier}` 하위 1-depth 디렉토리 목록 (name/size_bytes/size_formatted) | +| vendor_directory | object\|null | `{"items":[…],"total_size_bytes":1048576,"total_size_formatted":"1 MB"}` | Composer vendor/ 및 composer.lock 정보 (없으면 null) | +| extension_directory | object\|null | `{"path":"plugins/sirsoft-daum_postcode","size_bytes":524288,"size_formatted":"512 KB"}` | 확장 설치 디렉토리 경로와 용량 (없으면 null) | +| shared_records | array | `[{"table":"permissions","label_key":"permissions","count":3}]` | 코어 공유 테이블에 적재된 이 플러그인 소유 레코드 (permissions/menus/notification_definitions/identity_policies/identity_message_definitions, 0건 항목은 제외) | +| total_table_size_bytes | integer | `16384` | 삭제 대상 테이블 총 용량 (바이트) | +| total_table_size_formatted | string | `16 KB` | 테이블 총 용량 표기 | +| total_storage_size_bytes | integer | `0` | 스토리지 디렉토리 총 용량 (바이트) | +| total_storage_size_formatted | string | `0 B` | 스토리지 총 용량 표기 | **응답 예시** - +```json +{ + "success": true, + "message": "플러그인 삭제 정보를 성공적으로 조회했습니다.", + "data": { + "tables": [], + "storage_directories": [], + "vendor_directory": null, + "extension_directory": { + "path": "plugins/sirsoft-daum_postcode", + "size_bytes": 524288, + "size_formatted": "512 KB" + }, + "shared_records": [ + { + "table": "permissions", + "label_key": "permissions", + "count": 3 + } + ], + "total_table_size_bytes": 0, + "total_table_size_formatted": "0 B", + "total_storage_size_bytes": 0, + "total_storage_size_formatted": "0 B" + } +} +``` **에러 응답** @@ -1495,8 +2081,9 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해당 식별자의 플러그인이 존재하지 않는 경우 (`plugins.not_found`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 삭제 정보 조회 중 예외 발생 (`plugins.uninstall_info_failed`) | @@ -1540,11 +2127,66 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 는 업데이트된 플러그인 리소스(PluginResource) 입니다. 플러그인 정보를 얻을 수 없는 예외 경로에서는 업데이트 결과 맵(success/from_version/to_version/message) 이 반환됩니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-daum_postcode` | 업데이트된 플러그인 고유 식별자 | +| version | string | `1.1.0` | 업데이트 후 버전 | +| file_version | string\|null | `1.1.0` | 설치된 파일의 manifest 버전 | +| status | string | `active` | 업데이트 후 상태 (업데이트 이전 상태로 복원) | +| update_available | boolean | `false` | 업데이트 후 재계산된 업데이트 가능 여부 | +| latest_version | string\|null | `1.1.0` | 감지된 최신 배포 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true}` | 현재 사용자의 수행 가능 작업 맵 | + +나머지 필드는 목록(`GET /api/admin/plugins`) 응답 항목과 동일합니다 (PluginResource 단일 정의). 업데이트할 내용이 없고 `force` 도 없는 경우에는 `{"success": false, "from_version": …, "to_version": …, "message": "업데이트할 내용이 없습니다."}` 형태가 `data` 로 반환됩니다. **응답 예시** - +```json +{ + "success": true, + "message": "플러그인 \"sirsoft-daum_postcode\"이(가) 1.1.0 버전으로 업데이트되었습니다.", + "data": { + "id": 3, + "identifier": "sirsoft-daum_postcode", + "vendor": "sirsoft", + "name": "Daum 우편번호", + "version": "1.1.0", + "description": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.", + "dependencies": [], + "permissions": [], + "roles": [], + "config": [], + "hooks": [], + "status": "active", + "is_installed": true, + "has_settings": true, + "settings_route": "/admin/plugins/sirsoft-daum_postcode/settings", + "assets": { + "js": "/api/plugins/assets/sirsoft-daum_postcode/dist/js/plugin.iife.js", + "css": null, + "priority": 100 + }, + "update_available": false, + "update_source": null, + "latest_version": "1.1.0", + "file_version": "1.1.0", + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": true, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` **에러 응답** @@ -1553,7 +2195,8 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 업데이트 실패 (`plugin_name` 에 번역된 사유 — 다운그레이드 차단, 강제 업데이트 소스 없음, 코어 버전 비호환, 진행 중 상태 등) | +| 500 | Server Error | 업데이트 처리 중 예외 발생 (`plugins.errors.update_failed`) | @@ -1624,20 +2267,35 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — 요청한 에셋 파일의 원본 바이트를 그대로 서빙합니다 (성공 시 200 또는 304)._ + +| 응답 헤더 | 예시값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `text/javascript` | 파일 확장자에서 감지한 MIME 타입 | +| ETag | `"a1b2c3d4…"` | 파일 내용 기반 검증자 (재요청 시 304 판정) | +| Cache-Control | `public, max-age=31536000` | 1년 캐시 (환경별로 달라짐 — 비프로덕션은 no-cache) | **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: text/javascript +Cache-Control: public, max-age=31536000 +ETag: "a1b2c3d4e5f6" + +(function(){/* 플러그인 IIFE 번들 원본 바이트 */})(); +``` + +에러 시에는 JSON 봉투로 응답합니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 허용되지 않은 파일 형식 (`plugins.errors.file_type_not_allowed`) | +| 404 | Not Found | 플러그인이 없거나 비활성 (`plugins.errors.not_found`) 또는 파일 미존재 (`plugins.errors.file_not_found`) | +| 422 | Unprocessable Entity | 경로/확장자 보안 검증 실패 (ServePluginAssetRequest) | +| 500 | Server Error | 알 수 없는 오류 (`plugins.errors.unknown_error`) | @@ -1664,19 +2322,32 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — 활성 플러그인 CSS 를 병합한 텍스트를 그대로 서빙합니다 (성공 시 200 또는 304)._ + +| 응답 헤더 | 예시값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `text/css` | 항상 CSS | +| ETag | `"a1b2c3d4…"` | 병합 파일 내용 기반 검증자 (병합 파일이 있는 경우) | +| Cache-Control | `public, max-age=31536000` | 1년 캐시 (환경별로 달라짐 — 비프로덕션은 매 요청 재병합) | + +활성 global 플러그인 에셋이 하나도 없으면 본문이 빈 200 응답(`text/css`) 입니다. **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: text/css +Cache-Control: public, max-age=31536000 + +/* sirsoft-gdpr */ +.g7-gdpr-banner{position:fixed;bottom:0} +/* sirsoft-ckeditor5 */ +.ck-editor__editable{min-height:240px} +``` **에러 응답** -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +_에러 응답 없음 — 공개 조회이며 요청 파라미터가 없고, 활성 에셋이 하나도 없는 경우에도 빈 200 을 반환합니다._ @@ -1703,19 +2374,31 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — 활성 플러그인 IIFE JS 를 병합한 텍스트를 그대로 서빙합니다 (성공 시 200 또는 304)._ + +| 응답 헤더 | 예시값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `text/javascript` | 항상 JavaScript | +| ETag | `"a1b2c3d4…"` | 병합 파일 내용 기반 검증자 (병합 파일이 있는 경우) | +| Cache-Control | `public, max-age=31536000` | 1년 캐시 (환경별로 달라짐 — 비프로덕션은 매 요청 재병합) | + +활성 global 플러그인 에셋이 하나도 없으면 본문이 빈 200 응답(`text/javascript`) 입니다. 병합 시 각 IIFE 사이는 `\n;\n` 구분자로 연결됩니다. **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: text/javascript +Cache-Control: public, max-age=31536000 + +(function(){/* sirsoft-gdpr plugin.iife.js */})(); +; +(function(){/* sirsoft-ckeditor5 plugin.iife.js */})(); +``` **에러 응답** -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +_에러 응답 없음 — 공개 조회이며 요청 파라미터가 없고, 활성 에셋이 하나도 없는 경우에도 빈 200 을 반환합니다._ @@ -1870,30 +2553,44 @@ Accept: application/json **응답 필드** (`data` 내부) +_이 엔드포인트는 `success/message/data` 봉투를 사용하지 않습니다 — 플러그인의 `components.json` 원문을 그대로 JSON body 로 반환합니다 (`Cache-Control: public, max-age=3600`). 파일이 없는 구버전 플러그인은 빈 객체(`{}`) 로 폴백합니다._ - - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| $schema | string | `https://json-schema.org/draft/2020-12/schema` | 매니페스트 JSON 스키마 참조 | +| identifier | string | `sirsoft-ckeditor5` | 이 매니페스트를 소유한 플러그인 식별자 | +| version | string | `1.0.0-beta.4` | 매니페스트(플러그인) 버전 | +| components | object | `{"basic":[],"composite":[],"layout":[]}` | 타입별 컴포넌트 정의 맵 | +| components.basic | array | `[]` | Basic 컴포넌트 정의 목록 (HTML 래핑 계층) | +| components.composite | array | `[]` | Composite 컴포넌트 정의 목록 (집합 컴포넌트) | +| components.layout | array | `[]` | Layout 컴포넌트 정의 목록 | **응답 예시** - - ```http HTTP/1.1 200 +Content-Type: application/json +Cache-Control: public, max-age=3600 ``` ```json -[] +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "identifier": "sirsoft-ckeditor5", + "version": "1.0.0-beta.4", + "components": { + "basic": [], + "composite": [], + "layout": [] + } +} ``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 해당 식별자의 플러그인이 없거나 비활성 상태인 경우 (`plugins.errors.not_found`) | diff --git a/docs/backend/api/profile.md b/docs/backend/api/profile.md index b9620ab8..c66ccf19 100644 --- a/docs/backend/api/profile.md +++ b/docs/backend/api/profile.md @@ -38,11 +38,75 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`UserResource::toProfileArray()` 산물)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `홍길동` | 사용자 이름 | +| nickname | string\|null | `gildong` | 닉네임 (미설정 시 null) | +| email | string | `user@example.com` | 이메일 주소 | +| avatar | string\|null | `null` | 아바타 이미지 URL (`User::getAvatarUrl()` 산물, 미등록 시 null) | +| language | string | `ko` | 사용자 언어 설정 (`config('app.supported_locales')` 값 — 기본 `ko`, `en`) | +| timezone | string\|null | `Asia/Seoul` | 사용자 시간대 식별자 | +| country | string\|null | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (`active`, `inactive`, `blocked`, `withdrawn`, `pending_verification`) | +| status_label | string\|null | `활성` | 상태의 다국어 라벨 (`UserStatus::label()` 산물) | +| status_variant | string\|null | `success` | 상태 배지 색상 키 (`success`/`secondary`/`danger`/`warning`/`info`) | +| homepage | string\|null | `https://example.com` | 홈페이지 URL | +| mobile | string\|null | `010-1234-5678` | 휴대전화 번호 | +| phone | string\|null | `02-123-4567` | 전화번호 | +| zipcode | string\|null | `06234` | 우편번호 | +| address | string\|null | `서울특별시 강남구 테헤란로 1` | 기본 주소 | +| address_detail | string\|null | `101동 202호` | 상세 주소 | +| signature | string\|null | `null` | 게시글 서명 | +| bio | string\|null | `null` | 자기소개 | +| is_super | boolean | `false` | 슈퍼관리자 여부 (`User::isSuperAdmin()` 파생) | +| is_admin | boolean | `false` | 관리자 역할 보유 여부 (`User::isAdmin()` 파생) | +| withdrawn_at | string\|null | `null` | 탈퇴 처리 일시 (사용자 시간대 기준) | +| last_login_at | string\|null | `2026-07-05 19:15:16` | 마지막 로그인 일시 (사용자 시간대 기준) | +| last_login_human | string\|null | `1일 전` | 마지막 로그인 시각의 상대 표현 (`diffForHumans()` 산물) | +| created_at | string\|null | `2026-07-06 19:15:16` | 가입 일시 (사용자 시간대 기준) | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 | + +> 위 필드에 더해 `core.user.filter_resource_data` 훅으로 확장 소유 필드가 병합된다 (게시판 모듈 `notify_*`, 마케팅 플러그인 `marketing_consent*`/`email_subscription*`/`third_party_consent*`/`info_disclosure*`/`channels`/`consent_histories`, 이커머스 모듈 `ecommerce_*`). 전체 목록은 `GET /api/me` 문서 참조. **응답 예시** - +```json +{ + "success": true, + "message": "프로필 정보를 성공적으로 가져왔습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "홍길동", + "nickname": "gildong", + "email": "user@example.com", + "avatar": null, + "language": "ko", + "timezone": "Asia/Seoul", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "homepage": null, + "mobile": "010-1234-5678", + "phone": null, + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "101동 202호", + "signature": null, + "bio": null, + "is_super": false, + "is_admin": false, + "withdrawn_at": null, + "last_login_at": "2026-07-05 19:15:16", + "last_login_human": "1일 전", + "created_at": "2026-07-06 19:15:16", + "is_owner": true + } +} +``` **에러 응답** @@ -133,11 +197,83 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`UserResource::toArray()` 산물 — `show` 의 `toProfileArray()` 와 필드 구성이 다르다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 외부 노출용 UUID | +| name | string | `홍길동` | 사용자 이름 | +| nickname | string\|null | `gildong` | 닉네임 | +| email | string | `user@example.com` | 이메일 주소 | +| avatar | string\|null | `null` | 아바타 이미지 URL (미등록 시 null) | +| language | string | `ko` | 사용자 언어 설정 | +| language_label | string\|null | `한국어` | 언어의 다국어 라벨 (`user.language.{code}` 번역) | +| country | string\|null | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (`active`, `inactive`, `blocked`, `withdrawn`, `pending_verification`) | +| status_label | string\|null | `활성` | 상태의 다국어 라벨 | +| status_variant | string\|null | `success` | 상태 배지 색상 키 (`success`/`secondary`/`danger`/`warning`/`info`) | +| is_admin | boolean | `false` | 관리자 역할 보유 여부 | +| homepage | string\|null | `https://example.com` | 홈페이지 URL | +| mobile | string\|null | `010-1234-5678` | 휴대전화 번호 | +| phone | string\|null | `02-123-4567` | 전화번호 | +| zipcode | string\|null | `06234` | 우편번호 | +| address | string\|null | `서울특별시 강남구 테헤란로 1` | 기본 주소 | +| address_detail | string\|null | `101동 202호` | 상세 주소 | +| signature | string\|null | `null` | 게시글 서명 | +| bio | string\|null | `null` | 자기소개 | +| last_login_at | string\|null | `2026-07-05 19:15:16` | 마지막 로그인 일시 (사용자 시간대 기준) | +| email_verified_at | string\|null | `null` | 이메일 인증 완료 일시 (미인증 시 null) | +| timezone | string\|null | `Asia/Seoul` | 사용자 시간대 식별자 | +| created_at | string\|null | `2026-07-06 19:15:16` | 가입 일시 | +| updated_at | string\|null | `2026-07-08 10:41:24` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 | +| abilities | object | `{"can_read":true,"can_update":true,...}` | 현재 사용자의 리소스 권한 맵 (`can_read`/`can_create`/`can_update`/`can_delete`/`can_assign_roles`) | + +> `modules_count`/`plugins_count`/`menus_count`·`modules`/`plugins`/`menus`/`roles`/`permissions`/`consents`/`terms_consent`/`privacy_consent` 는 해당 관계가 로드된 경우에만 포함된다 (프로필 수정 응답에서는 로드되지 않아 생략). 확장 소유 필드는 `core.user.filter_resource_data` 훅으로 병합된다. **응답 예시** - +```json +{ + "success": true, + "message": "사용자 정보가 성공적으로 업데이트되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "홍길동", + "nickname": "gildong", + "email": "user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": false, + "homepage": null, + "mobile": "010-1234-5678", + "phone": null, + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "101동 202호", + "signature": null, + "bio": null, + "last_login_at": "2026-07-05 19:15:16", + "email_verified_at": null, + "timezone": "Asia/Seoul", + "created_at": "2026-07-06 19:15:16", + "updated_at": "2026-07-08 10:41:24", + "is_owner": true, + "abilities": { + "can_read": false, + "can_create": false, + "can_update": false, + "can_delete": false, + "can_assign_roles": false + } + } +} +``` **에러 응답** @@ -174,11 +310,38 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체에 `activities` 배열이 담긴다 (페이지네이션 없음, 최신순 최대 50건)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| activities | array | `[{...}]` | 최근 활동 로그 목록 (최신순, 최대 50건) | +| activities[].id | integer | `1` | 활동 로그 기본 키 | +| activities[].action | string | `profile.update` | 활동 액션 키 (점 표기, 예: `profile.show`, `profile.update`, `auth.login`) | +| activities[].action_label | string | `프로필 수정` | 액션의 다국어 라벨 (`activity_log.action.*` 번역, 확장 액션은 확장 lang 우선) | +| activities[].description | string | `프로필 정보를 수정했습니다.` | 활동 설명 (현재 로케일 기준 렌더 결과, `description_key` 미설정 시 빈 문자열) | +| activities[].ip_address | string\|null | `127.0.0.1` | 활동을 수행한 클라이언트 IP | +| activities[].created_at | string\|null | `2026-07-06T19:15:16+09:00` | 활동 발생 일시 (ISO 8601) | **응답 예시** - +```json +{ + "success": true, + "message": "활동 기록을 성공적으로 가져왔습니다.", + "data": { + "activities": [ + { + "id": 1024, + "action": "profile.update", + "action_label": "프로필 수정", + "description": "프로필 정보를 수정했습니다.", + "ip_address": "127.0.0.1", + "created_at": "2026-07-06T19:15:16+09:00" + } + ] + } +} +``` **에러 응답** @@ -214,11 +377,81 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`UserResource::toArray()` 산물 — `PUT /api/user/profile` 응답과 동일 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 외부 노출용 UUID | +| name | string | `홍길동` | 사용자 이름 | +| nickname | string\|null | `gildong` | 닉네임 | +| email | string | `user@example.com` | 이메일 주소 | +| avatar | string\|null | `null` | 아바타 이미지 URL | +| language | string | `en` | 변경된 사용자 언어 설정 | +| language_label | string\|null | `English` | 언어의 다국어 라벨 (`user.language.{code}` 번역) | +| country | string\|null | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 | +| status_label | string\|null | `활성` | 상태의 다국어 라벨 | +| status_variant | string\|null | `success` | 상태 배지 색상 키 | +| is_admin | boolean | `false` | 관리자 역할 보유 여부 | +| homepage | string\|null | `null` | 홈페이지 URL | +| mobile | string\|null | `010-1234-5678` | 휴대전화 번호 | +| phone | string\|null | `null` | 전화번호 | +| zipcode | string\|null | `06234` | 우편번호 | +| address | string\|null | `서울특별시 강남구 테헤란로 1` | 기본 주소 | +| address_detail | string\|null | `101동 202호` | 상세 주소 | +| signature | string\|null | `null` | 게시글 서명 | +| bio | string\|null | `null` | 자기소개 | +| last_login_at | string\|null | `2026-07-05 19:15:16` | 마지막 로그인 일시 | +| email_verified_at | string\|null | `null` | 이메일 인증 완료 일시 | +| timezone | string\|null | `Asia/Seoul` | 사용자 시간대 식별자 | +| created_at | string\|null | `2026-07-06 19:15:16` | 가입 일시 | +| updated_at | string\|null | `2026-07-08 10:41:24` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 | +| abilities | object | `{"can_read":false,...}` | 현재 사용자의 리소스 권한 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "언어 설정이 성공적으로 변경되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "홍길동", + "nickname": "gildong", + "email": "user@example.com", + "avatar": null, + "language": "en", + "language_label": "English", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": false, + "homepage": null, + "mobile": "010-1234-5678", + "phone": null, + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "101동 202호", + "signature": null, + "bio": null, + "last_login_at": "2026-07-05 19:15:16", + "email_verified_at": null, + "timezone": "Asia/Seoul", + "created_at": "2026-07-06 19:15:16", + "updated_at": "2026-07-08 10:41:24", + "is_owner": true, + "abilities": { + "can_read": false, + "can_create": false, + "can_update": false, + "can_delete": false, + "can_assign_roles": false + } + } +} +``` **에러 응답** diff --git a/docs/backend/api/roles.md b/docs/backend/api/roles.md index 47fdf28c..bf1eda1e 100644 --- a/docs/backend/api/roles.md +++ b/docs/backend/api/roles.md @@ -440,7 +440,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `role.delete_success` 키로 `success()` 만 호출)._ **응답 예시** diff --git a/docs/backend/api/schedules.md b/docs/backend/api/schedules.md index 86502f69..b7c5a56f 100644 --- a/docs/backend/api/schedules.md +++ b/docs/backend/api/schedules.md @@ -319,11 +319,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `3` | 실제로 삭제된 스케줄 건수 (`ScheduleService::bulkDelete` 반환값) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "선택한 스케줄이 삭제되었습니다.", + "data": { + "deleted_count": 3 + } +} +``` **에러 응답** @@ -372,11 +388,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 상태가 실제로 변경된 스케줄 건수 (`ScheduleService::bulkUpdateStatus` 반환값) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "선택한 스케줄의 상태가 변경되었습니다.", + "data": { + "updated_count": 3 + } +} +``` **에러 응답** @@ -414,11 +446,21 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `success('schedule.history_delete_success')` 를 데이터 없이 호출)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "실행 이력이 삭제되었습니다.", + "data": null +} +``` **에러 응답** @@ -426,7 +468,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 지정한 `historyId` 의 실행 이력이 존재하지 않는 경우 (`schedule.history_not_found`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -525,9 +567,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `success('schedule.delete_success')` 를 데이터 없이 호출)._ **응답 예시** @@ -950,7 +990,25 @@ Authorization: Bearer {YOUR_TOKEN} _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ - +목록 항목은 `ScheduleHistoryResource::toListArray()` 산물입니다 (단건 실행 응답과 달리 `output`/`error_output`/`memory_usage`(raw)/`created_at`/`updated_at` 은 포함되지 않습니다). + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `7` | 실행 이력의 기본 키 (내부 식별자) | +| schedule_id | integer | `1` | 이 이력이 속한 스케줄의 식별자 | +| started_at | string\|null | `2026-07-08T12:14:49+09:00` | 실행 시작 일시 (사용자 타임존 포맷) | +| ended_at | string\|null | `2026-07-08T12:14:49+09:00` | 실행 종료 일시 (사용자 타임존 포맷, 실행 중이면 null) | +| duration | integer\|null | `0` | 실행 소요 시간 | +| duration_formatted | string\|null | `45초` | `duration` 값의 표시용 포맷 문자열 (모델 접근자 파생, 없으면 null) | +| status | string | `success` | 실행 결과 상태: success(성공), failed(실패), running(실행중) | +| status_label | string\|null | `성공` | `status` 값의 사람이 읽는 라벨 (`schedule.result.*` 현지화) | +| exit_code | integer\|null | `0` | 실행 종료 코드 (0=성공, 그 외=실패) | +| memory_usage_formatted | string\|null | `417.34 KB` | 실행 중 메모리 사용량의 표시용 포맷 문자열 | +| trigger_type | string | `manual` | 실행 방식: scheduled(예약 자동 실행), manual(수동 즉시 실행) | +| trigger_type_label | string\|null | `수동 실행` | `trigger_type` 값의 사람이 읽는 라벨 (`schedule.trigger_type.*` 현지화) | +| triggered_by | object\|null | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"관리자"}` | 수동 실행을 트리거한 사용자 정보 (uuid/name — triggeredBy 관계가 로드되지 않았거나 자동 실행이면 null) | + +`data.pagination` 은 `current_page`/`last_page`/`per_page`/`total`/`from`/`to`/`has_more_pages` 로 구성됩니다. **응답 예시** @@ -963,14 +1021,30 @@ HTTP/1.1 200 "success": true, "message": "실행 이력을 조회했습니다.", "data": { - "data": [], + "data": [ + { + "id": 7, + "schedule_id": 1, + "started_at": "2026-07-08T12:14:49+09:00", + "ended_at": "2026-07-08T12:14:49+09:00", + "duration": 0, + "duration_formatted": null, + "status": "success", + "status_label": "성공", + "exit_code": 0, + "memory_usage_formatted": "417.34 KB", + "trigger_type": "manual", + "trigger_type_label": "수동 실행", + "triggered_by": null + } + ], "pagination": { "current_page": 1, "last_page": 1, "per_page": 25, - "total": 0, - "from": null, - "to": null, + "total": 1, + "from": 1, + "to": 1, "has_more_pages": false } } diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md index f9bcc8b6..badfd233 100644 --- a/docs/backend/api/seo.md +++ b/docs/backend/api/seo.md @@ -54,7 +54,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": { "urls": [], "count": 0 @@ -106,11 +106,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| cleared | integer \| string | `12` / `"all"` | 삭제 결과. `layout` 을 지정한 경우 해당 레이아웃에서 무효화된 캐시 항목 수(정수), 미지정 시 전체 캐시를 삭제했음을 뜻하는 문자열 `"all"`. | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "cleared": "all" + } +} +``` **에러 응답** @@ -119,6 +135,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 캐시 무효화(`invalidateByLayout` / `clearAll`) 중 예외가 발생한 경우 (`messages.error_occurred`) | @@ -315,7 +332,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": { "overall": { "total_entries": 0, @@ -365,11 +382,29 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| status | string | `dispatched` | 워밍업 요청 접수 상태. 현재 구현에서는 항상 `dispatched` 고정값이며, 실제 사전 렌더가 완료되었음을 뜻하지는 않는다. | +| message | string | `SEO 캐시 워밍업이 시작되었습니다.` | 관리자에게 노출할 안내 문구 (`seo.warmup_dispatched` 다국어 키). | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "status": "dispatched", + "message": "SEO 캐시 워밍업이 시작되었습니다." + } +} +``` **에러 응답** @@ -377,6 +412,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 500 | Internal Server Error | 워밍업 처리 중 예외가 발생한 경우 (`messages.error_occurred`) | diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md index 46305e7f..f8c1695e 100644 --- a/docs/backend/api/settings.md +++ b/docs/backend/api/settings.md @@ -171,111 +171,111 @@ HTTP/1.1 200 | advanced | body | array | 아니오 | — | 고급 탭 설정 묶음 (캐시·디버그·코어 업데이트·GeoIP 설정) | | notifications | body | array | 아니오 | — | 알림 탭 설정 묶음. channels 배열로 각 알림 채널의 id·is_active(활성 여부)·sort_order(표시 순서)를 저장 | | identity | body | array | 아니오 | — | 본인인증(IDV) 탭 설정 묶음 (기본 provider·목적별 provider 매핑·챌린지 유효시간·최대 시도 횟수) | -| notifications.channels | body | array | 아니오 | — | | +| notifications.channels | body | array | 아니오 | — | 알림 채널 목록. 각 원소는 `id`(채널 식별자, 필수·최대 50자)·`is_active`(활성 여부, 필수 boolean)·`sort_order`(표시 순서, 0 이상 정수) 로 구성 | | general.site_name | body | string | 예 | max 100 | general.site 이름 (식별자) | -| general.site_url | body | string | 예 | max 255 | | -| general.site_description | body | string | 아니오 | max 500 | | -| general.admin_email | body | email | 예 | max 255 | | -| general.timezone | body | string | 예 | — | | -| general.language | body | string | 예 | — | | -| general.currency | body | string | 아니오 | max 10 | | -| general.maintenance_mode | body | boolean | 아니오 | — | | -| general.site_logo | body | array | 아니오 | — | | -| mail.mailer | body | string | 아니오 | — | | -| mail.host | body | string | 아니오 | max 255 | | -| mail.port | body | integer | 아니오 | min 1, max 65535 | | -| mail.username | body | string | 아니오 | max 255 | | -| mail.password | body | string | 아니오 | max 255 | | -| mail.encryption | body | string | 아니오 | — | | -| mail.mailgun_domain | body | string | 아니오 | max 255 | | -| mail.mailgun_secret | body | string | 아니오 | max 255 | | -| mail.mailgun_endpoint | body | string | 아니오 | max 255 | | -| mail.ses_key | body | string | 아니오 | max 255 | | -| mail.ses_secret | body | string | 아니오 | max 255 | | -| mail.ses_region | body | string | 아니오 | max 255 | | -| mail.from_address | body | email | 아니오 | max 255 | | +| general.site_url | body | string | 예 | max 255 | 사이트 기본 URL | +| general.site_description | body | string | 아니오 | max 500 | 사이트 설명 | +| general.admin_email | body | email | 예 | max 255 | 관리자 이메일 주소 | +| general.timezone | body | string | 예 | — | 타임존 식별자 | +| general.language | body | string | 예 | — | 언어 코드 | +| general.currency | body | string | 아니오 | max 10 | 통화 코드 (ISO 4217 — 예: KRW) | +| general.maintenance_mode | body | boolean | 아니오 | — | 점검 모드 사용 여부 (사이트 접근 차단) | +| general.site_logo | body | array | 아니오 | — | 사이트 로고 이미지 | +| mail.mailer | body | string | 아니오 | — | 메일 발송 드라이버 (smtp/mailgun/ses) | +| mail.host | body | string | 아니오 | max 255 | 호스트 주소 | +| mail.port | body | integer | 아니오 | min 1, max 65535 | 포트 번호 | +| mail.username | body | string | 아니오 | max 255 | 사용자명 (로그인/인증 아이디) | +| mail.password | body | string | 아니오 | max 255 | 비밀번호 | +| mail.encryption | body | string | 아니오 | — | 전송 암호화 방식 (tls/ssl) | +| mail.mailgun_domain | body | string | 아니오 | max 255 | Mailgun 도메인 | +| mail.mailgun_secret | body | string | 아니오 | max 255 | Mailgun 시크릿 키 | +| mail.mailgun_endpoint | body | string | 아니오 | max 255 | Mailgun 엔드포인트 | +| mail.ses_key | body | string | 아니오 | max 255 | SES 액세스 키 | +| mail.ses_secret | body | string | 아니오 | max 255 | SES 시크릿 키 | +| mail.ses_region | body | string | 아니오 | max 255 | SES 리전 | +| mail.from_address | body | email | 아니오 | max 255 | 발신자 주소 | | mail.from_name | body | string | 아니오 | max 255 | mail.from 이름 (식별자) | -| upload.max_file_size | body | integer | 아니오 | min 1, max 1024 | | -| upload.allowed_extensions | body | string | 아니오 | — | | -| upload.image_max_width | body | integer | 아니오 | min 100, max 10000 | | -| upload.image_max_height | body | integer | 아니오 | min 100, max 10000 | | -| upload.image_quality | body | integer | 아니오 | min 1, max 100 | | -| seo.meta_title_suffix | body | string | 아니오 | max 100 | | -| seo.meta_description | body | string | 아니오 | max 160 | | -| seo.meta_keywords | body | string | 아니오 | max 255 | | +| upload.max_file_size | body | integer | 아니오 | min 1, max 1024 | 업로드 허용 최대 파일 크기 | +| upload.allowed_extensions | body | string | 아니오 | — | 업로드 허용 확장자 목록 | +| upload.image_max_width | body | integer | 아니오 | min 100, max 10000 | 이미지 리사이즈 최대 너비 (px) | +| upload.image_max_height | body | integer | 아니오 | min 100, max 10000 | 이미지 리사이즈 최대 높이 (px) | +| upload.image_quality | body | integer | 아니오 | min 1, max 100 | 이미지 리사이즈 시 압축 품질 (1~100) | +| seo.meta_title_suffix | body | string | 아니오 | max 100 | 모든 페이지 SEO 제목 뒤에 붙는 접미 문구 | +| seo.meta_description | body | string | 아니오 | max 160 | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) | +| seo.meta_keywords | body | string | 아니오 | max 255 | SEO 메타 키워드 (검색엔진 노출 키워드, 쉼표 구분) | | seo.google_analytics_id | body | string | 아니오 | max 50 | seo.google analytics 식별자 | -| seo.google_site_verification | body | string | 아니오 | max 100 | | -| seo.naver_site_verification | body | string | 아니오 | max 100 | | -| seo.bot_user_agents | body | array | 아니오 | — | | -| seo.bot_detection_enabled | body | boolean | 아니오 | — | | -| seo.bot_detection_library_enabled | body | boolean | 아니오 | — | | +| seo.google_site_verification | body | string | 아니오 | max 100 | Google Search Console 사이트 소유 확인 코드 | +| seo.naver_site_verification | body | string | 아니오 | max 100 | 네이버 서치어드바이저 사이트 소유 확인 코드 | +| seo.bot_user_agents | body | array | 아니오 | — | 봇으로 판정할 User-Agent 목록 | +| seo.bot_detection_enabled | body | boolean | 아니오 | — | 검색엔진 봇 감지 사용 여부 (봇 요청에 SEO 렌더링 적용) | +| seo.bot_detection_library_enabled | body | boolean | 아니오 | — | 봇 감지 라이브러리 사용 여부 (User-Agent 목록 대신 라이브러리 판정) | | seo.og_default_site_name | body | string | 아니오 | max 200 | seo.og default site 이름 (식별자) | -| seo.og_image_default_width | body | integer | 아니오 | min 0, max 8000 | | -| seo.og_image_default_height | body | integer | 아니오 | min 0, max 8000 | | -| seo.twitter_default_card | body | string | 아니오 | — | | -| seo.twitter_default_site | body | string | 아니오 | max 50 | | -| seo.cache_enabled | body | boolean | 아니오 | — | | -| seo.cache_ttl | body | integer | 아니오 | min 60, max 86400 | | -| seo.sitemap_enabled | body | boolean | 아니오 | — | | -| seo.sitemap_cache_ttl | body | integer | 아니오 | min 3600, max 604800 | | -| seo.sitemap_schedule | body | string | 아니오 | — | | -| seo.sitemap_schedule_time | body | string | 아니오 | — | | -| seo.generator_enabled | body | boolean | 아니오 | — | | -| seo.generator_content | body | string | 아니오 | max 200 | | -| security.force_https | body | boolean | 아니오 | — | | -| security.login_attempt_enabled | body | boolean | 아니오 | — | | -| security.auth_token_lifetime | body | integer | 아니오 | min 0, max 3600 | | -| security.max_login_attempts | body | integer | 아니오 | min 0, max 100 | | -| security.login_lockout_time | body | integer | 아니오 | min 0, max 1440 | | -| advanced.cache_enabled | body | boolean | 아니오 | — | | -| advanced.layout_cache_enabled | body | boolean | 아니오 | — | | -| advanced.layout_cache_ttl | body | integer | 아니오 | min 0, max 14400 | | -| advanced.stats_cache_enabled | body | boolean | 아니오 | — | | -| advanced.stats_cache_ttl | body | integer | 아니오 | min 0, max 14400 | | -| advanced.seo_cache_enabled | body | boolean | 아니오 | — | | -| advanced.seo_cache_ttl | body | integer | 아니오 | min 0, max 14400 | | -| advanced.debug_mode | body | boolean | 아니오 | — | | -| advanced.sql_query_log | body | boolean | 아니오 | — | | -| advanced.core_update_github_url | body | string | 아니오 | max 500 | | -| advanced.core_update_github_token | body | string | 아니오 | max 500 | | -| advanced.geoip_enabled | body | boolean | 아니오 | — | | -| advanced.geoip_license_key | body | string | 아니오 | max 200 | | -| advanced.geoip_auto_update_enabled | body | boolean | 아니오 | — | | -| drivers.storage_driver | body | string | 아니오 | — | | -| drivers.s3_bucket | body | string | 아니오 | max 255 | | -| drivers.s3_region | body | string | 아니오 | — | | -| drivers.s3_access_key | body | string | 아니오 | max 255 | | -| drivers.s3_secret_key | body | string | 아니오 | max 255 | | -| drivers.s3_url | body | string | 아니오 | max 500 | | -| drivers.cache_driver | body | string | 아니오 | — | | -| drivers.redis_host | body | string | 아니오 | max 255 | | -| drivers.redis_port | body | integer | 아니오 | min 1, max 65535 | | -| drivers.redis_password | body | string | 아니오 | max 255 | | -| drivers.redis_database | body | integer | 아니오 | min 0, max 15 | | -| drivers.memcached_host | body | string | 아니오 | max 255 | | -| drivers.memcached_port | body | integer | 아니오 | min 1, max 65535 | | -| drivers.session_driver | body | string | 아니오 | — | | -| drivers.session_lifetime | body | integer | 아니오 | min 1, max 43200 | | -| drivers.queue_driver | body | string | 아니오 | — | | -| drivers.websocket_enabled | body | boolean | 아니오 | — | | +| seo.og_image_default_width | body | integer | 아니오 | min 0, max 8000 | 기본 Open Graph 이미지 너비 (px) | +| seo.og_image_default_height | body | integer | 아니오 | min 0, max 8000 | 기본 Open Graph 이미지 높이 (px) | +| seo.twitter_default_card | body | string | 아니오 | — | 기본 트위터 카드 유형 (summary 등) | +| seo.twitter_default_site | body | string | 아니오 | max 50 | 기본 트위터 사이트 계정 (@handle) | +| seo.cache_enabled | body | boolean | 아니오 | — | 캐시 사용 여부 | +| seo.cache_ttl | body | integer | 아니오 | min 60, max 86400 | 캐시 유효 시간 (초) | +| seo.sitemap_enabled | body | boolean | 아니오 | — | sitemap.xml 생성 사용 여부 | +| seo.sitemap_cache_ttl | body | integer | 아니오 | min 3600, max 604800 | sitemap 캐시 유효 시간 (초) | +| seo.sitemap_schedule | body | string | 아니오 | — | sitemap 자동 생성 주기 | +| seo.sitemap_schedule_time | body | string | 아니오 | — | sitemap 자동 생성 시각 | +| seo.generator_enabled | body | boolean | 아니오 | — | SEO 페이지 생성기 사용 여부 | +| seo.generator_content | body | string | 아니오 | max 200 | SEO 렌더링 본문 생성 방식 | +| security.force_https | body | boolean | 아니오 | — | HTTPS 강제 리다이렉트 여부 | +| security.login_attempt_enabled | body | boolean | 아니오 | — | 로그인 시도 제한 사용 여부 | +| security.auth_token_lifetime | body | integer | 아니오 | min 0, max 3600 | 인증 토큰 유효 시간 (분) | +| security.max_login_attempts | body | integer | 아니오 | min 0, max 100 | 로그인 실패 허용 횟수 (초과 시 잠금) | +| security.login_lockout_time | body | integer | 아니오 | min 0, max 1440 | 로그인 잠금 지속 시간 (분) | +| advanced.cache_enabled | body | boolean | 아니오 | — | 캐시 사용 여부 | +| advanced.layout_cache_enabled | body | boolean | 아니오 | — | 레이아웃 캐시 사용 여부 (레이아웃 데이터를 캐시) | +| advanced.layout_cache_ttl | body | integer | 아니오 | min 0, max 14400 | 레이아웃 캐시 만료 시간 (초, 0 = 만료 없음) | +| advanced.stats_cache_enabled | body | boolean | 아니오 | — | 통계 캐시 사용 여부 (대시보드 통계를 캐시) | +| advanced.stats_cache_ttl | body | integer | 아니오 | min 0, max 14400 | 통계 캐시 만료 시간 (초, 0 = 만료 없음) | +| advanced.seo_cache_enabled | body | boolean | 아니오 | — | SEO 캐시 사용 여부 (SEO 메타데이터를 캐시) | +| advanced.seo_cache_ttl | body | integer | 아니오 | min 0, max 14400 | SEO 캐시 만료 시간 (초, 0 = 만료 없음) | +| advanced.debug_mode | body | boolean | 아니오 | — | 디버그 모드 사용 여부 (상세 오류 노출) | +| advanced.sql_query_log | body | boolean | 아니오 | — | SQL 쿼리 로그 기록 여부 | +| advanced.core_update_github_url | body | string | 아니오 | max 500 | 코어 업데이트를 확인할 GitHub 저장소 URL | +| advanced.core_update_github_token | body | string | 아니오 | max 500 | 프라이빗 저장소의 코어/확장 업데이트에 사용할 GitHub 액세스 토큰 (공개 저장소는 비워둘 수 있음) | +| advanced.geoip_enabled | body | boolean | 아니오 | — | IP 기반 타임존 감지(GeoIP) 사용 여부 | +| advanced.geoip_license_key | body | string | 아니오 | max 200 | MaxMind GeoLite2 라이선스 키 | +| advanced.geoip_auto_update_enabled | body | boolean | 아니오 | — | GeoIP DB 자동 업데이트 사용 여부 (주 1회 자동 재다운로드) | +| drivers.storage_driver | body | string | 아니오 | — | 스토리지 드라이버 (local/s3) | +| drivers.s3_bucket | body | string | 아니오 | max 255 | S3 버킷명 | +| drivers.s3_region | body | string | 아니오 | — | S3 리전 | +| drivers.s3_access_key | body | string | 아니오 | max 255 | S3 액세스 키 | +| drivers.s3_secret_key | body | string | 아니오 | max 255 | S3 시크릿 키 | +| drivers.s3_url | body | string | 아니오 | max 500 | S3 엔드포인트 URL | +| drivers.cache_driver | body | string | 아니오 | — | 캐시 드라이버 (file/redis/memcached) | +| drivers.redis_host | body | string | 아니오 | max 255 | Redis 호스트 주소 | +| drivers.redis_port | body | integer | 아니오 | min 1, max 65535 | Redis 포트 번호 | +| drivers.redis_password | body | string | 아니오 | max 255 | Redis 비밀번호 | +| drivers.redis_database | body | integer | 아니오 | min 0, max 15 | Redis 데이터베이스 번호 | +| drivers.memcached_host | body | string | 아니오 | max 255 | Memcached 호스트 주소 | +| drivers.memcached_port | body | integer | 아니오 | min 1, max 65535 | Memcached 포트 번호 | +| drivers.session_driver | body | string | 아니오 | — | 세션 드라이버 (file/database/redis) | +| drivers.session_lifetime | body | integer | 아니오 | min 1, max 43200 | 세션 유효 시간 (분) | +| drivers.queue_driver | body | string | 아니오 | — | 큐 드라이버 (sync/database/redis) | +| drivers.websocket_enabled | body | boolean | 아니오 | — | WebSocket 사용 여부 | | drivers.websocket_app_id | body | string | 아니오 | max 255 | drivers.websocket app 식별자 | -| drivers.websocket_app_key | body | string | 아니오 | max 255 | | -| drivers.websocket_app_secret | body | string | 아니오 | max 255 | | -| drivers.websocket_host | body | string | 아니오 | max 255 | | -| drivers.websocket_port | body | integer | 아니오 | min 1, max 65535 | | -| drivers.websocket_scheme | body | string | 아니오 | — | | -| drivers.websocket_verify_ssl | body | boolean | 아니오 | — | | -| drivers.websocket_server_host | body | string | 아니오 | max 255 | | -| drivers.websocket_server_port | body | integer | 아니오 | min 1, max 65535 | | -| drivers.websocket_server_scheme | body | string | 아니오 | — | | -| drivers.search_engine_driver | body | string | 아니오 | — | | -| drivers.log_driver | body | string | 아니오 | — | | -| drivers.log_level | body | string | 아니오 | — | | -| drivers.log_days | body | integer | 아니오 | min 1, max 365 | | -| identity.default_provider | body | string | 아니오 | max 100 | | -| identity.purpose_providers | body | array | 아니오 | — | | -| identity.challenge_ttl_minutes | body | integer | 아니오 | min 1, max 1440 | | -| identity.max_attempts | body | integer | 아니오 | min 1, max 20 | | +| drivers.websocket_app_key | body | string | 아니오 | max 255 | WebSocket 앱 키 | +| drivers.websocket_app_secret | body | string | 아니오 | max 255 | WebSocket 앱 시크릿 | +| drivers.websocket_host | body | string | 아니오 | max 255 | WebSocket 호스트 주소 | +| drivers.websocket_port | body | integer | 아니오 | min 1, max 65535 | WebSocket 포트 번호 | +| drivers.websocket_scheme | body | string | 아니오 | — | WebSocket 스킴 (http/https) | +| drivers.websocket_verify_ssl | body | boolean | 아니오 | — | WebSocket 서버 SSL 인증서 검증 여부 | +| drivers.websocket_server_host | body | string | 아니오 | max 255 | WebSocket 서버 호스트 주소 (서버측 발행 대상) | +| drivers.websocket_server_port | body | integer | 아니오 | min 1, max 65535 | WebSocket 서버 포트 번호 (서버측 발행 대상) | +| drivers.websocket_server_scheme | body | string | 아니오 | — | WebSocket 서버 스킴 (http/https — 서버측 발행 대상) | +| drivers.search_engine_driver | body | string | 아니오 | — | 검색 엔진 드라이버 (Scout 엔진 선택) | +| drivers.log_driver | body | string | 아니오 | — | 로그 드라이버 (single/daily/stack) | +| drivers.log_level | body | string | 아니오 | — | 로그 레벨 (debug/info/warning/error 등) | +| drivers.log_days | body | integer | 아니오 | min 1, max 365 | 로그 파일 보관 일수 | +| identity.default_provider | body | string | 아니오 | max 100 | 본인인증 기본 프로바이더 (목적별로 지정되지 않은 경우 사용). 예: `g7:core.mail` | +| identity.purpose_providers | body | array | 아니오 | — | 본인인증 목적(Purpose)별 프로바이더 매핑. 미지정 목적은 기본 프로바이더를 사용 | +| identity.challenge_ttl_minutes | body | integer | 아니오 | min 1, max 1440 | 발급된 인증 코드/링크(challenge)의 유효시간 (분) | +| identity.max_attempts | body | integer | 아니오 | min 1, max 20 | challenge 최대 시도 횟수 (연속 실패 시 잠금 — 재전송 필요) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.settings.save_validation_rules`, `core.search.engine_drivers`). @@ -435,11 +435,55 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| settings | object | `{"general":{...},"security":{...},"available_drivers":{...}}` | 저장 직후 다시 조회한 전체 설정 집계. 구조는 `GET /api/admin/settings` 의 `data` 와 동일 (general/security/mail/upload/seo/advanced/cache/debug/drivers/core_update/geoip/notifications/identity + `available_drivers`) | **응답 예시** - +```json +{ + "success": true, + "message": "설정이 성공적으로 저장되었습니다.", + "data": { + "settings": { + "general": { + "site_name": "Test Site", + "site_url": "https://test.example.com", + "site_description": "", + "admin_email": "admin@example.com", + "timezone": "Asia/Seoul", + "language": "ko", + "currency": "KRW", + "maintenance_mode": false, + "site_logo": [] + }, + "security": { + "force_https": true, + "login_attempt_enabled": true, + "auth_token_lifetime": 0, + "max_login_attempts": 5, + "login_lockout_time": 30 + }, + "available_drivers": { + "storage": [ + { + "id": "local", + "label": { + "ko": "로컬", + "en": "Local" + } + } + ] + } + } + } +} +``` + +> 위 예시는 지면상 일부 그룹만 표기한 것으로, 실제 응답의 `data.settings` 에는 `GET /api/admin/settings` 와 동일한 전체 그룹이 포함됩니다. **에러 응답** @@ -534,11 +578,23 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| backup_path | string | `backups/backup_2026-07-14_030000.zip` | 생성된 설정 백업 ZIP 의 저장소 상대 경로 (카테고리별 설정 JSON 을 묶은 아카이브). `POST /api/admin/settings/restore` 의 `backup_path` 로 그대로 전달해 복원에 사용 | **응답 예시** - +```json +{ + "success": true, + "message": "데이터베이스 백업이 성공적으로 시작되었습니다.", + "data": { + "backup_path": "backups/backup_2026-07-14_030000.zip" + } +} +``` **에러 응답** @@ -546,6 +602,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 500 | Internal Server Error | 백업 파일 생성에 실패한 경우 (`settings.backup_failed`) | @@ -575,18 +632,26 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('settings.backup_success')` 를 데이터 없이 호출하므로 `data` 는 `null` 입니다._ **응답 예시** - +```json +{ + "success": true, + "message": "데이터베이스 백업이 성공적으로 시작되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 백업이 수행되지 않은 경우 (`settings.backup_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 500 | Internal Server Error | 백업 처리 중 예외가 발생한 경우 (`settings.backup_error`) | @@ -616,18 +681,26 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('settings.cache_clear_success')` 를 데이터 없이 호출하므로 `data` 는 `null` 입니다._ **응답 예시** - +```json +{ + "success": true, + "message": "캐시가 성공적으로 정리되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 캐시 정리가 수행되지 않은 경우 (`settings.cache_clear_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 500 | Internal Server Error | 캐시 정리 중 예외가 발생한 경우 (`settings.cache_clear_error`) | @@ -657,18 +730,38 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (GeoIpDatabaseService::updateDatabase() 의 `data`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| database_path | string | `storage/app/geoip/GeoLite2-City.mmdb` | 갱신된 GeoLite2 DB 파일의 절대 경로 (`config('geoip.database_path')`) | +| file_size_bytes | integer | `60000000` | 다운로드·전개 후 최종 DB 파일 크기 (바이트) | +| elapsed_seconds | number | `12.34` | 다운로드부터 교체 완료까지 소요 시간 (초) | **응답 예시** - +```json +{ + "success": true, + "message": "settings.geoip.update_success", + "data": { + "database_path": "storage/app/geoip/GeoLite2-City.mmdb", + "file_size_bytes": 60000000, + "elapsed_seconds": 12.34 + } +} +``` + +> `message` 는 컨트롤러가 넘기는 `settings.geoip.update_success` 키를 번역한 값입니다. 현재 코어 `lang/{ko,en}/settings.php` 에 `geoip.*` 항목이 정의되어 있지 않아 번역이 없으면 키 문자열이 그대로 내려갑니다 (관리자 화면은 프론트엔드 다국어 키로 별도 문구를 표시). **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 400 | Bad Request | MaxMind 라이선스 키가 설정되지 않은 경우 (`missing_license_key`) | +| 401 | Unauthorized | MaxMind 라이선스 키가 유효하지 않은 경우 (`unauthorized`) | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 500 | Internal Server Error | MaxMind 연결 실패(`connection_failed`) 또는 다운로드·압축 해제 실패 | @@ -698,9 +791,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('settings.optimize_success')` 를 데이터 없이 호출하므로 `data` 는 `null` 입니다._ **응답 예시** @@ -760,19 +851,32 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| app_key | string | `base64:97gZH********************************` | 재생성된 애플리케이션 키를 마스킹한 문자열 (앞 12자 + 별표 32개). 전체 원문 키는 반환하지 않으며 `.env` 의 `APP_KEY` 에만 기록됨 | **응답 예시** - +```json +{ + "success": true, + "message": "어플리케이션 키가 성공적으로 재생성되었습니다.", + "data": { + "app_key": "base64:97gZH********************************" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 401 | Unauthorized | 본문 `password` 가 요청자 본인의 비밀번호와 일치하지 않는 경우 (`settings.invalid_password`) | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없거나, FormRequest 가 `super_admin` 역할이 아닌 사용자를 거부한 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | `.env` 기록/config 캐시 재생성 실패 (`settings.app_key_regenerate_failed`) | @@ -811,19 +915,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('settings.restore_success')` 를 데이터 없이 호출하므로 `data` 는 `null` 입니다._ **응답 예시** - +```json +{ + "success": true, + "message": "설정이 성공적으로 복원되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 복원이 수행되지 않은 경우 (`settings.restore_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 백업 파일을 읽을 수 없는 등 복원 중 예외 발생 (`settings.restore_error`) | @@ -1047,11 +1159,35 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (DriverConnectionTester::testAll() 산물)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| results | object | `{"redis":{"success":true,"message":"Redis 서버에 성공적으로 연결되었습니다.","latency":"3ms"}}` | 드라이버별 테스트 결과 맵. 키는 실제 테스트가 수행된 드라이버(`s3`/`redis`/`memcached`/`websocket`)만 포함 — 요청 설정에서 해당 드라이버를 쓰지 않으면 키 자체가 없음 | +| results.{driver}.success | boolean | `true` | 해당 드라이버 연결 성공 여부 | +| results.{driver}.message | string | `Redis 서버에 성공적으로 연결되었습니다.` | 결과 메시지 (성공/실패 사유 — 설정 누락·확장 미설치·인증 실패 등) | +| results.{driver}.latency | string | `3ms` | 성공 시에만 포함. 연결 왕복 소요 시간 | +| results.{driver}.error | string | `Connection refused` | 실패 시에만 포함. 원본 예외 메시지 | +| all_passed | boolean | `true` | 수행된 모든 드라이버 테스트가 성공했는지 여부. `false` 여도 HTTP 200 으로 응답하며 message 가 `일부 드라이버 연결 테스트가 실패했습니다.` 로 바뀜 | **응답 예시** - +```json +{ + "success": true, + "message": "모든 드라이버 연결 테스트가 성공했습니다.", + "data": { + "results": { + "redis": { + "success": true, + "message": "Redis 서버에 성공적으로 연결되었습니다.", + "latency": "3ms" + } + }, + "all_passed": true + } +} +``` **에러 응답** @@ -1060,6 +1196,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 테스트 실행 중 예외가 발생한 경우 (`settings.driver_test_error`) | @@ -1126,11 +1263,25 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (발송 성공 시에만 반환)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| subject | string | `그누보드7 테스트 메일` | 실제로 발송한 테스트 메일의 제목 (`settings.test_mail_subject` — 앱 이름이 치환됨) | +| body | string | `이것은 그누보드7에서 발송한 테스트 메일입니다. 이 메일을 받으셨다면 메일 설정이 올바르게 구성되어 있습니다.` | 실제로 발송한 테스트 메일의 본문 (`settings.test_mail_body`, 평문) | **응답 예시** - +```json +{ + "success": true, + "message": "테스트 메일이 성공적으로 발송되었습니다.", + "data": { + "subject": "그누보드7 테스트 메일", + "body": "이것은 그누보드7에서 발송한 테스트 메일입니다. 이 메일을 받으셨다면 메일 설정이 올바르게 구성되어 있습니다." + } +} +``` **에러 응답** @@ -1139,6 +1290,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 발송 실패 시 — `message` 는 `테스트 메일 발송에 실패했습니다.`, `error` 에 원본 예외 메시지(SMTP 인증 실패·연결 거부 등)가 담김 | @@ -1170,11 +1322,25 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| key | string | `general.site_name` | 요청한 설정 키를 그대로 되돌려 준 값 (`{category}.{field}` 형태) | +| value | mixed | `Test Site` | 해당 설정 키의 현재 값. 설정 항목의 자료형에 따라 문자열/정수/불리언/배열이 될 수 있으며, 키가 없으면 `null` | **응답 예시** - +```json +{ + "success": true, + "message": "설정을 성공적으로 가져왔습니다.", + "data": { + "key": "general.site_name", + "value": "Test Site" + } +} +``` **에러 응답** @@ -1183,6 +1349,7 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 500 | Internal Server Error | 설정 조회 중 예외가 발생한 경우 (`settings.fetch_failed`) | @@ -1222,20 +1389,28 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('settings.update_success')` 를 데이터 없이 호출하므로 `data` 는 `null` 입니다._ **응답 예시** - +```json +{ + "success": true, + "message": "설정이 성공적으로 업데이트되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 저장이 수행되지 않은 경우 (`settings.update_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 저장 중 예외가 발생한 경우 (`settings.update_error`) | diff --git a/docs/backend/api/templates.md b/docs/backend/api/templates.md index b90e7d85..a2fec5f8 100644 --- a/docs/backend/api/templates.md +++ b/docs/backend/api/templates.md @@ -238,11 +238,55 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| template | object | `{"identifier":"sirsoft-admin_basic","status":"active", …}` | 활성화된 템플릿 리소스 (TemplateResource 목록 필드 전체 — identifier/vendor/name/version/type/status/description/dependencies/dependencies_met/update_available/update_source/latest_version/file_version/github_url/github_changelog_url/is_pending/is_bundled/deactivated_reason/deactivated_at/incompatible_required_version/abilities) | +| pending_language_packs | array | `[]` | 이 템플릿 재활성화로 함께 되살아날 수 있는 비활성 번들 언어팩 목록 (각 항목: id, identifier, locale, locale_native_name) | **응답 예시** - +```json +{ + "success": true, + "message": "템플릿이 활성화되었습니다.", + "data": { + "template": { + "identifier": "sirsoft-admin_basic", + "vendor": "sirsoft", + "name": "Admin Basic", + "version": "1.0.0", + "type": "admin", + "status": "active", + "description": "그누보드7 기본 관리자 템플릿", + "dependencies": { + "modules": [], + "plugins": [] + }, + "dependencies_met": true, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": true, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true, + "can_edit_layouts": true + } + }, + "pending_language_packs": [] + } +} +``` **에러 응답** @@ -250,7 +294,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 409 | Conflict | 필요한 의존 모듈/플러그인이 충족되지 않은 경우 (`errors` 에 `warning`, `missing_modules`, `missing_plugins`, `message`) — `force=true` 로 우회 가능 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 활성화 처리 실패 (이미 활성 상태·미설치·코어 버전 비호환 등) | @@ -278,11 +324,36 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `1` | 업데이트 가능으로 판정된 템플릿 개수 | +| details | array | `[{"identifier":"sirsoft-basic", …}]` | 업데이트 가능한 템플릿 목록 (각 항목: identifier, current_version, latest_version, update_source) | +| details[].identifier | string | `sirsoft-basic` | 템플릿 식별자 | +| details[].current_version | string | `1.0.0` | 현재 설치된 버전 | +| details[].latest_version | string | `1.0.1` | 감지된 최신 버전 | +| details[].update_source | string | `github` | 업데이트 감지 출처 (`github` \| `bundled`) | **응답 예시** - +```json +{ + "success": true, + "message": "업데이트 확인이 완료되었습니다.", + "data": { + "updated_count": 1, + "details": [ + { + "identifier": "sirsoft-basic", + "current_version": "1.0.0", + "latest_version": "1.0.1", + "update_source": "github" + } + ] + } +} +``` **에러 응답** @@ -291,6 +362,7 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 업데이트 확인 처리 실패 (GitHub API 호출 실패 등) | @@ -327,11 +399,71 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (TemplateResource)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-admin_basic` | 템플릿 고유 식별자 (vendor-name 형식) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Admin Basic` | 템플릿 이름 (현재 로케일 값) | +| version | string | `1.0.0` | 템플릿 버전 | +| type | string | `admin` | 템플릿 타입 (`admin` \| `user`) | +| status | string | `inactive` | 상태 (비활성화 직후 `inactive`) | +| description | string | `그누보드7 기본 관리자 템플릿` | 템플릿 설명 (현재 로케일 값) | +| dependencies | object | `{"modules":[],"plugins":[]}` | 의존 확장 요약 ({modules, plugins} — 각 항목 identifier/name/type) | +| dependencies_met | boolean | `true` | 의존 확장이 모두 활성·버전 충족인지 여부 | +| update_available | boolean | `false` | 최신 버전 대비 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 (`github` \| `bundled` \| null) | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string\|null | `null` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | `_pending` 대기소에 있어 설치 대기 중인지 여부 | +| is_bundled | boolean | `true` | 코어 선탑재(번들) 확장 여부 | +| deactivated_reason | string\|null | `manual` | 비활성화 사유 (`manual` \| `incompatible_core` \| null) | +| deactivated_at | string\|null | `2026-07-14 10:00:00` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 코어 버전 미충족 시 요구 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true,"can_edit_layouts":true}` | 현재 사용자가 이 템플릿에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "템플릿이 비활성화되었습니다.", + "data": { + "identifier": "sirsoft-admin_basic", + "vendor": "sirsoft", + "name": "Admin Basic", + "version": "1.0.0", + "type": "admin", + "status": "inactive", + "description": "그누보드7 기본 관리자 템플릿", + "dependencies": { + "modules": [], + "plugins": [] + }, + "dependencies_met": true, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": true, + "deactivated_reason": "manual", + "deactivated_at": "2026-07-14 10:00:00", + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true, + "can_edit_layouts": true + } + } +} +``` **에러 응답** @@ -340,6 +472,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 비활성화 처리 실패 (템플릿 미존재 등) | @@ -384,11 +517,73 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (TemplateResource + cascade 결과)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-basic` | 설치된 템플릿 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Basic` | 템플릿 이름 (현재 로케일 값) | +| version | string | `1.0.0` | 설치된 템플릿 버전 | +| type | string | `user` | 템플릿 타입 (`admin` \| `user`) | +| status | string | `inactive` | 설치 직후 상태 (설치만 수행 — 활성화는 별도 API) | +| description | string | `그누보드7 기본 사용자 템플릿` | 템플릿 설명 (현재 로케일 값) | +| dependencies | object | `{"modules":[],"plugins":[]}` | 의존 확장 요약 ({modules, plugins}) | +| dependencies_met | boolean | `true` | 의존 확장이 모두 활성·버전 충족인지 여부 | +| update_available | boolean | `false` | 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string\|null | `null` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | `_pending` 대기 여부 | +| is_bundled | boolean | `true` | 번들 확장 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 코어 버전 미충족 시 요구 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true,"can_edit_layouts":true}` | 현재 사용자의 수행 가능 작업 맵 | +| language_pack_failures | array | `[]` | cascade 2단계에서 설치 실패한 동반 번들 언어팩 목록 (각 항목: identifier, reason). best-effort 이므로 실패해도 설치 자체는 성공 | **응답 예시** - +```json +{ + "success": true, + "message": "템플릿이 성공적으로 설치되었습니다.", + "data": { + "identifier": "sirsoft-basic", + "vendor": "sirsoft", + "name": "Basic", + "version": "1.0.0", + "type": "user", + "status": "inactive", + "description": "그누보드7 기본 사용자 템플릿", + "dependencies": { + "modules": [], + "plugins": [] + }, + "dependencies_met": true, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": true, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true, + "can_edit_layouts": true + }, + "language_pack_failures": [] + } +} +``` **에러 응답** @@ -396,7 +591,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 설치 실패 (이미 설치됨·manifest 오류·cascade 의존 확장 설치 실패 등 — `errors` 에 번역된 사유) | +| 500 | Server Error | 설치 처리 중 예기치 못한 오류 | @@ -436,11 +632,71 @@ Content-Type: application/octet-stream **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (TemplateResource — 목록 응답 항목과 동일한 필드 구성)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-basic` | 설치된 템플릿 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Basic` | 템플릿 이름 (현재 로케일 값) | +| version | string | `1.0.0` | 설치된 템플릿 버전 | +| type | string | `user` | 템플릿 타입 (`admin` \| `user`) | +| status | string | `inactive` | 설치 직후 상태 | +| description | string | `그누보드7 기본 사용자 템플릿` | 템플릿 설명 | +| dependencies | object | `{"modules":[],"plugins":[]}` | 의존 확장 요약 | +| dependencies_met | boolean | `true` | 의존 확장 충족 여부 | +| update_available | boolean | `false` | 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string\|null | `null` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | `_pending` 대기 여부 | +| is_bundled | boolean | `false` | 번들 확장 여부 (ZIP 업로드 설치이므로 false) | +| deactivated_reason | string\|null | `null` | 비활성화 사유 | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 코어 버전 미충족 시 요구 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true,"can_edit_layouts":true}` | 현재 사용자의 수행 가능 작업 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "템플릿이 성공적으로 설치되었습니다.", + "data": { + "identifier": "sirsoft-basic", + "vendor": "sirsoft", + "name": "Basic", + "version": "1.0.0", + "type": "user", + "status": "inactive", + "description": "그누보드7 기본 사용자 템플릿", + "dependencies": { + "modules": [], + "plugins": [] + }, + "dependencies_met": true, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true, + "can_edit_layouts": true + } + } +} +``` **에러 응답** @@ -448,7 +704,8 @@ Content-Type: application/octet-stream | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 파일 검증 위반 또는 ZIP 처리 실패 (template.json 누락/무효, 이미 설치된 식별자, 잘못된 디렉토리명 등) | +| 500 | Server Error | 설치 처리 중 예기치 못한 오류 | @@ -485,11 +742,71 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (TemplateResource — 목록 응답 항목과 동일한 필드 구성)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-basic` | 설치된 템플릿 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Basic` | 템플릿 이름 (현재 로케일 값) | +| version | string | `1.0.0` | 설치된 템플릿 버전 | +| type | string | `user` | 템플릿 타입 (`admin` \| `user`) | +| status | string | `inactive` | 설치 직후 상태 | +| description | string | `그누보드7 기본 사용자 템플릿` | 템플릿 설명 | +| dependencies | object | `{"modules":[],"plugins":[]}` | 의존 확장 요약 | +| dependencies_met | boolean | `true` | 의존 확장 충족 여부 | +| update_available | boolean | `false` | 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string\|null | `https://github.com/gnuboard/g7-template-basic` | manifest 에 선언된 GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | `_pending` 대기 여부 | +| is_bundled | boolean | `false` | 번들 확장 여부 (GitHub 설치이므로 false) | +| deactivated_reason | string\|null | `null` | 비활성화 사유 | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 코어 버전 미충족 시 요구 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true,"can_edit_layouts":true}` | 현재 사용자의 수행 가능 작업 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "템플릿이 성공적으로 설치되었습니다.", + "data": { + "identifier": "sirsoft-basic", + "vendor": "sirsoft", + "name": "Basic", + "version": "1.0.0", + "type": "user", + "status": "inactive", + "description": "그누보드7 기본 사용자 템플릿", + "dependencies": { + "modules": [], + "plugins": [] + }, + "dependencies_met": true, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": "https://github.com/gnuboard/g7-template-basic", + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true, + "can_edit_layouts": true + } + } +} +``` **에러 응답** @@ -497,7 +814,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | URL 검증 위반 또는 설치 실패 (유효하지 않은 GitHub URL, 저장소 없음, 다운로드 실패, 이미 설치된 식별자 등) | +| 500 | Server Error | 설치 처리 중 예기치 못한 오류 | @@ -527,11 +845,17 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`, 성공 메시지만)._ **응답 예시** - +```json +{ + "success": true, + "message": "첨부 파일을 삭제했습니다.", + "data": null +} +``` **에러 응답** @@ -541,6 +865,7 @@ Authorization: Bearer {YOUR_TOKEN} | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 첨부 파일 삭제 실패 (스토리지/DB 삭제 실패 — `첨부 파일 삭제에 실패했습니다.`) | @@ -578,11 +903,41 @@ Content-Type: application/octet-stream **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| manifest | object\|null | `{"identifier":"sirsoft-basic","version":"1.0.0", …}` | ZIP 안의 `template.json` 원본 내용 (파싱 실패 시 null) | +| validation | object | `{"errors":[],"is_valid":true, …}` | 설치 전 검증 결과 | +| validation.errors | array | `[]` | 추출/검증 중 발생한 오류 메시지 배열 (문자열) | +| validation.is_valid | boolean | `true` | manifest 추출 성공 + 오류 없음 여부 | +| validation.already_installed | boolean | `false` | 동일 식별자의 템플릿이 이미 설치되어 있는지 여부 | +| validation.existing_version | string\|null | `1.0.0` | 이미 설치된 경우 그 버전 (미설치면 null) | **응답 예시** - +```json +{ + "success": true, + "message": "manifest 미리보기를 완료했습니다.", + "data": { + "manifest": { + "identifier": "sirsoft-basic", + "vendor": "sirsoft", + "name": "Basic", + "version": "1.0.0", + "type": "user", + "description": "그누보드7 기본 사용자 템플릿" + }, + "validation": { + "errors": [], + "is_valid": true, + "already_installed": false, + "existing_version": null + } + } +} +``` **에러 응답** @@ -590,7 +945,7 @@ Content-Type: application/octet-stream | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 파일 검증 위반 또는 ZIP 열기 실패 (`manifest 미리보기에 실패했습니다.` + `errors.error`) | @@ -627,11 +982,71 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (TemplateResource — 갱신 후 템플릿 정보)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-admin_basic` | 템플릿 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Admin Basic` | 템플릿 이름 (현재 로케일 값) | +| version | string | `1.0.0` | 템플릿 버전 | +| type | string | `admin` | 템플릿 타입 (`admin` \| `user`) | +| status | string | `active` | 템플릿 상태 | +| description | string | `그누보드7 기본 관리자 템플릿` | 템플릿 설명 | +| dependencies | object | `{"modules":[],"plugins":[]}` | 의존 확장 요약 | +| dependencies_met | boolean | `true` | 의존 확장 충족 여부 | +| update_available | boolean | `false` | 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| file_version | string\|null | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string\|null | `null` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | `_pending` 대기 여부 | +| is_bundled | boolean | `true` | 번들 확장 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 코어 버전 미충족 시 요구 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true,"can_edit_layouts":true}` | 현재 사용자의 수행 가능 작업 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "템플릿 레이아웃이 성공적으로 갱신되었습니다.", + "data": { + "identifier": "sirsoft-admin_basic", + "vendor": "sirsoft", + "name": "Admin Basic", + "version": "1.0.0", + "type": "admin", + "status": "active", + "description": "그누보드7 기본 관리자 템플릿", + "dependencies": { + "modules": [], + "plugins": [] + }, + "dependencies_met": true, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": true, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true, + "can_edit_layouts": true + } + } +} +``` **에러 응답** @@ -639,7 +1054,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 레이아웃 갱신 실패 (레이아웃 JSON 무효, layout_name 누락 등) | +| 500 | Server Error | 레이아웃 갱신 처리 중 예기치 못한 오류 | @@ -672,11 +1088,17 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`, 성공 메시지만)._ **응답 예시** - +```json +{ + "success": true, + "message": "템플릿이 성공적으로 제거되었습니다.", + "data": null +} +``` **에러 응답** @@ -684,7 +1106,8 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 제거 실패 (활성 상태·파일 삭제 실패 등 — `errors.identifier` 에 번역된 사유) | +| 500 | Server Error | 제거 처리 중 예기치 못한 오류 | @@ -860,10 +1283,10 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | identifier | string | `sirsoft-admin_basic` | 템플릿 고유 식별자 (vendor-name 형식, 예: sirsoft-admin_basic) | -| js | array | `["\/api\/templates\/assets\/sirsoft-admin_basic?file=js%2…` | | -| css | array | `["\/api\/admin\/templates\/sirsoft-admin_basic\/editor\/c…` | | -| manifest_present | boolean | `true` | | -| manifest_source | string | `active` | | +| js | array | `["\/api\/templates\/assets\/sirsoft-admin_basic\/js\/comp…` | 프론트엔드 JS 에셋 목록 (manifest assets 파생) | +| css | array | `["\/api\/admin\/templates\/sirsoft-admin_basic\/editor\/c…` | 프론트엔드 CSS 에셋 목록 (manifest assets 파생) | +| manifest_present | boolean | `true` | manifest 파일 존재 여부 | +| manifest_source | string | `active` | manifest 를 읽어온 출처 경로 (활성/_bundled 구분) | **응답 예시** @@ -933,8 +1356,8 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | identifier | string | `sirsoft-admin_basic` | 템플릿 고유 식별자 (vendor-name 형식, 예: sirsoft-admin_basic) | -| channels | array | `[{"name":"core.admin.dashboard","source":{"kind":"core"}}…` | | -| events | array | `[]` | | +| channels | array | `[{"name":"core.admin.dashboard","source":{"kind":"core"}}…` | 이 템플릿에서 구독 가능한 브로드캐스트 채널 목록 (각 원소 name/source — 코어·모듈·플러그인이 등록한 채널) | +| events | array | `[]` | 이벤트 정의 목록 | **응답 예시** @@ -1046,11 +1469,17 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다. 본문은 `Content-Type: text/css; charset=UTF-8` 의 CSS 원문이며, 템플릿에 components.css 가 없으면 빈 본문(200)으로 폴백합니다._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: text/css; charset=UTF-8 + +.g7-editor-preview-dark .bg-white { background-color: #1f2937; } +/* (템플릿 components.css 를 프리뷰 격리 규칙으로 변환한 CSS 원문) */ +``` **에러 응답** @@ -1137,10 +1566,10 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| $schema | string | `https://json-schema.org/draft/2020-12…` | | -| templateId | string | `sirsoft-admin_basic` | | +| $schema | string | `https://json-schema.org/draft/2020-12…` | JSON 스키마 선언 URL (편집기/검증 도구용) | +| templateId | string | `sirsoft-admin_basic` | 대상 템플릿의 식별자 | | version | string | `1.0.0` | 템플릿 버전 (예: 1.0.0) | -| components | object | `{"basic":[{"name":"Button","type":"basic","description":"…` | | +| components | object | `{"basic":[{"name":"Button","type":"basic","description":"…` | 컴포넌트 트리 (레이아웃이 렌더할 컴포넌트 정의) | **응답 예시** @@ -93157,11 +93586,26 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 는 템플릿 `lang/{locale}.json` 의 내용을 그대로 담은 객체입니다 (번역 키 → 번역문). 고정 필드 스키마가 없으며 키 집합은 템플릿마다 다릅니다. 파일 부재 시 빈 객체로 폴백합니다 (message: `다국어 데이터가 비어 있습니다.`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| (템플릿 번역 키) | string\|object | `{"common":{"save":"저장"}}` | 템플릿 `lang/{locale}.json` 이 선언한 번역 키와 번역문 (중첩 객체 허용) | **응답 예시** - +```json +{ + "success": true, + "message": "다국어 데이터를 조회했습니다.", + "data": { + "common": { + "save": "저장", + "cancel": "취소" + } + } +} +``` **에러 응답** @@ -93330,10 +93774,10 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | version | string | `1.0.0` | 템플릿 버전 (예: 1.0.0) | -| routes | array | `[{"path":"*\/admin","redirect":"\/admin\/dashboard","auth…` | | -| base_layouts | array | `[{"layout_name":"_admin_base","label":null}]` | | -| modals | array | `[{"modal_id":"identity-challenge-modal","host_layout":"_a…` | | -| layout_versions | array | `[]` | | +| routes | array | `[{"path":"*\/admin","redirect":"\/admin\/dashboard","auth…` | 라우트 정의 목록 | +| base_layouts | array | `[{"layout_name":"_admin_base","label":null}]` | 상속 가능한 베이스 레이아웃 목록 | +| modals | array | `[{"modal_id":"identity-challenge-modal","host_layout":"_a…` | 모달 컴포넌트 정의 배열 | +| layout_versions | array | `[]` | 레이아웃 버전 이력 목록 | **응답 예시** @@ -93554,11 +93998,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-basic` | 미리보기를 요청한 템플릿 식별자 | +| enabled | boolean | `true` | 이 레이아웃이 봇 HTML 로 렌더되는지 여부 (`meta.seo.enabled=false` 이거나 미렌더 시 false) | +| html | string\|null | `…` | 렌더된 봇 HTML 원문 (SEO 캐시 우회 실시간 렌더). 미노출이면 null | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "identifier": "sirsoft-basic", + "enabled": true, + "html": "\n\n\n상품 목록\n\n...\n" + } +} +``` **에러 응답** @@ -93606,10 +94066,10 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | identifier | string | `sirsoft-admin_basic` | 템플릿 고유 식별자 (vendor-name 형식, 예: sirsoft-admin_basic) | -| page_types | array | `[{"value":"boards","owner":{"type":"module","id":"sirsoft…` | | -| toggle_settings | array | `[{"ref":"$module_settings:sirsoft-board:seo.seo_boards","…` | | -| vars | array | `[]` | | -| extensions | array | `[{"type":"module","id":"gnuboard7-hello_module","label":"…` | | +| page_types | array | `[{"value":"boards","owner":{"type":"module","id":"sirsoft…` | SEO 템플릿 키로 사용 가능한 페이지 유형 목록 | +| toggle_settings | array | `[{"ref":"$module_settings:sirsoft-board:seo.seo_boards","…` | 토글 가능한 설정 항목 목록 | +| vars | array | `[]` | SEO 변수 선언 맵 (데이터 소스 값의 표현식 매핑) | +| extensions | array | `[{"type":"module","id":"sirsoft-board","label":"게시판"},{"t…` | SEO 변수를 제공하는 확장 목록 (각 원소 type/id/label — 레이아웃 meta.seo.extensions 에 선언 가능한 후보) | **응답 예시** @@ -93912,11 +94372,89 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-basic` | 미리보기를 요청한 템플릿 식별자 | +| defaultsAvailable | boolean | `true` | 모듈 기본값 계산 가능 여부 (`extensions` 와 `page_type` 이 모두 지정된 경우에만 true) | +| missing | array | `["page_type"]` | 기본값 계산에 부족한 입력 키 목록 (`extensions` \| `page_type`) | +| og | array | `[{"key":"title","effectiveValue":"상품 목록", …}]` | og 키별 cascade 결과 목록 | +| og[].key | string | `title` | og 메타 키 (title, description, image, type, url 등) | +| og[].effectiveValue | mixed | `상품 목록` | 최종 적용될 값 (필터 잠김이면 필터 적용 후 값) | +| og[].source | string | `module:sirsoft-ecommerce` | 값 출처 (`filter` \| `inherited` \| `layout` \| `module:{id}` \| `core`) | +| og[].overriddenByLayout | boolean | `false` | 이 레이아웃이 직접 값을 덮었는지 여부 (상속은 false) | +| og[].inheritedFromBase | boolean | `false` | 베이스 레이아웃에서 상속된 값인지 여부 | +| og[].lockedByFilter | boolean | `false` | 확장 필터 훅이 값을 강제해 편집 불가인지 여부 | +| og[].sourceExpr | string | `{{product.name}}` | 모듈 자동값일 때 연결된 데이터 경로 표현식 (모듈 출처 + 메타 선언 시에만 동반) | +| og[].label | string | `상품 이름` | 모듈 자동값의 표시 라벨 (현재 로케일) | +| twitter | array | `[{"key":"card","effectiveValue":"summary_large_image", …}]` | twitter 키별 cascade 결과 목록 (og 와 동일 항목 구조) | +| structured | object | `{"autoBlock":{…},"hasLayoutBlock":false, …}` | 구조화 데이터(JSON-LD) 미리보기 | +| structured.autoBlock | object\|null | `{"@type":"Product", …}` | 모듈이 자동 생성한 구조화 블록 (레이아웃 미선언 시 노출) | +| structured.autoMeta | object | `{"name":{"expr":"{{product.name}}","label":"상품 이름"}}` | 자동 블록의 점 경로 키별 데이터 경로 메타 (연결 칩) | +| structured.hasLayoutBlock | boolean | `false` | 레이아웃이 직접 구조화 블록을 선언했는지 여부 (선언 시 모듈 자동값을 통째로 덮음) | +| structured.lockedByFilter | boolean | `false` | 확장 필터 훅이 구조화 블록을 강제해 편집 불가인지 여부 | +| structured.filteredBlock | object\|null | `null` | 필터 잠김 시 필터 적용 후 최종 블록 (잠기지 않으면 null) | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "identifier": "sirsoft-basic", + "defaultsAvailable": true, + "missing": [], + "og": [ + { + "key": "title", + "effectiveValue": "상품 목록", + "source": "layout", + "overriddenByLayout": true, + "inheritedFromBase": false, + "lockedByFilter": false + }, + { + "key": "image", + "effectiveValue": "https://example.com/storage/products/1.jpg", + "source": "module:sirsoft-ecommerce", + "overriddenByLayout": false, + "inheritedFromBase": false, + "lockedByFilter": false, + "sourceExpr": "{{product.image_url}}", + "label": "상품 이미지" + } + ], + "twitter": [ + { + "key": "card", + "effectiveValue": "summary_large_image", + "source": "core", + "overriddenByLayout": false, + "inheritedFromBase": false, + "lockedByFilter": false + } + ], + "structured": { + "autoBlock": { + "@context": "https://schema.org", + "@type": "Product", + "name": "예시 상품" + }, + "autoMeta": { + "name": { + "expr": "{{product.name}}", + "label": "상품 이름" + } + }, + "hasLayoutBlock": false, + "lockedByFilter": false, + "filteredBlock": null + } + } +} +``` **에러 응답** @@ -93960,7 +94498,17 @@ Authorization: Bearer {YOUR_TOKEN} - +_목록 응답: `data` 는 첨부 항목 배열입니다 (페이지네이션 없음). 첨부가 없으면 빈 배열._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 첨부 파일 기본 키 (삭제 시 path 파라미터로 사용) | +| layout_name | string\|null | `main` | 이 첨부가 속한 레이아웃 이름 (레이아웃 무관 첨부면 null) | +| original_name | string | `hero-bg.png` | 업로드 당시 원본 파일명 | +| mime_type | string | `image/png` | 파일 MIME 타입 | +| size | integer | `204800` | 파일 크기 (바이트) | +| url | string | `/storage/template-layout-attachments/sirsoft-basic/hero-bg.png` | 첨부 파일 접근 URL | +| created_at | string | `2026-07-14T10:00:00+09:00` | 업로드 일시 (ISO 8601) | **응답 예시** @@ -94031,11 +94579,33 @@ Content-Disposition: form-data; name="layout_name" **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 첨부 파일 기본 키 (삭제 시 path 파라미터로 사용) | +| layout_name | string\|null | `main` | 이 첨부가 속한 레이아웃 이름 (미지정 시 null) | +| original_name | string | `hero-bg.png` | 업로드된 원본 파일명 | +| mime_type | string | `image/png` | 파일 MIME 타입 | +| size | integer | `204800` | 파일 크기 (바이트) | +| url | string | `/storage/template-layout-attachments/sirsoft-basic/hero-bg.png` | 업로드된 파일의 접근 URL (편집기 ImagePickerControl 이 사용) | **응답 예시** - +```json +{ + "success": true, + "message": "첨부 파일을 업로드했습니다.", + "data": { + "id": 1, + "layout_name": "main", + "original_name": "hero-bg.png", + "mime_type": "image/png", + "size": 204800, + "url": "/storage/template-layout-attachments/sirsoft-basic/hero-bg.png" + } +} +``` **에러 응답** @@ -94043,8 +94613,9 @@ Content-Disposition: form-data; name="layout_name" | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 (`템플릿을 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | 파일 검증 위반 (이미지 아님, 지원하지 않는 형식 jpg/jpeg/png/gif/webp/svg 외, 크기 초과 등) | +| 500 | Server Error | 스토리지 저장 실패 (`첨부 파일 업로드에 실패했습니다.`) | @@ -94135,11 +94706,88 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (TemplateResource::toDetailArray + `language_packs`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-admin_basic` | 템플릿 고유 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Admin Basic` | 템플릿 이름 (현재 로케일 값) | +| version | string | `1.0.0` | 템플릿 버전 | +| latest_version | string\|null | `null` | 감지된 최신 배포 버전 | +| update_available | boolean | `false` | 업데이트 가능 여부 | +| update_source | string\|null | `null` | 업데이트 감지 출처 (`github` \| `bundled`) | +| file_version | string\|null | `1.0.0` | 설치된 파일의 manifest 버전 | +| type | string | `admin` | 템플릿 타입 (`admin` \| `user`) | +| status | string | `active` | 템플릿 상태 | +| description | string | `그누보드7 기본 관리자 템플릿` | 템플릿 설명 (현재 로케일 값) | +| github_url | string\|null | `null` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `null` | GitHub 변경 내역 URL | +| requires_core | string\|null | `>=7.0.0` | 요구 코어 버전 제약 (manifest `g7_version`) | +| is_pending | boolean | `false` | `_pending` 대기 여부 | +| is_bundled | boolean | `true` | 번들 확장 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 (`manual` \| `incompatible_core` \| null) | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 코어 버전 미충족 시 요구 버전 | +| locales | array | `["ko","en"]` | 템플릿이 지원하는 로케일 목록 | +| layouts_count | integer | `42` | 이 템플릿이 등록한 레이아웃 수 | +| components | object | `{"basic":[…],"composite":[…]}` | 템플릿이 제공하는 컴포넌트 목록 (basic/composite) | +| license | string\|null | `MIT` | 라이선스 표기 | +| metadata | object | `{}` | manifest 부가 메타데이터 | +| externals | object | `{}` | 외부 리소스 선언 (template.json `externals` 정규화 결과) | +| dependencies | object | `{"modules":[…],"plugins":[…]}` | 의존 확장 상세 (각 항목: identifier, name, required_version, installed_version, is_active, is_met) | +| created_at | string\|null | `2026-07-14 09:00:00` | 설치(레코드 생성) 일시 | +| updated_at | string\|null | `2026-07-14 10:00:00` | 최종 갱신 일시 | +| language_packs | array | `[]` | 이 템플릿을 대상으로 하는 언어팩 목록 (LanguagePackResource — id, identifier, locale, version, status, is_protected 등) | **응답 예시** - +```json +{ + "success": true, + "message": "템플릿 정보를 성공적으로 가져왔습니다.", + "data": { + "identifier": "sirsoft-admin_basic", + "vendor": "sirsoft", + "name": "Admin Basic", + "version": "1.0.0", + "latest_version": null, + "update_available": false, + "update_source": null, + "file_version": "1.0.0", + "type": "admin", + "status": "active", + "description": "그누보드7 기본 관리자 템플릿", + "github_url": null, + "github_changelog_url": null, + "requires_core": ">=7.0.0", + "is_pending": false, + "is_bundled": true, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "locales": [ + "ko", + "en" + ], + "layouts_count": 42, + "components": { + "basic": [], + "composite": [] + }, + "license": "MIT", + "metadata": {}, + "externals": {}, + "dependencies": { + "modules": [], + "plugins": [] + }, + "created_at": "2026-07-14 09:00:00", + "updated_at": "2026-07-14 10:00:00", + "language_packs": [] + } +} +``` **에러 응답** @@ -94147,8 +94795,9 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해당 식별자의 템플릿이 없는 경우 (`템플릿을 찾을 수 없습니다.`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 템플릿 정보 조회 처리 실패 | @@ -94178,11 +94827,38 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| has_modified_layouts | boolean | `true` | 사용자가 수정한 레이아웃이 하나라도 있는지 여부 (설치 시 해시와 현재 내용 비교) | +| modified_count | integer | `2` | 수정된 레이아웃 개수 | +| modified_layouts | array | `[{"id":12,"name":"main", …}]` | 수정된 레이아웃 목록 | +| modified_layouts[].id | integer | `12` | 레이아웃 레코드 ID | +| modified_layouts[].name | string | `main` | 레이아웃 이름 | +| modified_layouts[].updated_at | string\|null | `2026-07-14 10:00:00` | 마지막 수정 일시 | +| modified_layouts[].size_diff | integer | `128` | 설치 당시 대비 내용 크기 변화량 (바이트, 음수 가능) | **응답 예시** - +```json +{ + "success": true, + "message": "수정된 레이아웃 확인이 완료되었습니다.", + "data": { + "has_modified_layouts": true, + "modified_count": 1, + "modified_layouts": [ + { + "id": 12, + "name": "main", + "updated_at": "2026-07-14 10:00:00", + "size_diff": 128 + } + ] + } +} +``` **에러 응답** @@ -94190,8 +94866,9 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | 해당 식별자의 템플릿이 설치되어 있지 않은 경우 (레이아웃 0건과 구분됨) | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 확인 실패 (`errors.template_name`) | +| 500 | Server Error | 확인 처리 중 예기치 못한 오류 | @@ -94224,11 +94901,23 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | integer | `3` | 실제로 삭제된 커스텀 다국어 키 개수 (요청 `ids` 중 해당 템플릿 소속만 삭제하므로 요청 건수보다 적을 수 있음. 소속 키가 하나도 없으면 `0`) | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "deleted": 3 + } +} +``` **에러 응답** @@ -94236,8 +94925,9 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`ids` 누락 또는 빈 배열 등) | +| 500 | Server Error | 삭제 트랜잭션 실패 | @@ -94271,11 +94961,44 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_목록 응답: `data` 는 커스텀 다국어 키 배열입니다 (페이지네이션 없음)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 커스텀 다국어 키 ID | +| template_id | integer | `1` | 소속 템플릿 ID | +| layout_name | string\|null | `main` | 생성 출처 레이아웃 이름 | +| translation_key | string | `custom.main.a1b2c3` | 다국어 키 (`$t:` 참조 경로) | +| values | object | `{"ko":"저장","en":"Save"}` | 로케일별 번역 값 맵 | +| status | string | `active` | 상태 (`active`: 레이아웃에서 사용 중, `orphaned`: 참조가 사라진 고아 키) | +| lock_version | integer | `0` | 낙관적 잠금 버전 (수정 시 `expected_lock_version` 으로 되돌려 보냄) | +| created_at | string\|null | `2026-07-14T10:00:00.000000Z` | 생성 일시 | +| updated_at | string\|null | `2026-07-14T10:00:00.000000Z` | 최종 수정 일시 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": [ + { + "id": 1, + "template_id": 1, + "layout_name": "main", + "translation_key": "custom.main.a1b2c3", + "values": { + "ko": "저장", + "en": "Save" + }, + "status": "active", + "lock_version": 0, + "created_at": "2026-07-14T10:00:00.000000Z", + "updated_at": "2026-07-14T10:00:00.000000Z" + } + ] +} +``` **에러 응답** @@ -94283,7 +95006,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -94326,11 +95049,41 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 커스텀 다국어 키, HTTP 201)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 커스텀 다국어 키 ID | +| template_id | integer | `1` | 소속 템플릿 ID | +| layout_name | string\|null | `main` | 생성 출처 레이아웃 이름 | +| translation_key | string | `custom.main.a1b2c3` | 자동 생성된 다국어 키 (`$t:` 참조 경로) | +| values | object | `{"ko":"저장"}` | 로케일별 번역 값 맵 (요청의 `locale`/`value` 로 초기화) | +| status | string | `active` | 상태 (`active` \| `orphaned`) | +| lock_version | integer | `0` | 낙관적 잠금 버전 (생성 직후 0) | +| created_at | string\|null | `2026-07-14T10:00:00.000000Z` | 생성 일시 | +| updated_at | string\|null | `2026-07-14T10:00:00.000000Z` | 최종 수정 일시 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 1, + "template_id": 1, + "layout_name": "main", + "translation_key": "custom.main.a1b2c3", + "values": { + "ko": "저장" + }, + "status": "active", + "lock_version": 0, + "created_at": "2026-07-14T10:00:00.000000Z", + "updated_at": "2026-07-14T10:00:00.000000Z" + } +} +``` **에러 응답** @@ -94338,8 +95091,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 키 생성 트랜잭션 실패 | @@ -94370,11 +95124,17 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`, 성공 메시지만)._ **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": null +} +``` **에러 응답** @@ -94382,8 +95142,9 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿이 없거나, 해당 키가 없거나, 그 키가 경로의 템플릿 소속이 아닌 경우 (교차 템플릿 삭제 차단) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 삭제 트랜잭션 실패 | @@ -94426,11 +95187,42 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 커스텀 다국어 키)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 커스텀 다국어 키 ID | +| template_id | integer | `1` | 소속 템플릿 ID | +| layout_name | string\|null | `main` | 생성 출처 레이아웃 이름 | +| translation_key | string | `custom.main.a1b2c3` | 다국어 키 (`$t:` 참조 경로) | +| values | object | `{"ko":"저장","en":"Save"}` | 수정 후 로케일별 번역 값 맵 | +| status | string | `active` | 상태 (`active` \| `orphaned`) | +| lock_version | integer | `2` | 수정 후 증가된 낙관적 잠금 버전 (다음 수정 시 이 값을 `expected_lock_version` 으로 전달) | +| created_at | string\|null | `2026-07-14T10:00:00.000000Z` | 생성 일시 | +| updated_at | string\|null | `2026-07-14T11:00:00.000000Z` | 최종 수정 일시 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 1, + "template_id": 1, + "layout_name": "main", + "translation_key": "custom.main.a1b2c3", + "values": { + "ko": "저장", + "en": "Save" + }, + "status": "active", + "lock_version": 2, + "created_at": "2026-07-14T10:00:00.000000Z", + "updated_at": "2026-07-14T11:00:00.000000Z" + } +} +``` **에러 응답** @@ -94438,8 +95230,10 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿이 없거나, 해당 키가 없거나, 그 키가 경로의 템플릿 소속이 아닌 경우 | +| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 수정 트랜잭션 실패 | @@ -94469,11 +95263,73 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| target | object | `{"identifier":"sirsoft-basic","name":"Basic","version":"1.0.0"}` | 설치 대상 템플릿 요약 (identifier, name, version) | +| dependencies | array | `[{"type":"module","identifier":"sirsoft-board", …}]` | 함께 설치가 필요한 의존 확장 후보 목록 (cascade 체크리스트) | +| dependencies[].type | string | `module` | 확장 종류 (`module` \| `plugin`) | +| dependencies[].identifier | string | `sirsoft-board` | 의존 확장 식별자 | +| dependencies[].name | string\|null | `게시판` | 의존 확장 이름 (현재 로케일) | +| dependencies[].required_version | string\|null | `>=1.0.0` | manifest 가 요구하는 버전 제약 | +| dependencies[].installed_version | string\|null | `null` | 현재 설치된 버전 (미설치면 null) | +| dependencies[].is_installed | boolean | `false` | 설치 여부 | +| dependencies[].is_active | boolean | `false` | 활성 여부 | +| dependencies[].is_met | boolean | `false` | 의존성 충족 여부 (활성 + 버전 제약 만족) | +| dependencies[].available | boolean | `true` | cascade 설치 후보로 선택 가능한지 여부 (미충족 항목만 true) | +| dependencies[].default_selected | boolean | `true` | 체크리스트 기본 선택 여부 (미충족 + 미설치면 true) | +| language_packs | array | `[{"bundled_identifier":"g7-template-basic-ja", …}]` | 함께 설치 가능한 미설치 번들 언어팩 후보 (본 템플릿용 우선, 이어서 의존 확장용) | +| language_packs[].bundled_identifier | string | `g7-template-basic-ja` | 번들 언어팩 식별자 | +| language_packs[].locale | string | `ja` | 언어팩 로케일 코드 | +| language_packs[].locale_native_name | string | `日本語` | 로케일 자국어 표기 | +| language_packs[].locale_name | string | `일본어` | 로케일 이름 | +| language_packs[].version | string | `1.0.0` | 언어팩 버전 | +| language_packs[].depends_on_extension | string\|null | `null` | 이 언어팩이 귀속된 의존 확장 식별자 (본 템플릿용이면 null) | +| language_packs[].available | boolean | `true` | 설치 후보 선택 가능 여부 | +| language_packs[].default_selected | boolean | `true` | 체크리스트 기본 선택 여부 | **응답 예시** - +```json +{ + "success": true, + "message": "템플릿 정보를 성공적으로 가져왔습니다.", + "data": { + "target": { + "identifier": "sirsoft-basic", + "name": "Basic", + "version": "1.0.0" + }, + "dependencies": [ + { + "type": "module", + "identifier": "sirsoft-board", + "name": "게시판", + "required_version": ">=1.0.0", + "installed_version": null, + "is_installed": false, + "is_active": false, + "is_met": false, + "available": true, + "default_selected": true + } + ], + "language_packs": [ + { + "bundled_identifier": "g7-template-basic-ja", + "locale": "ja", + "locale_native_name": "日本語", + "locale_name": "일본어", + "version": "1.0.0", + "depends_on_extension": null, + "available": true, + "default_selected": true + } + ] + } +} +``` **에러 응답** @@ -94483,6 +95339,7 @@ Authorization: Bearer {YOUR_TOKEN} | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 대상 템플릿을 찾을 수 없거나 프리뷰 구성 실패 | @@ -94512,11 +95369,80 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_목록 응답: `data` 는 출처(모듈/플러그인)별 그룹 배열입니다 (페이지네이션 없음)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| source_identifier | string | `sirsoft-ecommerce` | 그룹 출처 확장의 식별자 (템플릿 오버라이드 행은 대상 확장 기준으로 묶임) | +| source_type | string | `module` | 그룹 출처 종류 (`module` \| `plugin` \| `template`) | +| source_label | string | `이커머스` | 그룹 출처 표시명 (현재 로케일) | +| extensions | array | `[{"id":3,"extension_type":"overlay", …}]` | 그 출처가 주입한 레이아웃 확장 목록 | +| extensions[].id | integer | `3` | 레이아웃 확장 ID | +| extensions[].template_id | integer | `1` | 소속 템플릿 ID | +| extensions[].extension_type | string | `overlay` | 확장 방식 (`overlay`: 특정 레이아웃 오버레이, `extension_point`: 확장점 주입) | +| extensions[].target_name | string | `main` | 대상 레이아웃명 또는 확장점 이름 | +| extensions[].source_type | string | `module` | 이 확장 행의 출처 종류 (`template` 이면 템플릿 오버라이드 행) | +| extensions[].source_identifier | string | `sirsoft-ecommerce` | 이 확장 행의 출처 식별자 | +| extensions[].source_label | string | `이커머스` | 출처 표시명 | +| extensions[].override_target | integer\|null | `null` | 템플릿 오버라이드인 경우 덮어쓰는 원본 확장 ID | +| extensions[].is_override | boolean | `false` | 템플릿 오버라이드 행 여부 | +| extensions[].priority | integer | `10` | 주입 우선순위 (낮을수록 먼저) | +| extensions[].is_active | boolean | `true` | 활성 여부 | +| extensions[].host_layouts | array | `["main","product_list"]` | 이 확장이 실제로 주입되는 호스트 레이아웃명 목록 | +| extensions[].content | string | `"{\n \"injections\": [...]\n}"` | 확장 content JSON 문자열 (pretty-print) | +| extensions[].size | integer | `2048` | content JSON 바이트 크기 | +| extensions[].size_formatted | string | `2.0 KB` | 사람이 읽는 크기 표기 | +| extensions[].is_modified | boolean | `false` | 관리자가 원본 content 를 수정했는지 여부 (해시 비교) | +| extensions[].lock_version | integer | `0` | 낙관적 잠금 버전 (수정 시 `expected_lock_version` 으로 전달) | +| extensions[].current_version | integer\|null | `2` | 현재(최신) 저장 버전 번호 (이력 없으면 null) | +| extensions[].created_at | string | `2026-07-14T10:00:00+09:00` | 생성 일시 (사용자 타임존 ISO 8601) | +| extensions[].updated_at | string | `2026-07-14T10:00:00+09:00` | 최종 수정 일시 | +| extensions[].abilities | object | `{"can_update":true}` | 현재 사용자의 수행 가능 작업 맵 (`core.templates.layouts.edit` 기반) | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": [ + { + "source_identifier": "sirsoft-ecommerce", + "source_type": "module", + "source_label": "이커머스", + "extensions": [ + { + "id": 3, + "template_id": 1, + "extension_type": "overlay", + "target_name": "main", + "source_type": "module", + "source_identifier": "sirsoft-ecommerce", + "source_label": "이커머스", + "override_target": null, + "is_override": false, + "priority": 10, + "is_active": true, + "host_layouts": [ + "main" + ], + "content": "{\n \"target_layout\": \"main\"\n}", + "size": 34, + "size_formatted": "34 B", + "is_modified": false, + "lock_version": 0, + "current_version": null, + "created_at": "2026-07-14T10:00:00+09:00", + "updated_at": "2026-07-14T10:00:00+09:00", + "abilities": { + "can_update": true + } + } + ] + } + ] +} +``` **에러 응답** @@ -94524,7 +95450,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -94556,11 +95482,67 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (LayoutExtensionResource + `host_layouts`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `3` | 레이아웃 확장 ID | +| template_id | integer | `1` | 소속 템플릿 ID | +| extension_type | string | `overlay` | 확장 방식 (`overlay` \| `extension_point`) | +| target_name | string | `main` | 대상 레이아웃명 또는 확장점 이름 | +| source_type | string | `module` | 출처 종류 (`module` \| `plugin` \| `template`) | +| source_identifier | string | `sirsoft-ecommerce` | 출처 확장 식별자 | +| source_label | string | `sirsoft-ecommerce` | 출처 표시명 (index 응답과 달리 라벨 미부착 시 식별자로 폴백) | +| override_target | integer\|null | `null` | 템플릿 오버라이드인 경우 덮어쓰는 원본 확장 ID | +| is_override | boolean | `false` | 템플릿 오버라이드 행 여부 | +| priority | integer | `10` | 주입 우선순위 | +| is_active | boolean | `true` | 활성 여부 | +| host_layouts | array | `["main"]` | 편집 캔버스가 병합 렌더할 호스트 레이아웃 후보 (overlay 는 대상 레이아웃, extension_point 는 그 확장점을 포함한 레이아웃 전체) | +| content | string | `"{\n \"target_layout\": \"main\"\n}"` | 확장 content JSON 문자열 (pretty-print) | +| size | integer | `34` | content JSON 바이트 크기 | +| size_formatted | string | `34 B` | 사람이 읽는 크기 표기 | +| is_modified | boolean | `false` | 관리자가 원본 content 를 수정했는지 여부 | +| lock_version | integer | `0` | 낙관적 잠금 버전 | +| current_version | integer\|null | `null` | 현재 저장 버전 번호 (상세 응답에서는 미부착 → null) | +| created_at | string | `2026-07-14T10:00:00+09:00` | 생성 일시 | +| updated_at | string | `2026-07-14T10:00:00+09:00` | 최종 수정 일시 | +| abilities | object | `{"can_update":true}` | 현재 사용자의 수행 가능 작업 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 3, + "template_id": 1, + "extension_type": "overlay", + "target_name": "main", + "source_type": "module", + "source_identifier": "sirsoft-ecommerce", + "source_label": "sirsoft-ecommerce", + "override_target": null, + "is_override": false, + "priority": 10, + "is_active": true, + "content": "{\n \"target_layout\": \"main\"\n}", + "size": 34, + "size_formatted": "34 B", + "is_modified": false, + "lock_version": 0, + "current_version": null, + "created_at": "2026-07-14T10:00:00+09:00", + "updated_at": "2026-07-14T10:00:00+09:00", + "abilities": { + "can_update": true + }, + "host_layouts": [ + "main" + ] + } +} +``` **에러 응답** @@ -94568,7 +95550,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 템플릿/확장이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -94591,8 +95573,8 @@ Authorization: Bearer {YOUR_TOKEN} | expected_lock_version | body | integer | 예 | min 0 | 낙관적 잠금 버전 (동시 편집 충돌 감지) | | content | body | array | 예 | — | 본문 내용 | | priority | body | integer | 아니오 | min 0, max 9999 | 우선순위 (작을수록 우선) | -| content.priority | body | integer | 아니오 | min 0, max 9999 | | -| content.data_sources | body | array | 아니오 | — | | +| content.priority | body | integer | 아니오 | min 0, max 9999 | 우선순위 (작을수록 우선) | +| content.data_sources | body | array | 아니오 | — | API 데이터 소스 정의 배열 (id/endpoint/method) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.layout_extension.update_content_validation_rules`). @@ -94620,11 +95602,65 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 LayoutExtensionResource)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `3` | 레이아웃 확장 ID | +| template_id | integer | `1` | 소속 템플릿 ID | +| extension_type | string | `overlay` | 확장 방식 (`overlay` \| `extension_point`) | +| target_name | string | `main` | 대상 레이아웃명 또는 확장점 이름 | +| source_type | string | `module` | 출처 종류 | +| source_identifier | string | `sirsoft-ecommerce` | 출처 확장 식별자 | +| source_label | string | `sirsoft-ecommerce` | 출처 표시명 | +| override_target | integer\|null | `null` | 템플릿 오버라이드인 경우 원본 확장 ID | +| is_override | boolean | `false` | 템플릿 오버라이드 행 여부 | +| priority | integer | `10` | 주입 우선순위 (요청에 `priority` 를 보내면 갱신됨) | +| is_active | boolean | `true` | 활성 여부 | +| host_layouts | array | `[]` | 호스트 레이아웃 목록 (수정 응답에서는 미부착 → 빈 배열) | +| content | string | `"{\n \"target_layout\": \"main\"\n}"` | 저장된 확장 content JSON 문자열 | +| size | integer | `34` | content JSON 바이트 크기 | +| size_formatted | string | `34 B` | 사람이 읽는 크기 표기 | +| is_modified | boolean | `true` | 원본 대비 수정 여부 | +| lock_version | integer | `1` | 증가된 낙관적 잠금 버전 (다음 저장 시 `expected_lock_version` 으로 전달) | +| current_version | integer\|null | `2` | 저장으로 기록된 최신 버전 번호 | +| created_at | string | `2026-07-14T10:00:00+09:00` | 생성 일시 | +| updated_at | string | `2026-07-14T11:00:00+09:00` | 최종 수정 일시 | +| abilities | object | `{"can_update":true}` | 현재 사용자의 수행 가능 작업 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 3, + "template_id": 1, + "extension_type": "overlay", + "target_name": "main", + "source_type": "module", + "source_identifier": "sirsoft-ecommerce", + "source_label": "sirsoft-ecommerce", + "override_target": null, + "is_override": false, + "priority": 10, + "is_active": true, + "host_layouts": [], + "content": "{\n \"target_layout\": \"main\"\n}", + "size": 34, + "size_formatted": "34 B", + "is_modified": true, + "lock_version": 1, + "current_version": 2, + "created_at": "2026-07-14T10:00:00+09:00", + "updated_at": "2026-07-14T11:00:00+09:00", + "abilities": { + "can_update": true + } + } +} +``` **에러 응답** @@ -94632,8 +95668,10 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 템플릿/확장이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 | +| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 저장 트랜잭션 실패 | @@ -94676,11 +95714,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| token | string | `9f2c1a4e8b7d…` | 미리보기 토큰 (임시 저장된 확장 content 를 식별) | +| preview_url | string | `/preview/9f2c1a4e8b7d…` | 미리보기 페이지 경로 (새 탭으로 열어 확인) | +| expires_at | string | `2026-07-14T11:00:00+09:00` | 미리보기 만료 일시 (ISO 8601) | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "token": "9f2c1a4e8b7d3c5f", + "preview_url": "/preview/9f2c1a4e8b7d3c5f", + "expires_at": "2026-07-14T11:00:00+09:00" + } +} +``` **에러 응답** @@ -94688,8 +95742,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 템플릿/확장이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 `extension_point` 타입인데 `preview_layout` 이 없는 경우 (대표 레이아웃 결정 불가) | +| 500 | Server Error | 미리보기 생성 실패 | @@ -94723,11 +95778,46 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_목록 응답: `data` 는 버전 이력 배열입니다 (페이지네이션 없음)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `7` | 버전 레코드 ID (복원 시 path 파라미터 `versionId` 로 사용) | +| extension_id | integer | `3` | 소속 레이아웃 확장 ID | +| version | integer | `2` | 버전 번호 (증가 순서) | +| content | string | `"{\n \"target_layout\": \"main\"\n}"` | 그 버전의 확장 content JSON 문자열 | +| changes_summary | object | `{"added_count":3,"removed_count":1,"char_diff":42}` | 직전 버전 대비 변경 요약 | +| changes_summary.added_count | integer | `3` | 추가된 라인 수 | +| changes_summary.removed_count | integer | `1` | 삭제된 라인 수 | +| changes_summary.char_diff | integer | `42` | 문자 수 변화량 (음수 가능) | +| created_by_name | string\|null | `관리자` | 저장자 이름 (탈퇴 사용자/관계 미로딩 시 null — 사용자 ID 는 미노출) | +| created_at | string | `2026-07-14T10:00:00+09:00` | 버전 저장 일시 | +| updated_at | string | `2026-07-14T10:00:00+09:00` | 레코드 갱신 일시 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": [ + { + "id": 7, + "extension_id": 3, + "version": 2, + "content": "{\n \"target_layout\": \"main\"\n}", + "changes_summary": { + "added_count": 3, + "removed_count": 1, + "char_diff": 42 + }, + "created_by_name": "관리자", + "created_at": "2026-07-14T10:00:00+09:00", + "updated_at": "2026-07-14T10:00:00+09:00" + } + ] +} +``` **에러 응답** @@ -94735,8 +95825,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 템플릿/확장이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -94769,11 +95858,41 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (복원 결과로 새로 기록된 버전)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `9` | 복원으로 새로 생성된 버전 레코드 ID | +| extension_id | integer | `3` | 소속 레이아웃 확장 ID | +| version | integer | `3` | 새 버전 번호 (복원도 새 버전으로 기록) | +| content | string | `"{\n \"target_layout\": \"main\"\n}"` | 복원된 확장 content JSON 문자열 | +| changes_summary | object | `{"added_count":1,"removed_count":3,"char_diff":-42}` | 직전 버전 대비 변경 요약 (added_count, removed_count, char_diff) | +| created_by_name | string\|null | `관리자` | 복원 수행자 이름 (탈퇴 사용자/관계 미로딩 시 null) | +| created_at | string | `2026-07-14T11:00:00+09:00` | 복원 일시 | +| updated_at | string | `2026-07-14T11:00:00+09:00` | 레코드 갱신 일시 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 9, + "extension_id": 3, + "version": 3, + "content": "{\n \"target_layout\": \"main\"\n}", + "changes_summary": { + "added_count": 1, + "removed_count": 3, + "char_diff": -42 + }, + "created_by_name": "관리자", + "created_at": "2026-07-14T11:00:00+09:00", + "updated_at": "2026-07-14T11:00:00+09:00" + } +} +``` **에러 응답** @@ -94781,8 +95900,9 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 템플릿/확장/버전이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 복원 트랜잭션 실패 | @@ -94814,11 +95934,41 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (LayoutExtensionVersionResource)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `7` | 버전 레코드 ID | +| extension_id | integer | `3` | 소속 레이아웃 확장 ID | +| version | integer | `2` | 버전 번호 (path 파라미터 `version` 과 일치) | +| content | string | `"{\n \"target_layout\": \"main\"\n}"` | 그 버전의 확장 content JSON 문자열 (diff 비교용) | +| changes_summary | object | `{"added_count":3,"removed_count":1,"char_diff":42}` | 직전 버전 대비 변경 요약 (added_count, removed_count, char_diff) | +| created_by_name | string\|null | `관리자` | 저장자 이름 (탈퇴 사용자/관계 미로딩 시 null) | +| created_at | string | `2026-07-14T10:00:00+09:00` | 버전 저장 일시 | +| updated_at | string | `2026-07-14T10:00:00+09:00` | 레코드 갱신 일시 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 7, + "extension_id": 3, + "version": 2, + "content": "{\n \"target_layout\": \"main\"\n}", + "changes_summary": { + "added_count": 3, + "removed_count": 1, + "char_diff": 42 + }, + "created_by_name": "관리자", + "created_at": "2026-07-14T10:00:00+09:00", + "updated_at": "2026-07-14T10:00:00+09:00" + } +} +``` **에러 응답** @@ -94826,7 +95976,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 템플릿/확장/버전이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -94916,7 +96066,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -94948,11 +96098,59 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (LayoutResource)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `12` | 레이아웃 레코드 ID | +| template_id | integer | `1` | 소속 템플릿 ID | +| name | string | `main` | 레이아웃 이름 | +| description | string | `메인 화면` | 레이아웃 설명 (`content.meta.description`, 없으면 이름) | +| endpoint | string\|null | `null` | 레이아웃이 선언한 기본 엔드포인트 | +| route_path | string\|null | `null` | 라우트 path (상세 응답에서는 매핑 미주입 → null) | +| components | array | `[…]` | 레이아웃 컴포넌트 트리 | +| data_sources | array | `[…]` | 레이아웃 데이터소스 정의 | +| metadata | object | `{}` | 레이아웃 메타데이터 | +| content | string | `"{\n \"layout_name\": \"main\"\n}"` | 레이아웃 content JSON 문자열 (코드 편집기 표시 원문) | +| size | integer | `32` | content JSON 바이트 크기 | +| size_formatted | string | `32 B` | 사람이 읽는 크기 표기 | +| has_update | boolean | `false` | 템플릿 파일 대비 갱신 필요 여부 (현재 항상 false) | +| lock_version | integer | `0` | 낙관적 잠금 버전 | +| current_version | integer\|null | `null` | 현재 저장 버전 번호 (상세 응답에서는 null) | +| created_at | string | `2026-07-14T10:00:00+09:00` | 생성 일시 | +| updated_at | string | `2026-07-14T10:00:00+09:00` | 최종 수정 일시 | +| abilities | object | `{"can_update":true}` | 현재 사용자의 수행 가능 작업 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 12, + "template_id": 1, + "name": "main", + "description": "메인 화면", + "endpoint": null, + "route_path": null, + "components": [], + "data_sources": [], + "metadata": {}, + "content": "{\n \"layout_name\": \"main\"\n}", + "size": 32, + "size_formatted": "32 B", + "has_update": false, + "lock_version": 0, + "current_version": null, + "created_at": "2026-07-14T10:00:00+09:00", + "updated_at": "2026-07-14T10:00:00+09:00", + "abilities": { + "can_update": true + } + } +} +``` **에러 응답** @@ -94960,7 +96158,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿 또는 해당 이름의 레이아웃이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -94982,65 +96180,65 @@ Authorization: Bearer {YOUR_TOKEN} | name | path | string | 예 | — | 대상의 이름/명칭 | | expected_lock_version | body | integer | 예 | min 0 | 낙관적 잠금 버전 (동시 편집 충돌 감지) | | content | body | array | 예 | — | 본문 내용 | -| content.version | body | string | 예 | — | | +| content.version | body | string | 예 | — | 레이아웃 JSON 스키마 버전 (예: `1.0`) | | content.layout_name | body | string | 예 | max 255 | content.layout 이름 (식별자) | -| content.extends | body | string | 아니오 | — | | -| content.slots | body | array | 아니오 | — | | -| content.data_sources | body | array | 아니오 | — | | -| content.metadata | body | array | 아니오 | — | | -| content.meta | body | array | 아니오 | — | | -| content.meta.title | body | string | 아니오 | — | | -| content.meta.description | body | string | 아니오 | — | | -| content.meta.keywords | body | string | 아니오 | — | | -| content.meta.auth_required | body | boolean | 아니오 | — | | -| content.meta.is_base | body | boolean | 아니오 | — | | -| content.meta.guest_only | body | boolean | 아니오 | — | | -| content.meta.is_error_layout | body | boolean | 아니오 | — | | -| content.meta.error_code | body | integer | 아니오 | — | | -| content.meta.seo | body | array | 아니오 | — | | -| content.meta.seo.enabled | body | boolean | 아니오 | — | | -| content.meta.seo.data_sources | body | array | 아니오 | — | | -| content.meta.seo.priority | body | number | 아니오 | min 0, max 1 | | -| content.meta.seo.changefreq | body | string | 아니오 | `always`, `hourly`, `daily`, `weekly`, `monthly`, `yearly`, `never` | | -| content.meta.seo.og | body | array | 아니오 | — | | -| content.meta.seo.structured_data | body | array | 아니오 | — | | -| content.meta.seo.page_type | body | string | 아니오 | — | | -| content.meta.seo.toggle_setting | body | string | 아니오 | — | | -| content.meta.seo.vars | body | array | 아니오 | — | | -| content.meta.seo.extensions | body | array | 아니오 | — | | -| content.modals | body | array | 아니오 | — | | -| content.state | body | array | 아니오 | — | | -| content.init_actions | body | array | 아니오 | — | | -| content.defines | body | array | 아니오 | — | | -| content.init_state | body | array | 아니오 | — | | -| content.initLocal | body | array | 아니오 | — | | -| content.initGlobal | body | array | 아니오 | — | | -| content.initIsolated | body | array | 아니오 | — | | -| content.global_state | body | array | 아니오 | — | | -| content.errorHandling | body | array | 아니오 | — | | -| content.actions | body | array | 아니오 | — | | -| content.pageConfig | body | array | 아니오 | — | | -| content.schema | body | array | 아니오 | — | | -| content.routes | body | array | 아니오 | — | | -| content.computed | body | array | 아니오 | — | | -| content.named_actions | body | array | 아니오 | — | | -| content.permissions | body | string | 아니오 | — | | -| content.globalHeaders | body | array | 아니오 | — | | -| content.transition_overlay | body | string | 아니오 | — | | -| content.transition_overlay.enabled | body | boolean | 아니오 | — | | -| content.transition_overlay.style | body | string | 아니오 | — | | -| content.transition_overlay.target | body | string | 아니오 | max 100 | | -| content.transition_overlay.fallback_target | body | string | 아니오 | max 100 | | -| content.transition_overlay.skeleton | body | array | 아니오 | — | | -| content.transition_overlay.skeleton.component | body | string | 아니오 | max 100 | | -| content.transition_overlay.skeleton.animation | body | string | 아니오 | — | | -| content.transition_overlay.skeleton.iteration_count | body | integer | 아니오 | min 1, max 50 | | -| content.transition_overlay.spinner | body | array | 아니오 | — | | -| content.transition_overlay.spinner.component | body | string | 아니오 | max 100 | | -| content.transition_overlay.spinner.text | body | string | 아니오 | max 200 | | -| content.transition_overlay.wait_for | body | array | 아니오 | — | | -| content.endpoint | body | string | 아니오 | — | | -| content.components | body | array | 예 | — | | +| content.extends | body | string | 아니오 | — | 상속할 베이스 레이아웃 이름 | +| content.slots | body | array | 아니오 | — | 슬롯별 삽입 콘텐츠 맵 (베이스 레이아웃의 slot 위치에 주입) | +| content.data_sources | body | array | 아니오 | — | API 데이터 소스 정의 배열 (id/endpoint/method) | +| content.metadata | body | array | 아니오 | — | 레거시 메타데이터 맵 (`metadata` 키를 쓰던 구버전 레이아웃 호환) | +| content.meta | body | array | 아니오 | — | 레이아웃 메타 정보 객체 (title/description/auth_required/seo 등 하위 키) | +| content.meta.title | body | string | 아니오 | — | 제목 | +| content.meta.description | body | string | 아니오 | — | 설명 | +| content.meta.keywords | body | string | 아니오 | — | 검색 키워드 메타태그 값 (쉼표 구분 문자열) | +| content.meta.auth_required | body | boolean | 아니오 | — | 로그인 필수 화면 여부 (미인증 접근 시 로그인 리다이렉트) | +| content.meta.is_base | body | boolean | 아니오 | — | base 여부 | +| content.meta.guest_only | body | boolean | 아니오 | — | 비로그인 전용 화면 여부 (로그인 상태면 리다이렉트, SEO/sitemap 제외 판정에도 사용) | +| content.meta.is_error_layout | body | boolean | 아니오 | — | error layout 여부 | +| content.meta.error_code | body | integer | 아니오 | — | 에러 레이아웃일 때 담당하는 HTTP 상태 코드 (예: 404, 500) | +| content.meta.seo | body | array | 아니오 | — | SEO 메타데이터 객체 (봇 HTML 렌더/sitemap 생성기가 소비하는 하위 키 묶음) | +| content.meta.seo.enabled | body | boolean | 아니오 | — | 사용 여부 | +| content.meta.seo.data_sources | body | array | 아니오 | — | API 데이터 소스 정의 배열 (id/endpoint/method) | +| content.meta.seo.priority | body | number | 아니오 | min 0, max 1 | 우선순위 (작을수록 우선) | +| content.meta.seo.changefreq | body | string | 아니오 | `always`, `hourly`, `daily`, `weekly`, `monthly`, `yearly`, `never` | sitemap changefreq 값 (daily/weekly/monthly 등) | +| content.meta.seo.og | body | array | 아니오 | — | Open Graph 메타태그 정의 맵 | +| content.meta.seo.structured_data | body | array | 아니오 | — | JSON-LD 구조화 데이터 정의 | +| content.meta.seo.page_type | body | string | 아니오 | — | SEO 템플릿 키를 결정하는 페이지 유형 | +| content.meta.seo.toggle_setting | body | string | 아니오 | — | SEO 활성화 여부를 결정하는 설정 경로 | +| content.meta.seo.vars | body | array | 아니오 | — | SEO 변수 선언 맵 (데이터 소스 값의 표현식 매핑) | +| content.meta.seo.extensions | body | array | 아니오 | — | 이 화면의 SEO 기본값을 제공하는 확장 목록 (`[{type, id}]` — 모듈/플러그인 declaration 적용 대상) | +| content.modals | body | array | 아니오 | — | 모달 컴포넌트 정의 배열 | +| content.state | body | array | 아니오 | — | 레이아웃 레벨 초기 상태 정의 맵 | +| content.init_actions | body | array | 아니오 | — | 레이아웃 로드 시 실행할 초기화 액션 배열 | +| content.defines | body | array | 아니오 | — | 재사용 컴포넌트 정의 맵 (컴포넌트 트리에서 참조) | +| content.init_state | body | array | 아니오 | — | 초기 상태 값 맵 | +| content.initLocal | body | array | 아니오 | — | API 응답을 `_local` 상태에 자동 복사할 키/경로 | +| content.initGlobal | body | array | 아니오 | — | API 응답을 `_global` 상태에 자동 복사할 키/경로 | +| content.initIsolated | body | array | 아니오 | — | API 응답을 `_isolated` 상태에 자동 복사할 키/경로 | +| content.global_state | body | array | 아니오 | — | 전역 상태 초기값 맵 | +| content.errorHandling | body | array | 아니오 | — | 레이아웃 레벨 에러 핸들링 설정 (에러 코드별 핸들러 매핑) | +| content.actions | body | array | 아니오 | — | 레이아웃 레벨 재사용 액션 정의 (컴포넌트가 id 로 참조) | +| content.pageConfig | body | array | 아니오 | — | 페이지 단위 설정 객체 | +| content.schema | body | array | 아니오 | — | 플러그인 설정 레이아웃 전용 — 설정 항목 스키마 정의 | +| content.routes | body | array | 아니오 | — | 레이아웃이 선언하는 라우트 정의 배열 | +| content.computed | body | array | 아니오 | — | 계산된 값 정의 맵 (키 → 표현식) | +| content.named_actions | body | array | 아니오 | — | 이름으로 호출 가능한 재사용 액션 정의 맵 | +| content.permissions | body | string | 아니오 | — | 이 레이아웃 접근에 필요한 권한 (권한 식별자 배열 또는 OR/AND 구조) | +| content.globalHeaders | body | array | 아니오 | — | 전역 HTTP 헤더 규칙 배열 (pattern + headers) | +| content.transition_overlay | body | string | 아니오 | — | 페이지 전환 오버레이 설정 (스켈레톤/스피너) | +| content.transition_overlay.enabled | body | boolean | 아니오 | — | 사용 여부 | +| content.transition_overlay.style | body | string | 아니오 | — | 전환 오버레이 표시 방식 (`opaque`, `blur`, `fade`, `skeleton`, `spinner`) | +| content.transition_overlay.target | body | string | 아니오 | max 100 | 오버레이를 씌울 대상 컴포넌트 id | +| content.transition_overlay.fallback_target | body | string | 아니오 | max 100 | target 을 찾지 못했을 때 대체로 씌울 컴포넌트 id | +| content.transition_overlay.skeleton | body | array | 아니오 | — | 스켈레톤 오버레이 설정 객체 (component/animation/iteration_count) | +| content.transition_overlay.skeleton.component | body | string | 아니오 | max 100 | 스켈레톤으로 반복 렌더할 컴포넌트 이름 (skeleton 지정 시 필수) | +| content.transition_overlay.skeleton.animation | body | string | 아니오 | — | 스켈레톤 애니메이션 (`pulse`, `wave`, `none`) | +| content.transition_overlay.skeleton.iteration_count | body | integer | 아니오 | min 1, max 50 | 스켈레톤 반복 렌더 개수 (1~50) | +| content.transition_overlay.spinner | body | array | 아니오 | — | 스피너 오버레이 설정 객체 (component/text) | +| content.transition_overlay.spinner.component | body | string | 아니오 | max 100 | 스피너로 렌더할 컴포넌트 이름 | +| content.transition_overlay.spinner.text | body | string | 아니오 | max 200 | 스피너와 함께 표시할 안내 문구 | +| content.transition_overlay.wait_for | body | array | 아니오 | — | 오버레이를 유지할 데이터소스 id 배열 (progressive/blocking 데이터소스 fetch 완료까지 표시) | +| content.endpoint | body | string | 아니오 | — | 이 화면이 주로 조회하는 데이터 API 경로 (레거시 최상위 필드 — 신규 레이아웃은 data_sources 사용) | +| content.components | body | array | 예 | — | 컴포넌트 트리 배열 (레이아웃이 렌더할 컴포넌트 정의) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.layout.update_content_validation_rules`). @@ -95184,11 +96382,59 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 LayoutResource)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `12` | 레이아웃 레코드 ID | +| template_id | integer | `1` | 소속 템플릿 ID | +| name | string | `main` | 레이아웃 이름 | +| description | string | `메인 화면` | 레이아웃 설명 (`content.meta.description`) | +| endpoint | string\|null | `null` | 레이아웃이 선언한 기본 엔드포인트 | +| route_path | string\|null | `null` | 라우트 path (저장 응답에서는 매핑 미주입 → null) | +| components | array | `[…]` | 저장된 컴포넌트 트리 | +| data_sources | array | `[…]` | 저장된 데이터소스 정의 | +| metadata | object | `{}` | 레이아웃 메타데이터 | +| content | string | `"{\n \"layout_name\": \"main\"\n}"` | 저장된 content JSON 문자열 | +| size | integer | `32` | content JSON 바이트 크기 | +| size_formatted | string | `32 B` | 사람이 읽는 크기 표기 | +| has_update | boolean | `false` | 템플릿 파일 대비 갱신 필요 여부 (현재 항상 false) | +| lock_version | integer | `1` | 증가된 낙관적 잠금 버전 (다음 저장 시 `expected_lock_version` 으로 전달) | +| current_version | integer\|null | `3` | 이번 저장으로 기록된 최신 버전 번호 | +| created_at | string | `2026-07-14T10:00:00+09:00` | 생성 일시 | +| updated_at | string | `2026-07-14T11:00:00+09:00` | 최종 수정 일시 | +| abilities | object | `{"can_update":true}` | 현재 사용자의 수행 가능 작업 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 12, + "template_id": 1, + "name": "main", + "description": "메인 화면", + "endpoint": null, + "route_path": null, + "components": [], + "data_sources": [], + "metadata": {}, + "content": "{\n \"layout_name\": \"main\"\n}", + "size": 32, + "size_formatted": "32 B", + "has_update": false, + "lock_version": 1, + "current_version": 3, + "created_at": "2026-07-14T10:00:00+09:00", + "updated_at": "2026-07-14T11:00:00+09:00", + "abilities": { + "can_update": true + } + } +} +``` **에러 응답** @@ -95196,8 +96442,10 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 | +| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) | +| 422 | Unprocessable Entity | content 구조 검증 위반 (레이아웃 구조/슬롯/데이터소스 병합/엔드포인트 화이트리스트/외부 URL 차단/권한 구조 규칙) | +| 500 | Server Error | 저장 트랜잭션 실패 | @@ -95236,11 +96484,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| token | string | `9f2c1a4e8b7d3c5f` | 미리보기 토큰 (임시 저장된 레이아웃 content 를 식별) | +| preview_url | string | `/preview/9f2c1a4e8b7d3c5f` | 미리보기 페이지 경로 (새 탭으로 열어 확인) | +| expires_at | string | `2026-07-14T11:00:00+09:00` | 미리보기 만료 일시 (ISO 8601) | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "token": "9f2c1a4e8b7d3c5f", + "preview_url": "/preview/9f2c1a4e8b7d3c5f", + "expires_at": "2026-07-14T11:00:00+09:00" + } +} +``` **에러 응답** @@ -95248,8 +96512,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 미리보기 생성 실패 | @@ -95283,11 +96548,49 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_목록 응답: `data` 는 버전 이력 배열입니다 (페이지네이션 없음. 목록에는 `full_content` 미포함)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `31` | 버전 레코드 ID (복원 시 path 파라미터 `versionId` 로 사용) | +| layout_id | integer | `12` | 소속 레이아웃 ID | +| version | integer | `3` | 버전 번호 | +| endpoint | string\|null | `null` | 그 버전 content 의 `endpoint` 값 | +| components | array | `[…]` | 그 버전의 컴포넌트 트리 | +| data_sources | array | `[…]` | 그 버전의 데이터소스 정의 | +| metadata | object | `{}` | 그 버전의 메타데이터 | +| changes_summary | object | `{"added_count":5,"removed_count":2,"char_diff":180}` | 직전 버전 대비 변경 요약 (added_count, removed_count, char_diff) | +| created_by_name | string\|null | `관리자` | 저장자 이름 (탈퇴 사용자/관계 미로딩 시 null — 사용자 ID 는 미노출) | +| created_at | string | `2026-07-14T11:00:00+09:00` | 버전 저장 일시 | +| updated_at | string | `2026-07-14T11:00:00+09:00` | 레코드 갱신 일시 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": [ + { + "id": 31, + "layout_id": 12, + "version": 3, + "endpoint": null, + "components": [], + "data_sources": [], + "metadata": {}, + "changes_summary": { + "added_count": 5, + "removed_count": 2, + "char_diff": 180 + }, + "created_by_name": "관리자", + "created_at": "2026-07-14T11:00:00+09:00", + "updated_at": "2026-07-14T11:00:00+09:00" + } + ] +} +``` **에러 응답** @@ -95295,8 +96598,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿 또는 레이아웃이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -95329,11 +96631,47 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (복원 결과로 새로 기록된 버전)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `33` | 복원으로 새로 생성된 버전 레코드 ID | +| layout_id | integer | `12` | 소속 레이아웃 ID | +| version | integer | `4` | 새 버전 번호 (복원도 새 버전으로 기록) | +| endpoint | string\|null | `null` | 복원된 content 의 `endpoint` 값 | +| components | array | `[…]` | 복원된 컴포넌트 트리 | +| data_sources | array | `[…]` | 복원된 데이터소스 정의 | +| metadata | object | `{}` | 복원된 메타데이터 | +| changes_summary | object | `{"added_count":2,"removed_count":5,"char_diff":-180}` | 직전 버전 대비 변경 요약 | +| created_by_name | string\|null | `관리자` | 복원 수행자 이름 | +| created_at | string | `2026-07-14T12:00:00+09:00` | 복원 일시 | +| updated_at | string | `2026-07-14T12:00:00+09:00` | 레코드 갱신 일시 | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 33, + "layout_id": 12, + "version": 4, + "endpoint": null, + "components": [], + "data_sources": [], + "metadata": {}, + "changes_summary": { + "added_count": 2, + "removed_count": 5, + "char_diff": -180 + }, + "created_by_name": "관리자", + "created_at": "2026-07-14T12:00:00+09:00", + "updated_at": "2026-07-14T12:00:00+09:00" + } +} +``` **에러 응답** @@ -95341,8 +96679,9 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿·레이아웃·버전이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 복원 트랜잭션 실패 | @@ -95374,11 +96713,53 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (LayoutVersionResource + `full_content` — 단건 조회에서만 원본 전체 노출)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `31` | 버전 레코드 ID | +| layout_id | integer | `12` | 소속 레이아웃 ID | +| version | integer | `3` | 버전 번호 (path 파라미터 `version` 과 일치) | +| endpoint | string\|null | `null` | 그 버전 content 의 `endpoint` 값 | +| components | array | `[…]` | 그 버전의 컴포넌트 트리 | +| data_sources | array | `[…]` | 그 버전의 데이터소스 정의 | +| metadata | object | `{}` | 그 버전의 메타데이터 | +| changes_summary | object | `{"added_count":5,"removed_count":2,"char_diff":180}` | 직전 버전 대비 변경 요약 | +| created_by_name | string\|null | `관리자` | 저장자 이름 (탈퇴 사용자/관계 미로딩 시 null) | +| created_at | string | `2026-07-14T11:00:00+09:00` | 버전 저장 일시 | +| updated_at | string | `2026-07-14T11:00:00+09:00` | 레코드 갱신 일시 | +| full_content | object | `{"layout_name":"main","components":[…]}` | 그 버전의 content 원본 전체 (slots/extends 등 분해되지 않는 키 포함 — 버전 비교 diff 용. 목록 응답에는 미포함) | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 31, + "layout_id": 12, + "version": 3, + "endpoint": null, + "components": [], + "data_sources": [], + "metadata": {}, + "changes_summary": { + "added_count": 5, + "removed_count": 2, + "char_diff": 180 + }, + "created_by_name": "관리자", + "created_at": "2026-07-14T11:00:00+09:00", + "updated_at": "2026-07-14T11:00:00+09:00", + "full_content": { + "version": "1.0", + "layout_name": "main", + "components": [] + } + } +} +``` **에러 응답** @@ -95386,7 +96767,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 대상 템플릿·레이아웃·버전이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -95417,11 +96798,30 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| extension_directory | object\|null | `{"path":"templates/sirsoft-basic", …}` | 제거 시 삭제될 템플릿 설치 디렉토리 정보 (디렉토리가 없으면 null) | +| extension_directory.path | string | `templates/sirsoft-basic` | 삭제 대상 디렉토리 경로 (프로젝트 루트 기준) | +| extension_directory.size_bytes | integer | `5242880` | 디렉토리 전체 용량 (바이트) | +| extension_directory.size_formatted | string | `5.0 MB` | 사람이 읽는 용량 표기 | **응답 예시** - +```json +{ + "success": true, + "message": "템플릿 정보를 성공적으로 가져왔습니다.", + "data": { + "extension_directory": { + "path": "templates/sirsoft-basic", + "size_bytes": 5242880, + "size_formatted": "5.0 MB" + } + } +} +``` **에러 응답** @@ -95429,8 +96829,9 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해당 식별자의 템플릿이 없는 경우 (`템플릿을 찾을 수 없습니다.`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 삭제 정보 조회 실패 | @@ -95470,11 +96871,73 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (업데이트 후 TemplateResource)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-basic` | 템플릿 식별자 | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `Basic` | 템플릿 이름 (현재 로케일 값) | +| version | string | `1.0.1` | 업데이트 후 버전 | +| type | string | `user` | 템플릿 타입 (`admin` \| `user`) | +| status | string | `active` | 업데이트 후 상태 (업데이트 전 상태로 복원됨) | +| description | string | `그누보드7 기본 사용자 템플릿` | 템플릿 설명 | +| dependencies | object | `{"modules":[],"plugins":[]}` | 의존 확장 요약 | +| dependencies_met | boolean | `true` | 의존 확장 충족 여부 | +| update_available | boolean | `false` | 업데이트 가능 여부 (업데이트 직후 false) | +| update_source | string\|null | `github` | 업데이트를 가져온 출처 (`github` \| `bundled`) | +| latest_version | string\|null | `1.0.1` | 감지된 최신 배포 버전 | +| file_version | string\|null | `1.0.1` | 설치된 파일의 manifest 버전 | +| github_url | string\|null | `https://github.com/gnuboard/g7-template-basic` | GitHub 저장소 URL | +| github_changelog_url | string\|null | `https://github.com/gnuboard/g7-template-basic/blob/main/CHANGELOG.md` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | `_pending` 대기 여부 | +| is_bundled | boolean | `true` | 번들 확장 여부 | +| deactivated_reason | string\|null | `null` | 비활성화 사유 | +| deactivated_at | string\|null | `null` | 비활성화 일시 | +| incompatible_required_version | string\|null | `null` | 코어 버전 미충족 시 요구 버전 | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":true,"can_edit_layouts":true}` | 현재 사용자의 수행 가능 작업 맵 | + +_업데이트할 내용이 없거나 템플릿 정보를 다시 읽지 못한 경우에는 리소스 대신 결과 요약(`success`, `from_version`, `to_version`, `message`)이 `data` 로 반환됩니다._ **응답 예시** - +```json +{ + "success": true, + "message": "템플릿 \"sirsoft-basic\"이(가) 1.0.1 버전으로 업데이트되었습니다.", + "data": { + "identifier": "sirsoft-basic", + "vendor": "sirsoft", + "name": "Basic", + "version": "1.0.1", + "type": "user", + "status": "active", + "description": "그누보드7 기본 사용자 템플릿", + "dependencies": { + "modules": [], + "plugins": [] + }, + "dependencies_met": true, + "update_available": false, + "update_source": "github", + "latest_version": "1.0.1", + "file_version": "1.0.1", + "github_url": "https://github.com/gnuboard/g7-template-basic", + "github_changelog_url": "https://github.com/gnuboard/g7-template-basic/blob/main/CHANGELOG.md", + "is_pending": false, + "is_bundled": true, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true, + "can_edit_layouts": true + } + } +} +``` **에러 응답** @@ -95483,7 +96946,8 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 업데이트 실패 (미설치, 다운그레이드 차단, 다운로드 실패, 코어 버전 비호환 등 — `errors.template_name` 에 번역된 사유) | +| 500 | Server Error | 업데이트 처리 중 예기치 못한 오류 | @@ -95554,20 +97018,27 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다. 본문은 요청한 정적 파일의 원본 바이트이며, 파일의 MIME 타입(`Content-Type`), `ETag`, 장기 캐싱 헤더(`Cache-Control` max-age 31536000)가 함께 반환됩니다. 실패 시에만 JSON 에러 봉투를 반환합니다._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: text/css; charset=UTF-8 +Cache-Control: public, max-age=31536000 +ETag: "9f2c1a4e8b7d3c5f" + +(정적 자산 파일의 원본 바이트) +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 허용되지 않은 파일 유형을 요청한 경우 (`허용되지 않은 파일 타입입니다.`) | +| 404 | Not Found | 템플릿이 없거나 요청한 파일이 존재하지 않는 경우 | +| 422 | Unprocessable Entity | 경로 파라미터가 보안 검증을 위반한 경우 (디렉토리 트래버설 등) | +| 500 | Server Error | 파일 조회 중 알 수 없는 오류 (`알 수 없는 오류가 발생했습니다.`) | @@ -95648,7 +97119,21 @@ Accept: application/json **응답 필드** (`data` 내부) - + + +_이 엔드포인트는 `success`/`message`/`data` 봉투를 사용하지 않습니다. 본문은 템플릿 `components.json` 파일의 내용을 그대로 반환한 JSON 이며 1시간 캐싱 헤더가 붙습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| $schema | string | `https://json-schema.org/draft/2020-12/schema` | components.json 이 따르는 JSON Schema URI | +| templateId | string | `sirsoft-admin_basic` | 이 컴포넌트 정의가 속한 템플릿 식별자 | +| version | string | `1.0.0` | 컴포넌트 정의 버전 | +| components | object | `{"basic":[…],"composite":[…]}` | 컴포넌트 정의 묶음 (`basic`: HTML 래핑 기본 컴포넌트, `composite`: 집합 컴포넌트) | +| components.basic[].name | string | `Button` | 컴포넌트 이름 (레이아웃 JSON 의 `name` 값) | +| components.basic[].type | string | `basic` | 컴포넌트 종류 (`basic` \| `composite`) | +| components.basic[].description | string | `HTML button element wrapper` | 컴포넌트 설명 | +| components.basic[].path | string | `src/components/basic/Button.tsx` | 템플릿 내 소스 파일 경로 | +| components.basic[].props | object | `{"variant":{"type":"string", …}}` | 지원 props 정의 맵 (각 항목: type, required, default, description) | **응답 예시** @@ -95696,24 +97181,24 @@ _단건 응답: `data` 객체의 필드._ | --- | --- | --- | --- | | identifier | string | `sirsoft-admin_basic` | 템플릿 고유 식별자 (vendor-name 형식, 예: sirsoft-admin_basic) | | vendor | string | `sirsoft` | 벤더/개발자명 (예: sirsoft) | -| name | object | `{"ko":"Admin Basic","en":"Admin Basic"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| version | string | `1.0.4` | 템플릿 버전 (예: 1.0.0) | -| license | string | `MIT` | | -| description | object | `{"ko":"그누보드7 기본 관리자 템플릿","en":"Gnuboard7 default admin te…` | 설명 (다국어 필드는 로케일별 값 객체) | +| name | object | `{"ko":"Admin Basic","en":"Admin Basic"}` | 템플릿 이름 (다국어 JSON) | +| version | string | `1.0.0` | 템플릿 버전 (예: 1.0.0) | +| license | string | `MIT` | 라이선스 식별자 (manifest license) | +| description | object | `{"ko":"그누보드7 기본 관리자 템플릿","en":"Gnuboard7 default admin te…` | 템플릿 설명 (다국어 JSON) | | type | string | `admin` | 템플릿 타입 (admin: 관리자용, user: 사용자용) | | locales | array | `["ko","en"]` | 활성 로케일 코드 배열 | | author | object | `{"name":"sirsoft","email":"contact@sirsoft.com","url":"ht…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | -| release_date | string | `2026-05-15` | | -| g7_version | string | `>=7.0.6` | | +| release_date | string | `2026-05-15` | 배포일 | +| g7_version | string | `>=7.0.0` | 요구하는 코어 최소 버전 (manifest requires.g7_version) | | dependencies | object | `{"modules":[],"plugins":[]}` | 의존하는 확장 맵 (manifest 파생 — {modules, plugins}) | | assets | object | `{"css":["assets\/css\/main.css","assets\/css\/ui-system.c…` | 프론트엔드 에셋 매니페스트 (manifest 파생 — js/css 진입점·로딩 전략) | -| components | object | `{"basic":["a","button","checkbox","div","form","h1","h2",…` | | -| preview | object | `{"thumbnail":"preview\/thumbnail.png","screenshots":["pre…` | | -| error_config | object | `{"layouts":{"401":"errors\/401","403":"errors\/403","404"…` | | -| github_url | string | `https://github.com/gnuboard/g7-templa…` | GitHub 저장소 URL (manifest 파생) | -| github_changelog_url | string | `https://github.com/gnuboard/g7-templa…` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) | -| externals | array | `[{"id":"fontawesome","type":"style","url":"https:\/\/cdnj…` | | -| cache_version | integer | `1785848038` | | +| components | object | `{"basic":["a","button","checkbox","div","form","h1","h2",…` | 컴포넌트 트리 (레이아웃이 렌더할 컴포넌트 정의) | +| preview | object | `{"thumbnail":"preview\/thumbnail.png","screenshots":["pre…` | 미리보기 정보 객체 | +| error_config | object | `{"layouts":{"401":"errors\/401","403":"errors\/403","404"…` | 에러 표시 설정 객체 | +| github_url | string | `https://github.com/gnuboard/g7-templa…` | GitHub 저장소 URL | +| github_changelog_url | string | `https://github.com/gnuboard/g7-templa…` | GitHub 변경 내역 URL | +| externals | array | `[{"id":"fontawesome","type":"style","url":"https:\/\/cdnj…` | 외부 의존 라이브러리 목록 (번들에서 제외되고 전역에서 해석) | +| cache_version | integer | `1784000969` | 에셋 캐시 무효화 버전 (번들 URL 파일명에 포함) | **응답 예시** @@ -187824,20 +189309,32 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `success`/`message`/`data` 봉투를 사용하지 않습니다. 본문은 템플릿 `lang/{locale}.json` 에 활성 모듈의 다국어 데이터를 병합한 JSON 을 그대로 반환하며(1시간 캐싱), 고정 필드 스키마가 없습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| (번역 키) | string\|object | `{"common":{"save":"저장"}}` | 템플릿 + 활성 모듈이 선언한 번역 키와 번역문 (중첩 객체 허용, 키 집합은 템플릿/설치 모듈 구성에 따라 다름) | **응답 예시** - +```json +{ + "common": { + "save": "저장", + "cancel": "취소" + }, + "sirsoft-board": { + "write": "글쓰기" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 템플릿이 없거나, 그 템플릿이 해당 로케일을 지원하지 않거나, 언어 파일이 없는 경우 | +| 500 | Server Error | 언어 파일 JSON 파싱 실패 (`유효하지 않은 JSON 형식입니다.`) 또는 알 수 없는 오류 | @@ -187867,20 +189364,24 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다. 본문은 첨부 파일의 원본 바이트이며, 첨부의 MIME 타입(`Content-Type`), `ETag`, 캐싱 헤더(레이아웃 캐시 TTL — 기본 24시간)와 함께 인라인 스트림됩니다. 실패 시에만 JSON 에러 봉투를 반환합니다._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: image/png +Cache-Control: public, max-age=86400 +ETag: "9f2c1a4e8b7d3c5f" + +(첨부 이미지 파일의 원본 바이트) +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 첨부가 없거나, 그 첨부가 경로의 템플릿 소속이 아니거나, 스토리지에 실제 파일이 없는 경우 (`첨부 파일을 찾을 수 없습니다.`) | @@ -187914,7 +189415,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | version | string | `1.0.0` | 템플릿 버전 (예: 1.0.0) | -| routes | array | `[{"path":"*\/admin","redirect":"\/admin\/dashboard","auth…` | | +| routes | array | `[{"path":"*\/admin","redirect":"\/admin\/dashboard","auth…` | 라우트 정의 목록 | **응답 예시** diff --git a/docs/backend/api/users.md b/docs/backend/api/users.md index 664a6795..eaa18e7e 100644 --- a/docs/backend/api/users.md +++ b/docs/backend/api/users.md @@ -293,11 +293,85 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 사용자 — `UserResource::toArray()`, 관계 미로드이므로 modules/plugins/menus/roles/permissions/consents 키는 응답에 포함되지 않음)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| nickname | string\|null | `null` | 닉네임 | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| avatar | string\|null | `null` | 프로필 아바타 이미지 URL (미설정 시 null) | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: English) | +| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (`user.language.{code}` 번역) | +| country | string\|null | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (UserStatus Enum: active/inactive/blocked/withdrawn) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (UserStatus::label()) | +| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (UserStatus::variant() — UI 배지용) | +| is_admin | boolean | `false` | 관리자 역할 보유 여부 (User::isAdmin()) | +| homepage | string\|null | `null` | 홈페이지 URL | +| mobile | string\|null | `null` | 휴대폰 번호 | +| phone | string\|null | `null` | 전화번호 | +| zipcode | string\|null | `null` | 우편번호 | +| address | string\|null | `null` | 기본 주소 | +| address_detail | string\|null | `null` | 상세 주소 | +| signature | string\|null | `null` | 서명 | +| bio | string\|null | `null` | 자기소개 | +| last_login_at | string\|null | `null` | 마지막 로그인 일시 (신규 생성 시 null) | +| email_verified_at | string\|null | `null` | 이메일 인증 일시 (미인증 시 null) | +| timezone | string\|null | `Asia/Seoul` | 사용자 시간대 | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 10:41:24` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can_delete":true,"can_assign_roles":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (슈퍼관리자 대상은 can_delete=false 강제) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "사용자가 성공적으로 생성되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "예시 이름", + "nickname": null, + "email": "user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": false, + "homepage": null, + "mobile": "010-1234-5678", + "phone": null, + "zipcode": null, + "address": null, + "address_detail": null, + "signature": null, + "bio": null, + "last_login_at": null, + "email_verified_at": null, + "timezone": "Asia/Seoul", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "is_owner": false, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + } +} +``` **에러 응답** @@ -306,6 +380,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 사용자 생성 중 예외 발생 (`user.create_failed`, `errors.error` 에 예외 메시지) | @@ -348,11 +423,29 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (UserService::bulkUpdateStatus() 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 상태가 변경된 사용자 수 (요청자 본인 제외 규칙 적용 후 갱신된 행 수) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count명의 사용자 상태가 변경되었습니다.", + "data": { + "updated_count": 3 + } +} +``` + +> `user.bulk_status_updated` 메시지는 `:count` 치환자를 갖지만 컨트롤러가 messageParams 를 넘기지 않으므로 응답 message 에는 치환되지 않은 원문이 그대로 담긴다. 실제 변경 건수는 `data.updated_count` 로 판단한다. **에러 응답** @@ -360,7 +453,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 예: 요청자 본인 UUID 포함 시 `ExcludeCurrentUser` 위반) | +| 500 | Internal Server Error | 일괄 변경 중 예외 발생 (`user.bulk_update_status_failed`) | @@ -401,11 +495,29 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| available | boolean | `true` | 해당 이메일의 사용 가능 여부 (true: 사용 가능, false: 이미 사용 중). `exclude_user_id` 로 지정한 사용자가 소유한 이메일이면 true 로 판정 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용 가능한 이메일입니다.", + "data": { + "available": true + } +} +``` + +> 이미 사용 중인 이메일이면 동일한 200 응답에 `message` 가 `이미 사용 중인 이메일입니다.` (`user.email_unavailable`), `data.available` 이 `false` 로 반환된다. **에러 응답** @@ -413,7 +525,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 이메일 형식/필수, exclude_user_id UUID 형식·존재 여부) | +| 500 | Internal Server Error | 중복 확인 중 예외 발생 (`user.email_check_failed`) | @@ -695,11 +808,87 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_목록 응답: `data[]` 배열 항목의 필드 (페이지네이션 없는 단순 컬렉션 — `UserResource::collection()`, 관계 미로드이므로 modules/plugins/menus/roles/permissions/consents 키는 포함되지 않음). 일치하는 사용자가 없으면 `data` 는 빈 배열._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| nickname | string\|null | `null` | 닉네임 | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| avatar | string\|null | `null` | 프로필 아바타 이미지 URL (미설정 시 null) | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: English) | +| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (`user.language.{code}` 번역) | +| country | string\|null | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (UserStatus Enum: active/inactive/blocked/withdrawn) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (UserStatus::label()) | +| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (UserStatus::variant() — UI 배지용) | +| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin()) | +| homepage | string\|null | `null` | 홈페이지 URL | +| mobile | string\|null | `null` | 휴대폰 번호 | +| phone | string\|null | `null` | 전화번호 | +| zipcode | string\|null | `null` | 우편번호 | +| address | string\|null | `null` | 기본 주소 | +| address_detail | string\|null | `null` | 상세 주소 | +| signature | string\|null | `null` | 서명 | +| bio | string\|null | `null` | 자기소개 | +| last_login_at | string\|null | `2026-07-07 10:41:24` | 마지막 로그인 일시 (미로그인 시 null) | +| email_verified_at | string\|null | `2026-07-08 10:41:24` | 이메일 인증 일시 (미인증 시 null) | +| timezone | string\|null | `Asia/Seoul` | 사용자 시간대 | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 10:41:24` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can_delete":true,"can_assign_roles":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용자 검색을 성공적으로 완료했습니다.", + "data": [ + { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": null, + "email": "apidoc-sample-user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": true, + "homepage": null, + "mobile": null, + "phone": null, + "zipcode": null, + "address": null, + "address_detail": null, + "signature": null, + "bio": null, + "last_login_at": "2026-07-07 10:41:24", + "email_verified_at": "2026-07-08 10:41:24", + "timezone": "Asia/Seoul", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + } + ] +} +``` **에러 응답** @@ -707,7 +896,8 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — `keyword`/`uuid` 를 모두 생략한 경우 포함) | +| 500 | Internal Server Error | 검색 중 예외 발생 (`user.search_failed`) | @@ -809,7 +999,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** @@ -1151,11 +1341,85 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 사용자 — `UserResource::toArray()`, 관계 미로드이므로 modules/plugins/menus/roles/permissions/consents 키는 응답에 포함되지 않음)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| nickname | string\|null | `null` | 닉네임 | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| avatar | string\|null | `null` | 프로필 아바타 이미지 URL (미설정 시 null) | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: English) | +| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (`user.language.{code}` 번역) | +| country | string\|null | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (UserStatus Enum: active/inactive/blocked/withdrawn) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (UserStatus::label()) | +| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (UserStatus::variant() — UI 배지용) | +| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin()) | +| homepage | string\|null | `https://example.com` | 홈페이지 URL | +| mobile | string\|null | `010-1234-5678` | 휴대폰 번호 | +| phone | string\|null | `null` | 전화번호 | +| zipcode | string\|null | `null` | 우편번호 | +| address | string\|null | `null` | 기본 주소 | +| address_detail | string\|null | `null` | 상세 주소 | +| signature | string\|null | `null` | 서명 | +| bio | string\|null | `null` | 자기소개 | +| last_login_at | string\|null | `2026-07-07 10:41:24` | 마지막 로그인 일시 (미로그인 시 null) | +| email_verified_at | string\|null | `2026-07-08 10:41:24` | 이메일 인증 일시 (미인증 시 null) | +| timezone | string\|null | `Asia/Seoul` | 사용자 시간대 | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 11:02:07` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can_delete":true,"can_assign_roles":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (슈퍼관리자 대상은 can_delete=false 강제) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용자 정보가 성공적으로 업데이트되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "예시 이름", + "nickname": null, + "email": "user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": false, + "homepage": "https://example.com", + "mobile": "010-1234-5678", + "phone": null, + "zipcode": null, + "address": null, + "address_detail": null, + "signature": null, + "bio": null, + "last_login_at": null, + "email_verified_at": null, + "timezone": "Asia/Seoul", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 11:02:07", + "is_owner": false, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + } +} +``` **에러 응답** @@ -1164,7 +1428,8 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 마지막 관리자 본인의 admin 역할 제거 시도 시 `user.last_admin_role_cannot_remove` 포함) | +| 500 | Internal Server Error | 수정 중 예외 발생 (`user.update_failed`, `errors.error` 에 예외 메시지) | diff --git a/docs/backend/api/verify-password.md b/docs/backend/api/verify-password.md index 7828bf6d..740cd234 100644 --- a/docs/backend/api/verify-password.md +++ b/docs/backend/api/verify-password.md @@ -45,18 +45,25 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `ResponseHelper::success('user.password_verified')` 를 인자 없이 호출하므로 `data` 는 `null` 입니다)._ **응답 예시** - +```json +{ + "success": true, + "message": "비밀번호가 확인되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 401 | 비밀번호 불일치 | 전달한 `password` 가 저장된 해시와 일치하지 않는 경우 (`message`: 비밀번호가 일치하지 않습니다.) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | 비밀번호 확인 실패 | 확인 처리 중 예기치 못한 예외가 발생한 경우 (`message`: 비밀번호 확인에 실패했습니다.) | diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index c5235753..e95621d5 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -11,6 +11,8 @@ - 예약 작업에 등록할 수 없는 Artisan 명령·옵션을 안내하는 문구 일본어 번역 추가 — 왜 등록이 거부됐는지(허용 목록 밖 / 형식 오류 / 허용되지 않은 옵션 / 추가 인자 불가)가 사유별로 표시됩니다. - 언어팩 설치 시 거부 사유를 안내하는 문구 일본어 번역 추가 — 허용되지 않는 파일 형식, 번역 배열이 아닌 PHP 파일(줄 번호 포함), 심볼릭 링크, 그리고 설치 직후 활성화에 필요한 권한 안내입니다. - 환경설정 고급 탭의 목록 상한 항목명(총 건수 집계 상한·페이지 번호 상한) 일본어 번역 추가 — 값이 범위를 벗어났을 때의 안내에 내부 식별자 대신 항목명이 표시됩니다. +- 공통 응답 메시지 일본어 번역 추가 (`common.created`·`common.updated`·`common.deleted`) — 생성·수정·삭제 성공 응답이 일본어 로케일에서 번역문으로 표시됩니다. +- 본인인증 정책 삭제 제한 안내 메시지 일본어 번역 추가 (`identity.errors.cannot_delete_system_policy`) — 시스템이 선언한 정책은 삭제할 수 없다는 안내가 일본어 로케일에서 자연스럽게 표시됩니다. ### Changed diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/common.php b/lang-packs/_bundled/g7-core-ja/backend/ja/common.php index b98a0f1b..24336875 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/common.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/common.php @@ -30,4 +30,7 @@ return [ 'version_format' => ':attributeのフォーマットが正しくありません。(例:1.0.0、1.0.0-beta.1)', 'to_version_required' => '開始バージョンが指定されている場合、終了バージョンも必須です。', ], + 'created' => '作成されました。', + 'updated' => '編集されました。', + 'deleted' => '削除されました。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php b/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php index ee10002c..3d1546a3 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php @@ -29,6 +29,7 @@ return [ 'missing_scope_or_target' => 'ポリシー照会には scope と target の両方が必要です。', 'admin_policy_has_no_default' => '管理者が直接作成したポリシーには宣言デフォルト値がありません。', 'reset_field_failed' => '宣言デフォルト値の復元に失敗しました。フィールドが有効であることを確認してください。', + 'cannot_delete_system_policy' => 'システムが宣言したポリシーは削除できません。管理者が直接作成したポリシーのみ削除できます。', ], 'messages' => [ 'challenge_requested' => '本人認証コードを送信しました。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 134cca0b..709a4d84 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -14,6 +14,7 @@ - 주문 상세의 현금영수증 카드 문구(발급 상태 안내, 발급·발급취소·재발급 버튼, 발급 이력 표) 일본어 번역 추가 — 구매자와 관리자가 보는 현금영수증 영역이 일본어 로케일에서 자연스럽게 표시됩니다. - 환불 계좌 입력 안내와 검증 문구(`validation.order.refund_bank_*`) 일본어 번역 추가 — 주문서와 관리자 취소 화면의 환불 계좌 입력란이 일본어 로케일에서 자연스럽게 표시됩니다. - 현금영수증 자진발급 설정의 국세청 의무발행 업종 안내 링크 문구(`order_settings.cash_receipt.self_issue_mandatory_link`) 일본어 번역 추가 — 자진발급을 켤지 판단할 때 참고하는 국세청 안내 링크가 일본어 로케일에서 자연스럽게 표시됩니다. +- 카테고리 이미지 처리 결과 안내 문구(`category_images.*` — 업로드·삭제·순서 변경·이미지 없음) 일본어 번역 추가 — 관리자가 카테고리 이미지를 등록·삭제·정렬할 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. ## [1.0.8] - 2026-08-01 diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php index 903f22e2..2ec657fd 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php @@ -15,12 +15,6 @@ return [ 'status_changed' => 'カテゴリの状態が変更されました。', 'order_updated' => 'カテゴリの順序が変更されました。', ], - 'category_images' => [ - 'uploaded' => 'カテゴリ画像がアップロードされました。', - 'not_found' => 'カテゴリ画像が見つかりません。', - 'deleted' => 'カテゴリ画像が削除されました。', - 'reordered' => 'カテゴリ画像の順序が変更されました。', - ], 'settings' => [ 'fetch_success' => '設定を閲覧しました。', 'fetch_failed' => '設定の取得に失敗しました。', @@ -79,6 +73,12 @@ return [ 'thumbnail_set' => 'メイン画像が設定されました。', 'not_found' => '商品画像が見つかりません。', ], + 'category_images' => [ + 'uploaded' => '画像がアップロードされました。', + 'deleted' => '画像が削除されました。', + 'reordered' => '画像の順序が変更されました。', + 'not_found' => 'カテゴリー画像が見つかりません。', + ], 'brands' => [ 'list_retrieved' => 'ブランド一覧を閲覧しました。', 'retrieved' => 'ブランド情報を閲覧しました。', diff --git a/lang/en/common.php b/lang/en/common.php index e222f65b..79bd3b3b 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -9,6 +9,9 @@ return [ 'unauthorized' => 'Authentication is required.', 'forbidden' => 'Access denied.', 'validation_failed' => 'Input validation failed.', + 'created' => 'Created successfully.', + 'updated' => 'Updated successfully.', + 'deleted' => 'Deleted successfully.', // Button states 'saving' => 'Saving...', diff --git a/lang/en/identity.php b/lang/en/identity.php index 65555f2c..46cbf1d3 100644 --- a/lang/en/identity.php +++ b/lang/en/identity.php @@ -30,6 +30,7 @@ return [ 'missing_scope_or_target' => 'Both scope and target are required to resolve a policy.', 'admin_policy_has_no_default' => 'Admin-created policies do not have a declared default.', 'reset_field_failed' => 'Failed to reset the field to its declared default. Check if the field is valid.', + 'cannot_delete_system_policy' => 'System-declared policies cannot be deleted. Only administrator-created policies can be deleted.', ], 'messages' => [ diff --git a/lang/ko/common.php b/lang/ko/common.php index e6d5f4b2..de27563c 100644 --- a/lang/ko/common.php +++ b/lang/ko/common.php @@ -9,6 +9,9 @@ return [ 'unauthorized' => '인증이 필요합니다.', 'forbidden' => '권한이 없습니다.', 'validation_failed' => '입력 값 검증에 실패했습니다.', + 'created' => '생성되었습니다.', + 'updated' => '수정되었습니다.', + 'deleted' => '삭제되었습니다.', // 버튼 상태 'saving' => '저장 중...', diff --git a/lang/ko/identity.php b/lang/ko/identity.php index 50302998..1f1d8b47 100644 --- a/lang/ko/identity.php +++ b/lang/ko/identity.php @@ -30,6 +30,7 @@ return [ 'missing_scope_or_target' => '정책 조회를 위해 scope 와 target 이 모두 필요합니다.', 'admin_policy_has_no_default' => '관리자가 직접 생성한 정책에는 선언 기본값이 없습니다.', 'reset_field_failed' => '선언 기본값 복원에 실패했습니다. 필드가 유효한지 확인하세요.', + 'cannot_delete_system_policy' => '시스템이 선언한 정책은 삭제할 수 없습니다. 관리자가 직접 생성한 정책만 삭제할 수 있습니다.', ], 'messages' => [ diff --git a/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md b/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md index eb53d213..dcd51587 100644 --- a/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md +++ b/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md @@ -337,13 +337,47 @@ HTTP/1.1 200 **응답 예시** - +```json +{ + "success": true, + "message": "메모를 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "uuid": "9f1c0b4e-3a2d-4c8e-9f77-0a1b2c3d4e5f", + "title": "샘플 메모", + "content": "메모 본문 내용", + "created_at": "2026-04-21 09:00:00", + "updated_at": "2026-04-21 09:00:00", + "abilities": { + "can_create": false, + "can_update": false, + "can_delete": false + } + } + ], + "meta": { + "current_page": 1, + "last_page": 3, + "per_page": 10, + "total": 21 + }, + "abilities": { + "can_create": false, + "can_update": false, + "can_delete": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 429 | Too Many Requests | `throttle:600,1` 초과 (분당 600회) | +| 500 | Internal Server Error | 목록 조회 중 예외 발생 시 `messages.memo.fetch_failed` (`메모 조회에 실패했습니다.`) | @@ -382,7 +416,7 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| id | path | string | 예 | — | 대상 리소스의 식별자 | +| id | path | string | 예 | — | 조회할 메모의 고유번호(기본 키). `MemoService::getMemo($id)` 로 직접 조회하며 없으면 404 | **요청 예시** @@ -395,17 +429,47 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체(`MemoResource`)의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 메모 고유번호 (기본 키) | +| uuid | string | `9f1c0b4e-3a2d-4c8e-9f77-0a1b2c3d4e5f` | 메모 UUID (외부 식별자, 36자) | +| title | string | `샘플 메모` | 메모 제목 (최대 255자) | +| content | string | `메모 본문 내용` | 메모 본문 | +| created_at | string | `2026-04-21 09:00:00` | 생성 일시 (사용자 타임존 기준 `Y-m-d H:i:s`, 값이 없으면 필드 자체 생략) | +| updated_at | string | `2026-04-21 09:00:00` | 수정 일시 (사용자 타임존 기준 `Y-m-d H:i:s`, 값이 없으면 필드 자체 생략) | +| abilities | object | `{"can_create":false,"can_update":false,"can_delete":false}` | 권한 능력 (`gnuboard7-hello_module.memos.{create,update,delete}` 보유 여부). `MemoResource` 는 `ownerField()` 미정의 → `is_owner` 미포함 | **응답 예시** - +```json +{ + "success": true, + "message": "메모를 조회했습니다.", + "data": { + "id": 1, + "uuid": "9f1c0b4e-3a2d-4c8e-9f77-0a1b2c3d4e5f", + "title": "샘플 메모", + "content": "메모 본문 내용", + "created_at": "2026-04-21 09:00:00", + "updated_at": "2026-04-21 09:00:00", + "abilities": { + "can_create": false, + "can_update": false, + "can_delete": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해당 `id` 의 메모가 없는 경우 — `ModelNotFoundException` → `메모를 찾을 수 없습니다.` | +| 429 | Too Many Requests | `throttle:600,1` 초과 (분당 600회) | +| 500 | Internal Server Error | 조회 중 예외 발생 시 `메모 조회에 실패했습니다.` | diff --git a/modules/_bundled/sirsoft-board/docs/api/board-types.md b/modules/_bundled/sirsoft-board/docs/api/board-types.md index 9098e96f..f19e409a 100644 --- a/modules/_bundled/sirsoft-board/docs/api/board-types.md +++ b/modules/_bundled/sirsoft-board/docs/api/board-types.md @@ -125,11 +125,30 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `5` | 게시판 유형 ID (기본 키) | +| slug | string | `notice-board` | 유형 식별자 (basic, card, gallery 등) — 소문자로 시작하는 소문자/숫자/하이픈 조합 | +| name | object | `{"ko":"공지형","en":"Notice"}` | 유형명 (다국어: `{"ko": "기본형", "en": "Basic List"}`) | **응답 예시** - +```json +{ + "success": true, + "message": "게시판 유형이 생성되었습니다.", + "data": { + "id": 5, + "slug": "notice-board", + "name": { + "ko": "공지형", + "en": "Notice" + } + } +} +``` **에러 응답** @@ -167,11 +186,17 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만)._ **응답 예시** - +```json +{ + "success": true, + "message": "게시판 유형이 삭제되었습니다.", + "data": null +} +``` **에러 응답** @@ -179,7 +204,8 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.boards.create`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해당 `id` 의 게시판 유형이 없는 경우 (`게시판 유형을 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | 해당 유형을 사용 중인 게시판이 있는 경우 (`:count개 게시판에서 사용 중인 유형은 삭제할 수 없습니다.`) 또는 게시판 기본 유형(`basic_defaults.type`)으로 설정된 경우 (`기본값으로 설정된 유형은 삭제할 수 없습니다.`) | @@ -215,11 +241,30 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `2` | 게시판 유형 ID (기본 키) | +| slug | string | `basic` | 유형 식별자 (basic, card, gallery 등) — 수정되지 않음 | +| name | object | `{"ko":"기본형","en":"Basic List"}` | 유형명 (다국어: `{"ko": "기본형", "en": "Basic List"}`) | **응답 예시** - +```json +{ + "success": true, + "message": "게시판 유형이 수정되었습니다.", + "data": { + "id": 2, + "slug": "basic", + "name": { + "ko": "기본형", + "en": "Basic List" + } + } +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md index 4b565cbc..209a90a0 100644 --- a/modules/_bundled/sirsoft-board/docs/api/board.md +++ b/modules/_bundled/sirsoft-board/docs/api/board.md @@ -82,11 +82,46 @@ Content-Disposition: form-data; name="temp_key" **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. FileUploader 컴포넌트 호환을 위해 파일 메타는 `data.data` 로 한 번 더 감싸집니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| data | object | `{"id":1,"hash":"apidocsmpl1", ...}` | 업로드된 첨부파일 메타 객체 (아래 하위 필드) | +| data.id | integer | `1` | 첨부파일 기본 키 (게시판별 첨부 테이블의 내부 식별자) | +| data.hash | string | `apidocsmpl1` | 첨부파일 해시 식별자 (다운로드/미리보기 URL 에 사용) | +| data.original_filename | string | `apidoc-sample.png` | 사용자가 업로드한 원본 파일명 | +| data.stored_filename | string | `—` | 스토리지에 실제 저장된 파일명 (충돌 방지용 내부 파일명) | +| data.mime_type | string | `image/png` | 업로드 파일의 MIME 타입 | +| data.size | integer | `2048` | 파일 크기 (바이트) | +| data.url | string | `/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1` | 첨부파일 접근 URL (`AttachmentService::getUrl()` 산물) | +| data.order | integer | `0` | 첨부파일 표시 순서 (0 부터 오름차순) | +| data.created_at | string | `2026-07-08 10:41:34` | 업로드(생성) 일시 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "파일이 업로드되었습니다.", + "data": { + "data": { + "id": 1, + "hash": "apidocsmpl1", + "original_filename": "apidoc-sample.png", + "stored_filename": "apidocsmpl1.png", + "mime_type": "image/png", + "size": 2048, + "url": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1", + "order": 0, + "created_at": "2026-07-08 10:41:34" + } + } +} +``` **에러 응답** @@ -94,8 +129,9 @@ Content-Disposition: form-data; name="temp_key" | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.attachments.upload`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 (슬러그에 해당하는 게시판 없음 — `게시판을 찾을 수 없습니다.`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 업로드 처리 실패 (`파일 업로드에 실패했습니다.`) | @@ -128,11 +164,17 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다. 성공 시 파일 본문을 그대로 내려주는 `StreamedResponse`(바이너리 스트림)이며, `data` 구조가 없습니다. 실패 시에만 JSON 에러 봉투가 반환됩니다._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: image/png +Content-Disposition: attachment; filename="apidoc-sample.png" + +(바이너리 파일 내용) +``` **에러 응답** @@ -140,7 +182,8 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.attachments.download`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 슬러그에 해당하는 게시판이 없거나(`게시판을 찾을 수 없습니다.`), 해시에 해당하는 첨부가 없거나(`파일을 찾을 수 없습니다.`), 실제 파일이 스토리지에 없는 경우 | +| 500 | Internal Server Error | 다운로드 처리 실패 (`파일 다운로드에 실패했습니다.`) | @@ -180,11 +223,21 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "첨부파일 순서가 변경되었습니다.", + "data": null +} +``` **에러 응답** @@ -192,8 +245,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.attachments.upload`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 슬러그에 해당하는 게시판이 없는 경우 (`게시판을 찾을 수 없습니다.`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 순서 변경 처리 실패 (`첨부파일 순서 변경에 실패했습니다.`) | @@ -224,7 +278,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** @@ -264,11 +318,14 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | **요청 예시** ```http -GET /api/modules/sirsoft-board/admin/board/{slug}/posts HTTP/1.1 +GET /api/modules/sirsoft-board/admin/board/{slug}/posts?page=1&per_page=1&search=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -412,6 +469,7 @@ HTTP/1.1 200 | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -443,11 +501,120 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`PostResource` — 게시글 수정(PUT) 응답과 동일한 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| category | null | `null` | 게시글 분류(카테고리) 문자열. 게시판이 카테고리를 쓰지 않거나 미지정 시 null (최대 50자). | +| author | object | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자"}` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_notice | boolean | `false` | 공지 게시글 여부 | +| is_secret | boolean | `false` | 비밀글 여부 | +| content_mode | string | `html` | 본문 편집 모드. `html`(위지윅/HTML) 또는 `text`(평문). | +| is_new | boolean | `true` | 신규(NEW) 표시 대상 여부 (게시판 `new_display_hours` 기준) | +| status | string | `published` | 게시글 상태 코드. `published` / `blinded` / `deleted` 중 하나. | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| view_count | integer | `0` | 조회수 (집계) | +| comment_count | integer | `0` | 댓글 수 (집계) | +| reply_count | integer | `0` | 답변글 수 (집계) | +| attachment_count | integer | `0` | 첨부파일 수 (집계) | +| has_attachment | boolean | `false` | 첨부파일 보유 여부 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 (없으면 null) | +| parent_id | null | `null` | 원글 ID (답변글일 때만 값 존재) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위) | +| is_reply | boolean | `false` | 답변글 여부 | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `방금 전` | `created_at` 값의 표시용 포맷 문자열 | +| is_author | boolean | `true` | 현재 사용자가 작성자인지 여부 | +| is_guest_post | boolean | `false` | 비회원 작성 게시글 여부 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| content | string | `

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

` | 본문 내용 | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 작성자 사용자 식별자 | +| trigger_type | string | `user` | 상태 변경을 유발한 주체 (`report`/`admin`/`system`/`auto_hide`/`user`/`cascade`) | +| updated_at | string | `2026-07-08 10:41:34` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| ip_address | string | `127.0.0.1` | 작성 요청이 발생한 IP 주소 | +| action_logs | array | `[]` | 블라인드/복원/삭제 처리 이력 목록. `admin.manage` 권한 보유자에게만 노출. | +| board | null | `null` | 소속 게시판 정보 객체. board 관계 미로드 시 null. | +| navigation | null | `null` | 이전/다음 게시글 이동 정보. 쓰기 응답에서는 계산하지 않아 null. | +| parent | null | `null` | 원글 객체 (parent 관계 파생) | +| comments | null | `null` | 댓글 목록. comments 관계 미로드 시 null. | +| attachments | null | `null` | 첨부파일 목록. attachments 관계 미로드 시 null. | +| replies | null | `null` | 답변글 목록. replies 관계 미로드 시 null. | +| is_already_reported | boolean | `false` | 현재 사용자가 이미 신고한 게시글인지 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,…}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "게시글이 등록되었습니다.", + "data": { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": false, + "content_mode": "html", + "is_new": true, + "status": "published", + "status_label": "게시됨", + "view_count": 0, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": null, + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "방금 전", + "is_author": true, + "is_guest_post": false, + "title": "API 문서 샘플 게시글", + "content": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "trigger_type": "user", + "updated_at": "2026-07-08 10:41:34", + "deleted_at": null, + "ip_address": "127.0.0.1", + "action_logs": [], + "board": null, + "navigation": null, + "parent": null, + "comments": null, + "attachments": null, + "replies": null, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": true, + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + } + } +} +``` **에러 응답** @@ -456,7 +623,8 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 제목/본문 길이, 금지 키워드, 작성 쿨다운, 원글(`parent_id`) 유효성 위반 포함 | +| 500 | Internal Server Error | 게시글 생성 처리 실패 (`게시글 등록에 실패했습니다.`) | @@ -474,11 +642,13 @@ Authorization: Bearer {YOUR_TOKEN} | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| post_id | query | integer | 아니오 | min 1 | post 식별자 | +| parent_id | query | integer | 아니오 | min 1 | parent 식별자 | **요청 예시** ```http -GET /api/modules/sirsoft-board/admin/board/{slug}/posts/form-data HTTP/1.1 +GET /api/modules/sirsoft-board/admin/board/{slug}/posts/form-data?post_id=1&parent_id=1 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -527,6 +697,7 @@ HTTP/1.1 200 | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 제목/본문 길이, 금지 키워드, 작성 쿨다운, 원글(`parent_id`) 유효성 위반 포함 | @@ -808,7 +979,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write|sirsoft-board.{slug}.admin.manage`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write\|sirsoft-board.{slug}.admin.manage`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | @@ -1425,11 +1596,127 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`PostResource` — 복원된 게시글. 게시글 수정(PUT) 응답과 동일한 구조이며, `status` 가 `published` 로 되돌아가고 `action_logs` 에 `restore` 이력이 1건 추가됩니다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| category | null | `null` | 게시글 분류(카테고리) 문자열 (미지정 시 null) | +| author | object | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자"}` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_notice | boolean | `false` | 공지 게시글 여부 | +| is_secret | boolean | `false` | 비밀글 여부 | +| content_mode | string | `html` | 본문 편집 모드 (`html` / `text`) | +| is_new | boolean | `true` | 신규(NEW) 표시 대상 여부 | +| status | string | `published` | 복원 후 게시글 상태 (`published` 로 되돌아감) | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 | +| view_count | integer | `44` | 조회수 (집계) | +| comment_count | integer | `0` | 댓글 수 (집계) | +| reply_count | integer | `0` | 답변글 수 (집계) | +| attachment_count | integer | `0` | 첨부파일 수 (집계) | +| has_attachment | boolean | `false` | 첨부파일 보유 여부 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 | +| parent_id | null | `null` | 원글 ID (답변글일 때만 값 존재) | +| depth | integer | `0` | 계층 트리에서의 깊이 | +| is_reply | boolean | `false` | 답변글 여부 | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 | +| is_author | boolean | `true` | 현재 사용자가 작성자인지 여부 | +| is_guest_post | boolean | `false` | 비회원 작성 게시글 여부 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| content | string | `

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

` | 본문 내용 | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 작성자 사용자 식별자 | +| trigger_type | string | `admin` | 상태 변경을 유발한 주체 (복원은 관리자 직권이므로 `admin`) | +| updated_at | string | `2026-07-08 15:01:45` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (복원 후 null) | +| ip_address | string | `127.0.0.1` | 작성 요청이 발생한 IP 주소 | +| action_logs | array | `[{"action":"restore","reason":null,"admin_name":"API 문서 샘플 사용자","created_at":"2026-07-08 06:01:45"}]` | 블라인드/복원/삭제 처리 이력 목록. `admin.manage` 권한 보유자에게만 노출. | +| board | null | `null` | 소속 게시판 정보 객체 (관계 미로드 시 null) | +| navigation | null | `null` | 이전/다음 게시글 이동 정보 (쓰기 응답은 미계산 → null) | +| parent | null | `null` | 원글 객체 (parent 관계 파생) | +| comments | null | `null` | 댓글 목록 (관계 미로드 시 null) | +| attachments | null | `null` | 첨부파일 목록 (관계 미로드 시 null) | +| replies | null | `null` | 답변글 목록 (관계 미로드 시 null) | +| is_already_reported | boolean | `false` | 현재 사용자가 이미 신고한 게시글인지 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 | +| abilities | object | `{"can_read":true,"can_write":true,…}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글이 복원되었습니다.", + "data": { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": false, + "content_mode": "html", + "is_new": true, + "status": "published", + "status_label": "게시됨", + "view_count": 44, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": null, + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "is_author": true, + "is_guest_post": false, + "title": "API 문서 샘플 게시글", + "content": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "trigger_type": "admin", + "updated_at": "2026-07-08 15:01:45", + "deleted_at": null, + "ip_address": "127.0.0.1", + "action_logs": [ + { + "action": "restore", + "reason": null, + "admin_name": "API 문서 샘플 사용자", + "created_at": "2026-07-08 06:01:45" + } + ], + "board": null, + "navigation": null, + "parent": null, + "comments": null, + "attachments": null, + "replies": null, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": true, + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + } + } +} +``` **에러 응답** @@ -1437,8 +1724,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.manage`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 슬러그에 해당하는 게시판 또는 ID 에 해당하는 게시글이 없는 경우 (`존재하지 않는 게시글입니다.`) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`reason` 이 1000자를 초과하는 등) | +| 500 | Internal Server Error | 복원 처리 실패 (`게시글 복원에 실패했습니다.`) | @@ -1471,20 +1759,91 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CommentResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 댓글 기본 키 (내부 식별자) | +| post_id | integer | `1` | 댓글이 속한 게시글 식별자 | +| parent_id | null | `null` | 상위 댓글 식별자 (대댓글일 때만 값 존재) | +| content | string | `API 문서 샘플 댓글입니다.` | 댓글 본문 내용 | +| author | object | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자"}` | 작성자 사용자 객체 (author 관계 파생) | +| is_secret | boolean | `false` | 비밀 댓글 여부 | +| status | string | `published` | 댓글 상태 코드 (`published` / `blinded` / `deleted`) | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| depth | integer | `0` | 댓글 계층 깊이 (0 = 최상위 댓글, 서비스에서 자동 계산) | +| replies_count | integer | `0` | 이 댓글에 달린 대댓글 수 (집계) | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `방금 전` | `created_at` 값의 표시용 포맷 문자열 | +| updated_at | string | `2026-07-08 10:41:34` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| is_cascade_deleted | boolean | `false` | 상위 게시글/댓글 삭제로 연쇄 삭제된 댓글인지 여부 | +| ip_address | string | `127.0.0.1` | 작성 요청이 발생한 IP 주소 (권한자에게만 노출) | +| action_logs | array | `[]` | 블라인드/복원/삭제 처리 이력 목록. `admin.manage` 권한 보유자에게만 노출. | +| is_author | boolean | `true` | 현재 사용자가 작성자인지 여부 | +| is_guest_comment | boolean | `false` | 비회원 작성 댓글 여부 | +| is_already_reported | boolean | `false` | 현재 사용자가 이미 신고한 댓글인지 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,"can_manage":true}` | 현재 사용자가 이 댓글에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "댓글이 등록되었습니다.", + "data": { + "id": 1, + "post_id": 1, + "parent_id": null, + "content": "API 문서 샘플 댓글입니다.", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_secret": false, + "status": "published", + "status_label": "게시됨", + "depth": 0, + "replies_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "방금 전", + "updated_at": "2026-07-08 10:41:34", + "deleted_at": null, + "is_cascade_deleted": false, + "ip_address": "127.0.0.1", + "action_logs": [], + "is_author": true, + "is_guest_comment": false, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_manage": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.comments.write`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.comments.write`)이 없거나, 게시판의 댓글 기능이 꺼진 경우 (`이 게시판은 댓글 기능이 비활성화되어 있습니다.`) | +| 404 | Not Found | 슬러그에 해당하는 게시판이 없는 경우 (`게시판을 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 댓글 길이, 금지 키워드, 작성 쿨다운, 대상 게시글·상위 댓글 유효성 위반 포함 | +| 500 | Internal Server Error | 댓글 생성 처리 실패 (`댓글 등록에 실패했습니다.`) | @@ -1516,7 +1875,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** @@ -1537,8 +1896,9 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.comments.write|sirsoft-board.{slug}.admin.manage`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.comments.write\|sirsoft-board.{slug}.admin.manage`)이 없거나, 타인/비회원 댓글을 `admin.manage` 없이 삭제하려는 경우(`접근 권한이 없습니다.`), 게시판의 댓글 기능이 꺼진 경우(`이 게시판은 댓글 기능이 비활성화되어 있습니다.`) | +| 404 | Not Found | ID 에 해당하는 댓글 또는 슬러그에 해당하는 게시판이 없는 경우 (`댓글을 찾을 수 없습니다.`) | +| 500 | Internal Server Error | 삭제 처리 실패 (`댓글 삭제에 실패했습니다.`) | @@ -1572,20 +1932,91 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CommentResource` — 댓글 생성/블라인드 응답과 동일한 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 댓글 기본 키 (내부 식별자) | +| post_id | integer | `1` | 댓글이 속한 게시글 식별자 | +| parent_id | null | `null` | 상위 댓글 식별자 (대댓글일 때만 값 존재) | +| content | string | `API 문서 샘플 댓글입니다. (수정)` | 수정된 댓글 본문 내용 | +| author | object | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자"}` | 작성자 사용자 객체 (author 관계 파생) | +| is_secret | boolean | `false` | 비밀 댓글 여부 | +| status | string | `published` | 댓글 상태 코드 (`published` / `blinded` / `deleted`) | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 | +| depth | integer | `0` | 댓글 계층 깊이 (0 = 최상위 댓글) | +| replies_count | integer | `0` | 이 댓글에 달린 대댓글 수 (집계) | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 | +| updated_at | string | `2026-07-08 15:01:45` | 최종 수정 일시 (수정 시각으로 갱신) | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| is_cascade_deleted | boolean | `false` | 상위 게시글/댓글 삭제로 연쇄 삭제된 댓글인지 여부 | +| ip_address | null | `null` | 작성 요청이 발생한 IP 주소 (권한자에게만 노출) | +| action_logs | array | `[]` | 블라인드/복원/삭제 처리 이력 목록. `admin.manage` 권한 보유자에게만 노출. | +| is_author | boolean | `true` | 현재 사용자가 작성자인지 여부 | +| is_guest_comment | boolean | `false` | 비회원 작성 댓글 여부 | +| is_already_reported | boolean | `false` | 현재 사용자가 이미 신고한 댓글인지 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 | +| abilities | object | `{"can_read":true,"can_write":true,"can_manage":true}` | 현재 사용자가 이 댓글에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "댓글이 수정되었습니다.", + "data": { + "id": 1, + "post_id": 1, + "parent_id": null, + "content": "API 문서 샘플 댓글입니다. (수정)", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_secret": false, + "status": "published", + "status_label": "게시됨", + "depth": 0, + "replies_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "updated_at": "2026-07-08 15:01:45", + "deleted_at": null, + "is_cascade_deleted": false, + "ip_address": null, + "action_logs": [], + "is_author": true, + "is_guest_comment": false, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_manage": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.comments.write`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.comments.write`)이 없거나, 타인/비회원 댓글을 `admin.manage` 없이 수정하려는 경우(`접근 권한이 없습니다.`), 게시판의 댓글 기능이 꺼진 경우(`이 게시판은 댓글 기능이 비활성화되어 있습니다.`) | +| 404 | Not Found | ID 에 해당하는 댓글 또는 슬러그에 해당하는 게시판이 없는 경우 (`댓글을 찾을 수 없습니다.`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 수정 처리 실패 (`댓글 수정에 실패했습니다.`) | @@ -1733,6 +2164,7 @@ HTTP/1.1 200 | slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | | postId | path | string | 예 | — | 대상 post의 식별자 | | id | path | string | 예 | — | 대상 리소스의 식별자 | +| reason | body | string | 아니오 | max 1000 | 댓글 블라인드 처리 사유(최대 1000자). 처리 이력에 기록되며, 미지정 시 빈 문자열로 저장됩니다. | **요청 예시** @@ -1741,23 +2173,113 @@ PATCH /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id} Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "reason": "예시값" +} ``` **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CommentResource` — 복원된 댓글. 블라인드 응답과 동일한 구조이며, `status` 가 `published` 로 되돌아가고 `action_logs` 에 `restore` 이력이 1건 추가됩니다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 댓글 기본 키 (내부 식별자) | +| post_id | integer | `1` | 댓글이 속한 게시글 식별자 | +| parent_id | null | `null` | 상위 댓글 식별자 (대댓글일 때만 값 존재) | +| content | string | `API 문서 샘플 댓글입니다.` | 댓글 본문 내용 (복원되어 원문이 다시 노출됨) | +| author | object | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자"}` | 작성자 사용자 객체 (author 관계 파생) | +| is_secret | boolean | `false` | 비밀 댓글 여부 | +| status | string | `published` | 복원 후 댓글 상태 (`published` 로 되돌아감) | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 | +| depth | integer | `0` | 댓글 계층 깊이 (0 = 최상위 댓글) | +| replies_count | integer | `0` | 이 댓글에 달린 대댓글 수 (집계) | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 | +| updated_at | string | `2026-07-08 15:01:45` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| is_cascade_deleted | boolean | `false` | 상위 게시글/댓글 삭제로 연쇄 삭제된 댓글인지 여부 | +| ip_address | null | `null` | 작성 요청이 발생한 IP 주소 (권한자에게만 노출) | +| action_logs | array | `[{"action":"blind","reason":"실측 예시값",…},{"action":"restore","reason":null,…}]` | 블라인드/복원/삭제 처리 이력 목록 (복원 이력이 추가됨). `admin.manage` 권한 보유자에게만 노출. | +| is_author | boolean | `true` | 현재 사용자가 작성자인지 여부 | +| is_guest_comment | boolean | `false` | 비회원 작성 댓글 여부 | +| is_already_reported | boolean | `false` | 현재 사용자가 이미 신고한 댓글인지 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 | +| abilities | object | `{"can_read":true,"can_write":true,"can_manage":true}` | 현재 사용자가 이 댓글에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "댓글이 복원되었습니다.", + "data": { + "id": 1, + "post_id": 1, + "parent_id": null, + "content": "API 문서 샘플 댓글입니다.", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_secret": false, + "status": "published", + "status_label": "게시됨", + "depth": 0, + "replies_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "updated_at": "2026-07-08 15:01:45", + "deleted_at": null, + "is_cascade_deleted": false, + "ip_address": null, + "action_logs": [ + { + "action": "blind", + "reason": "실측 예시값", + "admin_name": "API 문서 샘플 사용자", + "created_at": "2026-07-08 06:01:44" + }, + { + "action": "restore", + "reason": null, + "admin_name": "API 문서 샘플 사용자", + "created_at": "2026-07-08 06:01:45" + } + ], + "is_author": true, + "is_guest_comment": false, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_manage": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.manage`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.manage`)이 없거나, 게시판의 댓글 기능이 꺼진 경우 (`이 게시판은 댓글 기능이 비활성화되어 있습니다.`) | +| 404 | Not Found | ID 에 해당하는 댓글 또는 슬러그에 해당하는 게시판이 없는 경우 (`댓글을 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 복원 처리 실패 (`댓글 복원에 실패했습니다.`) | diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md index f662b954..17709bf2 100644 --- a/modules/_bundled/sirsoft-board/docs/api/boards.md +++ b/modules/_bundled/sirsoft-board/docs/api/boards.md @@ -224,11 +224,123 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 게시판 = `BoardResource`, 상세 조회(`GET /admin/boards/{board}`)와 동일 스키마)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `12` | 기본 키 (내부 식별자) | +| name | string | `API 문서 샘플 게시판` | 게시판명 (다국어 JSON) | +| slug | string | `apidoc-sample-board` | 게시판 슬러그 (URL/테이블명 — 생성 후 변경 불가) | +| is_active | boolean | `true` | active 여부 | +| type | string | `basic` | 게시판 타입 (basic, gallery, card 등) | +| description | string | `` | 게시판 설명 (다국어 JSON) | +| per_page | integer | `20` | 페이지당 게시글 수 (PC) | +| per_page_mobile | integer | `15` | 페이지당 게시글 수 (Mobile) | +| order_by | string | `created_at` | 정렬 기준 (created_at, view_count, title, author) | +| order_direction | string | `DESC` | 정렬 방향 (ASC, DESC) | +| categories | array | `[]` | 분류 목록 (배열) | +| show_view_count | boolean | `true` | 조회수 노출 | +| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) | +| use_comment | boolean | `true` | 댓글 기능 사용 | +| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | +| max_reply_depth | integer | `5` | 답변글 최대 깊이 | +| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | +| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | +| max_comment_depth | integer | `10` | 대댓글 최대 깊이 | +| min_title_length | integer | `2` | 최소 제목 글자 수 | +| max_title_length | integer | `200` | 최대 제목 글자 수 | +| min_content_length | integer | `10` | 최소 게시글 글자 수 | +| max_content_length | integer | `10000` | 최대 게시글 글자 수 | +| min_comment_length | integer | `2` | 최소 댓글 글자 수 | +| max_comment_length | integer | `1000` | 최대 댓글 글자 수 | +| blocked_keywords | array | `[]` | 금지어 목록 (배열) | +| use_file_upload | boolean | `true` | 파일 업로드 사용 | +| max_file_size | integer | `10` | 최대 파일 크기 (MB) | +| max_file_count | integer | `5` | 게시글 1건당 최대 첨부 개수 | +| allowed_extensions | array | `["jpg","png","pdf"]` | 허용 확장자 배열 | +| add_to_menu | null | `null` | 관리자 메뉴 등록 여부 (폼 요청에서만 채워지는 토글 초기값 — 조회 응답에서는 항상 null) | +| new_display_hours | integer | `24` | 신규 게시글 표시 기간 (시간 단위) | +| board_managers | array | `[]` | 게시판 관리자로 지정된 사용자 목록 (uuid/name/email — 역할 기반 파생) | +| board_steps | array | `[]` | 게시판 승인/처리 담당자(스텝) 사용자 목록 (uuid/name/email — 역할 기반 파생) | +| board_manager_ids | array | `[]` | board manager 식별자(uuid) 배열 | +| board_step_ids | array | `[]` | board step 식별자(uuid) 배열 | +| notify_author | boolean | `true` | 작성자 이메일 알림 (댓글, 대댓글, 답변글, 관리자 처리 시) | +| notify_admin_on_post | boolean | `true` | 관리자 이메일 알림 (게시글 등록 시) | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| updated_at | string | `2026-07-07 09:34:50` | 최종 수정 일시 | +| permissions | object \| null | `null` | 게시판별 권한 매트릭스 (`include_permissions` 요청 시에만 채워지며, 미포함 시 null) | +| category_post_counts | null | `null` | 분류별 게시글 개수 맵 (요청 시 조건부로만 채워지며, 미포함 시 null) | +| posts_count | integer | `0` | 게시글 수 (집계 — 생성 직후 0) | +| user_abilities | null | `null` | 현재 사용자의 게시판별 세부 권한 맵 (`include_user_abilities` 요청 시에만 채워지며 미포함 시 null) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "게시판이 생성되었습니다.", + "data": { + "id": 12, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "is_active": true, + "type": "basic", + "description": "", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "categories": [], + "show_view_count": true, + "secret_mode": "disabled", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "use_report": true, + "comment_order": "ASC", + "max_comment_depth": 10, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 10, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "blocked_keywords": [], + "use_file_upload": true, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "png", + "pdf" + ], + "add_to_menu": null, + "new_display_hours": 24, + "board_managers": [], + "board_steps": [], + "board_manager_ids": [], + "board_step_ids": [], + "notify_author": true, + "notify_admin_on_post": true, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34", + "permissions": null, + "category_post_counts": null, + "posts_count": 0, + "user_abilities": null, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -237,6 +349,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.boards.create`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 게시판 전용 테이블 생성 실패 등 생성 처리 중 예외 (`게시판 생성에 실패했습니다.`) | @@ -788,9 +901,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `success('...boards.delete_success')` 를 데이터 없이 호출하므로 `data` 는 항상 `null` 입니다._ **응답 예시** @@ -1229,11 +1340,123 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 게시판 = `BoardResource`, 상세 조회(`GET /admin/boards/{board}`)와 동일 스키마)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `12` | 기본 키 (내부 식별자) | +| name | string | `API 문서 샘플 게시판` | 게시판명 (다국어 JSON) | +| slug | string | `apidoc-sample-board` | 게시판 슬러그 (생성 시 고정 — 수정 대상 아님) | +| is_active | boolean | `true` | active 여부 | +| type | string | `basic` | 게시판 타입 (basic, gallery, card 등) | +| description | string | `` | 게시판 설명 (다국어 JSON) | +| per_page | integer | `20` | 페이지당 게시글 수 (PC) | +| per_page_mobile | integer | `15` | 페이지당 게시글 수 (Mobile) | +| order_by | string | `created_at` | 정렬 기준 (created_at, view_count, title, author) | +| order_direction | string | `DESC` | 정렬 방향 (ASC, DESC) | +| categories | array | `[]` | 분류 목록 (배열) | +| show_view_count | boolean | `true` | 조회수 노출 | +| secret_mode | string | `disabled` | 비밀글 설정 (disabled/enabled/always) | +| use_comment | boolean | `true` | 댓글 기능 사용 | +| use_reply | boolean | `true` | 게시글 답변 기능 사용 | +| max_reply_depth | integer | `5` | 답변글 최대 깊이 | +| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | +| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC, DESC) | +| max_comment_depth | integer | `10` | 대댓글 최대 깊이 | +| min_title_length | integer | `2` | 최소 제목 글자 수 | +| max_title_length | integer | `200` | 최대 제목 글자 수 | +| min_content_length | integer | `10` | 최소 게시글 글자 수 | +| max_content_length | integer | `10000` | 최대 게시글 글자 수 | +| min_comment_length | integer | `2` | 최소 댓글 글자 수 | +| max_comment_length | integer | `1000` | 최대 댓글 글자 수 | +| blocked_keywords | array | `[]` | 금지어 목록 (배열) | +| use_file_upload | boolean | `true` | 파일 업로드 사용 | +| max_file_size | integer | `10` | 최대 파일 크기 (MB) | +| max_file_count | integer | `5` | 게시글 1건당 최대 첨부 개수 | +| allowed_extensions | array | `["jpg","png","pdf"]` | 허용 확장자 배열 | +| add_to_menu | null | `null` | 관리자 메뉴 등록 여부 (폼 요청 전용 토글 초기값 — 조회 응답에서는 항상 null) | +| new_display_hours | integer | `24` | 신규 게시글 표시 기간 (시간 단위) | +| board_managers | array | `[]` | 게시판 관리자 사용자 목록 (uuid/name/email) | +| board_steps | array | `[]` | 게시판 승인/처리 담당자(스텝) 사용자 목록 (uuid/name/email) | +| board_manager_ids | array | `[]` | board manager 식별자(uuid) 배열 | +| board_step_ids | array | `[]` | board step 식별자(uuid) 배열 | +| notify_author | boolean | `true` | 작성자 이메일 알림 | +| notify_admin_on_post | boolean | `true` | 관리자 이메일 알림 (게시글 등록 시) | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| updated_at | string | `2026-07-08 11:02:10` | 최종 수정 일시 (수정 후 갱신됨) | +| permissions | object \| null | `null` | 게시판별 권한 매트릭스 (`include_permissions` 요청 시에만 채워짐) | +| category_post_counts | null | `null` | 분류별 게시글 개수 맵 (조건부) | +| posts_count | integer | `0` | 게시글 수 (집계) | +| user_abilities | null | `null` | 현재 사용자의 게시판별 세부 권한 맵 (`include_user_abilities` 요청 시에만 채워짐) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시판이 수정되었습니다.", + "data": { + "id": 12, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "is_active": true, + "type": "basic", + "description": "", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "categories": [], + "show_view_count": true, + "secret_mode": "disabled", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "use_report": true, + "comment_order": "ASC", + "max_comment_depth": 10, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 10, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "blocked_keywords": [], + "use_file_upload": true, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "png", + "pdf" + ], + "add_to_menu": null, + "new_display_hours": 24, + "board_managers": [], + "board_steps": [], + "board_manager_ids": [], + "board_step_ids": [], + "notify_author": true, + "notify_admin_on_post": true, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 11:02:10", + "permissions": null, + "category_post_counts": null, + "posts_count": 0, + "user_abilities": null, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -1243,6 +1466,7 @@ Content-Type: application/json | 403 | Forbidden | 요구 권한(`sirsoft-board.boards.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 수정 처리 중 예외 (`게시판 수정에 실패했습니다.`) | @@ -1382,7 +1606,7 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 없음 (공개 조회 — 컨트롤러가 캐시된 조회 결과를 그대로 반환하며 인증·권한·검증 실패 외 도메인 특이 에러를 던지지 않습니다)._ @@ -1438,7 +1662,7 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 없음 (공개 조회 — 컨트롤러가 캐시된 조회 결과를 그대로 반환하며 인증·권한·검증 실패 외 도메인 특이 에러를 던지지 않습니다)._ @@ -1469,10 +1693,10 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | id | integer | `1` | 기본 키 (내부 식별자) | -| board_slug | string | `gallery` | | -| board_name | string | `갤러리` | | +| board_slug | string | `gallery` | 이 게시글이 속한 게시판의 slug (게시판 URL 식별자) | +| board_name | string | `갤러리` | 이 게시글이 속한 게시판의 이름 (현재 로케일로 해석된 표시명) | | title | string | `일반 게시글 (이미지 있음)` | 제목 | -| excerpt | string | `이미지가 첨부된 일반 게시글입니다. 정상적으로 표시됩니다.` | | +| excerpt | string | `이미지가 첨부된 일반 게시글입니다. 정상적으로 표시됩니다.` | 본문 요약 발췌 (HTML 태그 제거 후 앞 100자) | | author | object | `{"id":1,"name":"관리자","email":"heuristing@gmail.com","is_g…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | | view_count | integer | `258` | view 개수 (집계) | | comment_count | integer | `2` | comment 개수 (집계) | @@ -1516,7 +1740,7 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 없음 (공개 조회 — 컨트롤러가 캐시된 조회 결과를 그대로 반환하며 인증·권한·검증 실패 외 도메인 특이 에러를 던지지 않습니다)._ @@ -1576,7 +1800,7 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 없음 (공개 조회 — 컨트롤러가 캐시된 조회 결과를 그대로 반환하며 인증·권한·검증 실패 외 도메인 특이 에러를 던지지 않습니다)._ @@ -1648,7 +1872,7 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 없음 (공개 조회 — 컨트롤러가 캐시된 조회 결과를 그대로 반환하며 인증·권한·검증 실패 외 도메인 특이 에러를 던지지 않습니다)._ @@ -1706,7 +1930,7 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 없음 (공개 조회 — 컨트롤러가 캐시된 조회 결과를 그대로 반환하며 인증·권한·검증 실패 외 도메인 특이 에러를 던지지 않습니다)._ @@ -2030,18 +2254,35 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 성공 시 파일 바이너리를 `StreamedResponse` 로 스트리밍하며(`Content-Disposition: attachment`), JSON 봉투는 실패 시(404/403/500)에만 사용됩니다._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="example.pdf" + +(파일 바이너리 스트림) +``` + +실패 시(첨부 미존재): + +```json +{ + "success": false, + "message": "파일을 찾을 수 없습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.download`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.download`)이 없거나 삭제글 첨부 등 접근이 차단된 경우 (`auth.scope_denied`) | +| 404 | Not Found | 게시판 슬러그 또는 해시에 해당하는 첨부가 없는 경우 (`파일을 찾을 수 없습니다.` / `게시판을 찾을 수 없습니다.`) | +| 500 | Internal Server Error | 파일 스트리밍 중 예외 (`파일 다운로드에 실패했습니다.`) | @@ -2072,17 +2313,36 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 성공 시 이미지 바이너리를 원본 MIME 타입으로 인라인 응답하며, 레이아웃 캐시 TTL(`cache.layout_ttl`, 기본 86400초) 기반 캐싱 헤더가 포함됩니다. JSON 봉투는 실패 시(400/403/404/500)에만 사용됩니다._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: image/png +Cache-Control: public, max-age=86400 + +(이미지 바이너리) +``` + +실패 시(이미지가 아닌 파일 요청): + +```json +{ + "success": false, + "message": "이미지 파일만 미리보기가 가능합니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 400 | Bad Request | 대상 첨부가 이미지가 아닌 경우 (`이미지 파일만 미리보기가 가능합니다.`) | +| 403 | Forbidden | 삭제글 첨부 등 접근이 차단된 경우 (`auth.scope_denied`) | +| 404 | Not Found | 게시판 슬러그 또는 해시에 해당하는 첨부가 없는 경우 (`파일을 찾을 수 없습니다.` / `게시판을 찾을 수 없습니다.`) | +| 500 | Internal Server Error | 미리보기 처리 중 예외 (`이미지 미리보기에 실패했습니다.`) | @@ -2138,19 +2398,54 @@ Content-Disposition: form-data; name="temp_key" **응답 필드** (`data` 내부) - +_단건 응답: FileUploader 컴포넌트가 `response.data?.data` 형식을 기대하므로, 업로드된 첨부 객체는 `data.data` 한 단계 더 안쪽에 담깁니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| data.id | integer | `155` | 첨부파일 기본 키 (내부 식별자) | +| data.hash | string | `apidocsmpl1` | 첨부파일 해시 (다운로드/미리보기 URL의 식별자) | +| data.original_filename | string | `example.pdf` | 업로드 당시의 원본 파일명 | +| data.stored_filename | string | `20260708_ab12cd34.pdf` | 스토리지에 저장된 실제 파일명 | +| data.mime_type | string | `application/pdf` | 파일 MIME 타입 | +| data.size | integer | `102400` | 파일 크기 (바이트) | +| data.url | string | `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}` | 첨부 접근 URL (AttachmentService::getUrl 산출) | +| data.order | integer | `0` | 첨부 표시 순서 (reorder 로 갱신) | +| data.created_at | string | `2026-07-08 10:41:34` | 업로드 일시 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "파일이 업로드되었습니다.", + "data": { + "data": { + "id": 155, + "hash": "apidocsmpl1", + "original_filename": "example.pdf", + "stored_filename": "20260708_ab12cd34.pdf", + "mime_type": "application/pdf", + "size": 102400, + "url": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1", + "order": 0, + "created_at": "2026-07-08 10:41:34" + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.upload`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.upload`)이 없거나, 게시판의 파일 업로드 설정이 꺼진 경우 (`이 게시판에서는 파일 업로드가 비활성화되어 있습니다.`) | +| 404 | Not Found | 게시판 슬러그에 해당하는 게시판이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 파일 저장 중 예외 (`파일 업로드에 실패했습니다.`) | @@ -2190,19 +2485,30 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `success('...attachment.reorder_success')` 를 데이터 없이 호출합니다._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "첨부파일 순서가 변경되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.upload`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 게시판 슬러그에 해당하는 게시판이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 순서 갱신 실패 또는 처리 중 예외 (`첨부파일 순서 변경에 실패했습니다.`) | @@ -2233,7 +2539,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `success('...attachment.delete_success')` 를 데이터 없이 호출합니다._ **응답 예시** @@ -2253,8 +2559,9 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.upload`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.upload`)이 없거나, 해당 첨부의 삭제 권한(소유자/관리자)이 없는 경우 (`파일 삭제 권한이 없습니다.`) | +| 404 | Not Found | 게시판 또는 첨부파일이 없는 경우 (`파일을 찾을 수 없습니다.`) | +| 500 | Internal Server Error | 삭제 처리 중 예외 (`파일 삭제에 실패했습니다.`) | @@ -2293,19 +2600,111 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (접수된 신고 케이스 = `ReportResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 신고 케이스 기본 키 (boards_reports 1행 = 1케이스) | +| board_id | integer | `1` | 신고가 접수된 게시판 식별자 | +| board | object | `{"id":1,"name":"API 문서 샘플 게시판","slug":"apidoc-sample-board","title":null,"current_status":null,"deleted_at":null}` | 게시판 정보 (id/name/slug + 신고 대상 제목·현재 상태·삭제 일시) | +| target_type | string | `comment` | 신고 대상 유형 (`post` 게시글 / `comment` 댓글 — ReportType Enum 값) | +| target_type_label | string | `댓글` | 신고 대상 유형의 표시 라벨 | +| target_id | integer | `760` | 신고 대상(댓글) 식별자 | +| post_id | integer | `237` | 신고 대상 댓글이 속한 게시글 식별자 | +| content | null | `null` | 신고 대상 본문 (상세 조회 라우트에서만 채워지며, 접수 응답에서는 null) | +| content_mode | string | `text` | 신고 대상 본문 편집 모드 (html / text) | +| content_preview | string | `API 문서 샘플 댓글입니다.` | 신고 대상 본문 미리보기 (앞 100자 — 목록/접수 응답용) | +| author | object | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자","email":"apidoc-sample-user@example.com","is_guest":false}` | 신고 대상의 작성자 (비회원이면 `is_guest: true`, name 은 `비회원`) | +| reporter | object \| null | `{"uuid":"…","name":"신고자","email":"reporter@example.com","is_guest":false}` | 최초 신고자 (첫 번째 신고 로그 기준) | +| reason_type | string | `spam` | 대표 신고 사유 유형 (abuse, hate_speech, spam, copyright, privacy, misinformation, sexual, violence, other) | +| reason_type_label | string | `스팸/광고` | 대표 신고 사유의 표시 라벨 | +| status | string | `pending` | 신고 케이스 처리 상태 (ReportStatus Enum 값 — 접수 직후 `pending`) | +| status_label | string | `대기` | 처리 상태의 표시 라벨 | +| status_variant | string | `warning` | 처리 상태의 UI 배지 변형값 | +| processor | null | `null` | 처리 관리자 (uuid/name — 미처리 시 null) | +| processed_at | null | `null` | 처리 일시 (미처리 시 null) | +| metadata | null | `null` | 신고 메타(IP/User-Agent) — 상세 조회 라우트에서만 노출, 접수 응답에서는 null | +| report_count | null | `null` | 케이스 누적 신고 건수 (목록 그룹 조회에서만 채워짐) | +| last_reported_at | string | `2026-07-08 10:41:34` | 마지막 신고 접수 일시 (재접수 시 갱신) | +| is_reactivated | boolean | `false` | 반려/중단 후 재접수된 케이스인지 여부 | +| target_status | null | `null` | 신고 대상의 현재 상태 (목록 그룹 조회에서만 채워짐) | +| target_trigger_type | null | `null` | 신고 대상 상태 변경 주체 (목록 그룹 조회에서만 채워짐) | +| target_status_label | null | `null` | 신고 대상 상태 표시 라벨 (목록 그룹 조회에서만 채워짐) | +| created_at | string | `2026-07-08 10:41:34` | 신고 케이스 생성 일시 | +| updated_at | string | `2026-07-08 10:41:34` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 신고의 신고자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_view":false,"can_manage":false}` | 현재 사용자의 신고 조회/관리 권한 맵 (`sirsoft-board.reports.view` / `.manage`) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "신고가 접수되었습니다.", + "data": { + "id": 1, + "board_id": 1, + "board": { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "title": null, + "current_status": null, + "deleted_at": null + }, + "target_type": "comment", + "target_type_label": "댓글", + "target_id": 1, + "post_id": 1, + "content": null, + "content_mode": "text", + "content_preview": "API 문서 샘플 댓글입니다.", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "is_guest": false + }, + "reporter": null, + "reason_type": null, + "reason_type_label": null, + "status": "pending", + "status_label": "대기", + "status_variant": "warning", + "processor": null, + "processed_at": null, + "metadata": null, + "report_count": null, + "last_reported_at": "2026-07-08 10:41:34", + "is_reactivated": false, + "target_status": null, + "target_trigger_type": null, + "target_status_label": null, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34", + "is_owner": true, + "abilities": { + "can_view": false, + "can_manage": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 게시판의 신고 기능이 꺼진 경우(`이 게시판은 신고 기능이 비활성화되어 있습니다.`), 본인이 작성한 댓글 신고(`본인이 작성한 글은 신고할 수 없습니다.`), 블라인드/삭제된 대상 신고(`신고할 수 없는 대상입니다.`) | +| 404 | Not Found | 게시판 슬러그에 해당하는 게시판이 없는 경우 | +| 409 | Conflict | 동일 대상에 이미 신고한 이력이 있는 경우 (`이미 신고한 내역이 있습니다.`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 신고 접수 중 예외 (`신고 접수에 실패했습니다.`) | @@ -2336,18 +2735,46 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| verified | boolean | `true` | 비밀번호 검증 성공 여부 (성공 응답에서는 항상 true) | +| comment_id | integer | `760` | 검증 대상 댓글 식별자 | +| verification_token | string | `G7xk3mQ8pRt1vZ...` (32자 랜덤 문자열) | 이후 수정/삭제 요청에서 비밀번호 대신 사용하는 임시 검증 토큰 | +| expires_at | string | `2026-07-08T11:41:34+09:00` | 검증 토큰 만료 시각 (ISO 8601 — 발급 후 1시간) | + +> 참고: 이 엔드포인트는 요청 본문에 `password`(문자열, 필수)를 함께 받습니다 (컨트롤러의 인라인 검증). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "비밀번호가 확인되었습니다.", + "data": { + "verified": true, + "comment_id": 1, + "verification_token": "G7xk3mQ8pRt1vZaB4cD6eF8gH0iJ2kL4", + "expires_at": "2026-07-08T11:41:34+09:00" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 대상이 비회원 댓글이 아닌 경우 (회원 댓글은 비밀번호 검증 대상 아님) | +| 401 | Unauthorized | 비밀번호가 일치하지 않는 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 댓글이 없는 경우 (`댓글을 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | `password` 누락 등 검증 실패 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 검증 처리 중 예외 | @@ -2548,19 +2975,145 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 게시글 = `PostResource`, 상세 조회(`GET /boards/{slug}/posts/{id}`)와 동일 스키마)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `237` | 생성된 게시글 기본 키 | +| category | null | `null` | 게시글 분류(카테고리) 이름 (미지정 시 null) | +| author | object | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자","email":"…","avatar":null,"status":"active","status_label":"활성","is_guest":false}` | 작성자 정보 (비회원이면 `is_guest: true`) | +| is_notice | boolean | `false` | 공지 게시글 여부 | +| is_secret | boolean | `false` | 비밀글 여부 (게시판 `secret_mode` 가 always 이면 강제 true) | +| content_mode | string | `html` | 본문 편집 모드 (html: WYSIWYG/HTML, text: 일반 텍스트) | +| is_new | boolean | `true` | 신규(NEW) 표시 대상 여부 (게시판 `new_display_hours` 기준) | +| status | string | `published` | 게시 상태 (published / blinded / deleted — PostStatus Enum) | +| status_label | string | `게시됨` | 게시 상태 표시 라벨 | +| view_count | integer | `0` | 조회수 (생성 직후 0) | +| comment_count | integer | `0` | 댓글 수 (집계) | +| reply_count | integer | `0` | 답변글 수 (집계) | +| attachment_count | integer | `0` | 첨부 수 (집계) | +| has_attachment | boolean | `false` | 첨부 보유 여부 | +| thumbnail | null | `null` | 썸네일 이미지 URL (첫 이미지 첨부 — 없으면 null) | +| parent_id | null | `null` | 답변글의 원글 식별자 (원글이면 null) | +| depth | integer | `0` | 계층 트리 깊이 (0 = 원글) | +| is_reply | boolean | `false` | 답변글 여부 | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `방금 전` | `created_at` 의 표시용 포맷 문자열 | +| is_author | boolean | `true` | 현재 사용자가 작성자인지 여부 | +| is_guest_post | boolean | `false` | 비회원 작성 게시글 여부 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| content | string | `

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

` | 본문 내용 | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 작성자 UUID (비회원이면 null) | +| trigger_type | string \| null | `null` | 상태 변경/삭제를 유발한 주체 (user/admin/report/system/auto_hide/cascade — TriggerType Enum, 생성 직후 null) | +| updated_at | string | `2026-07-08 10:41:34` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| ip_address | string \| null | `null` | 작성 IP (admin.manage 권한 보유자에게만 노출, 그 외 null) | +| action_logs | array \| null | `null` | 블라인드/복원/삭제 등 운영 처리 이력 (admin.manage 권한 보유자만) | +| board | object | `{"id":1,"slug":"apidoc-sample-board","name":"API 문서 샘플 게시판","type":"basic", …}` | 게시글이 속한 게시판 정보 + 댓글/답변/신고/조회수 설정 및 신고 유형 목록 | +| navigation | null | `null` | 이전/다음 글 정보 (별도 navigation 엔드포인트로 비동기 로딩) | +| parent | null | `null` | 답변글의 원글 객체 (원글이면 null) | +| comments | null | `null` | 댓글 목록 (생성 응답에서는 관계 미로드 → null) | +| attachments | null | `null` | 첨부파일 목록 (생성 응답에서는 관계 미로드 → null) | +| replies | null | `null` | 답변글 목록 (관계 미로드 시 null) | +| is_already_reported | boolean | `false` | 현재 사용자가 이 게시글을 이미 신고했는지 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":false,"can_read_comments":true,"can_write_comments":true,"can_upload":true,"can_download":true,"can_manage":false,"can_access_admin":false}` | 현재 사용자의 게시판별 세부 권한 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "게시글이 등록되었습니다.", + "data": { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": false, + "content_mode": "html", + "is_new": true, + "status": "published", + "status_label": "게시됨", + "view_count": 0, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": null, + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "방금 전", + "is_author": true, + "is_guest_post": false, + "title": "API 문서 샘플 게시글", + "content": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "trigger_type": null, + "updated_at": "2026-07-08 10:41:34", + "deleted_at": null, + "ip_address": null, + "action_logs": null, + "board": { + "id": 1, + "slug": "apidoc-sample-board", + "name": "API 문서 샘플 게시판", + "type": "basic", + "use_comment": true, + "use_reply": true, + "use_report": true, + "show_view_count": true, + "max_reply_depth": 5, + "max_comment_depth": 10, + "report_types": [ + { "value": "abuse", "label": "욕설/비방" }, + { "value": "spam", "label": "스팸/광고" } + ] + }, + "navigation": null, + "parent": null, + "comments": null, + "attachments": null, + "replies": null, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": false, + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": false, + "can_access_admin": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write`)이 없는 경우, 게시판이 파일 업로드를 허용하지 않는데 파일을 첨부한 경우(`이 게시판은 파일 업로드가 허용되지 않습니다.`), 첨부 업로드 권한이 없는 경우, 비밀글 기능이 꺼진 게시판에 `is_secret=true` 로 요청한 경우 | +| 404 | Not Found | 게시판 슬러그에 해당하는 활성 게시판이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 제목/본문 길이, 금지어, 작성 쿨다운 등) | +| 500 | Internal Server Error | 게시글 저장 중 예외 (`게시글 등록에 실패했습니다.`) | @@ -2783,7 +3336,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `success('...posts.delete_success')` 를 데이터 없이 호출하며, 삭제는 소프트 삭제입니다._ **응답 예시** @@ -2803,8 +3356,9 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write| sirsoft-board.{slug}.manager`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write\|sirsoft-board.{slug}.manager`)이 없거나, 작성자 본인·게시판 관리자·비회원 글 검증(토큰/비밀번호) 중 어느 조건도 만족하지 않는 경우 (`게시글 삭제 권한이 없습니다.`) | +| 404 | Not Found | 활성 게시판 또는 게시글이 없는 경우 | +| 500 | Internal Server Error | 삭제 처리 중 예외 (`게시글 삭제에 실패했습니다.`) | @@ -3257,8 +3811,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write| sirsoft-board.{slug}.manager`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write\|sirsoft-board.{slug}.manager`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -3357,19 +3910,124 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 검증 성공 시 `password_verified` 플래그가 설정되어 게시글 상세(`PostResource`)를 그대로 반환합니다 — 즉 `GET /boards/{slug}/posts/{id}` 와 동일 스키마이며, 차이는 비밀글이라도 `content` 와 `attachments` 가 채워진다는 점입니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `237` | 게시글 기본 키 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| content | string | `

비밀글 본문입니다.

` | 본문 내용 (비밀번호 검증 성공으로 노출됨) | +| content_mode | string | `html` | 본문 편집 모드 (html / text) | +| category | null | `null` | 게시글 분류 | +| author | object | `{"uuid":null,"name":"비회원 작성자","email":null,"avatar":null,"status":null,"status_label":null,"is_guest":true}` | 작성자 정보 | +| is_secret | boolean | `true` | 비밀글 여부 | +| is_notice | boolean | `false` | 공지 여부 | +| status | string | `published` | 게시 상태 (PostStatus Enum 값) | +| status_label | string | `게시됨` | 게시 상태 표시 라벨 | +| view_count | integer | `43` | 조회수 | +| comment_count | integer | `0` | 댓글 수 (집계) | +| reply_count | integer | `0` | 답변글 수 (집계) | +| attachment_count | integer | `1` | 첨부 수 (집계) | +| has_attachment | boolean | `true` | 첨부 보유 여부 | +| attachments | array | `[{"id":155,"hash":"apidocsmpl1","original_filename":"apidoc.pdf", …}]` | 첨부파일 목록 (검증 성공으로 노출됨) | +| comments | array | `[]` | 댓글 목록 (관계 로드 시) | +| parent_id | null | `null` | 답변글의 원글 식별자 | +| depth | integer | `0` | 계층 트리 깊이 | +| is_reply | boolean | `false` | 답변글 여부 | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | 표시용 일시 포맷 | +| updated_at | string | `2026-07-08 10:41:34` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 | +| is_author | boolean | `false` | 현재 사용자가 작성자인지 여부 | +| is_guest_post | boolean | `true` | 비회원 작성 게시글 여부 | +| is_owner | boolean | `false` | 현재 인증 사용자가 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":false, …}` | 현재 사용자의 게시판별 세부 권한 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "비밀번호가 확인되었습니다.", + "data": { + "id": 1, + "title": "API 문서 샘플 비밀글", + "content": "

비밀글 본문입니다.

", + "content_mode": "html", + "category": null, + "author": { + "uuid": null, + "name": "비회원 작성자", + "email": null, + "avatar": null, + "status": null, + "status_label": null, + "is_guest": true + }, + "is_secret": true, + "is_notice": false, + "status": "published", + "status_label": "게시됨", + "view_count": 43, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 1, + "has_attachment": true, + "attachments": [ + { + "id": 155, + "hash": "apidocsmpl1", + "original_filename": "apidoc.pdf", + "mime_type": "application/pdf", + "size": 102400, + "size_formatted": "100 KB", + "collection": "attachments", + "order": 0, + "download_url": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1", + "preview_url": null, + "is_image": false, + "meta": null + } + ], + "comments": [], + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "updated_at": "2026-07-08 10:41:34", + "deleted_at": null, + "is_author": false, + "is_guest_post": true, + "is_owner": false, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": false, + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": false, + "can_access_admin": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 401 | Unauthorized | 비밀번호가 일치하지 않는 경우 (`비밀번호가 일치하지 않습니다.` — Service 의 verifyPassword 가 반환한 에러 키/코드) | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 활성 게시판 또는 게시글이 없는 경우 | +| 422 | Unprocessable Entity | `password` 누락 등 검증 실패 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 검증 처리 중 예외 (`비밀번호 검증에 실패했습니다.`) | @@ -3406,19 +4064,43 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| verified | boolean | `true` | 비밀번호 검증 성공 여부 (성공 응답에서는 항상 true) | +| post_id | integer | `237` | 검증 대상 게시글 식별자 | +| verification_token | string | `G7xk3mQ8pRt1vZ...` (32자 랜덤 문자열) | 이후 수정/삭제 요청에서 비밀번호 대신 사용하는 임시 검증 토큰 (캐시에 저장, 1회 소비) | +| expires_at | string | `2026-07-08T11:41:34+09:00` | 검증 토큰 만료 시각 (PostService::storeDeleteVerifyToken 산출) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "비밀번호가 확인되었습니다.", + "data": { + "verified": true, + "post_id": 1, + "verification_token": "G7xk3mQ8pRt1vZaB4cD6eF8gH0iJ2kL4", + "expires_at": "2026-07-08T11:41:34+09:00" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 401 | Unauthorized | 비밀번호가 일치하지 않는 경우 (Service 의 verifyPassword 가 반환한 에러 키/코드) | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 활성 게시판 또는 게시글이 없는 경우 | +| 422 | Unprocessable Entity | `password` 누락 등 검증 실패 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 검증 처리 중 예외 (`비밀번호 검증에 실패했습니다.`) | @@ -3561,19 +4243,88 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 댓글 = `CommentResource`, 댓글 목록(`GET .../comments`) 항목과 동일 스키마)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `760` | 생성된 댓글 기본 키 | +| post_id | integer | `237` | 댓글이 속한 게시글 식별자 | +| parent_id | integer \| null | `null` | 대댓글의 상위 댓글 식별자 (최상위 댓글이면 null) | +| content | string | `API 문서 샘플 댓글입니다.` | 댓글 본문 | +| author | object | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자","email":"…","avatar":null,"status":"active","status_label":"활성","is_guest":false}` | 작성자 정보 (비회원이면 `is_guest: true`) | +| is_secret | boolean | `false` | 비밀 댓글 여부 | +| status | string | `published` | 게시 상태 (published / blinded / deleted — PostStatus Enum 값) | +| status_label | string | `게시됨` | 게시 상태 표시 라벨 | +| depth | integer | `0` | 대댓글 계층 깊이 (0 = 최상위) | +| replies_count | integer | `0` | 이 댓글에 달린 대댓글 수 (생성 직후 0) | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `방금 전` | 표시용 일시 포맷 | +| updated_at | string | `2026-07-08 10:41:34` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| is_cascade_deleted | boolean | `false` | 게시글 삭제로 함께 숨겨진(cascade) 댓글인지 여부 | +| ip_address | string \| null | `null` | 작성 IP (admin.manage 권한 보유자에게만 노출) | +| action_logs | array \| null | `null` | 블라인드/복원/삭제 등 운영 처리 이력 (admin.manage 권한 보유자만) | +| is_author | boolean | `true` | 현재 사용자가 작성자인지 여부 | +| is_guest_comment | boolean | `false` | 비회원 작성 댓글 여부 | +| is_already_reported | boolean | `false` | 현재 사용자가 이 댓글을 이미 신고했는지 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_write":true}` | 현재 사용자의 댓글 작성 권한 맵 (사용자 라우트는 `can_write` 만 노출) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "댓글이 등록되었습니다.", + "data": { + "id": 1, + "post_id": 1, + "parent_id": null, + "content": "API 문서 샘플 댓글입니다.", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_secret": false, + "status": "published", + "status_label": "게시됨", + "depth": 0, + "replies_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "방금 전", + "updated_at": "2026-07-08 10:41:34", + "deleted_at": null, + "is_cascade_deleted": false, + "ip_address": null, + "action_logs": null, + "is_author": true, + "is_guest_comment": false, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_write": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write`)이 없거나, 게시판의 댓글 기능이 꺼진 경우 (`이 게시판은 댓글 기능이 비활성화되어 있습니다.`) | +| 404 | Not Found | 게시판 슬러그에 해당하는 게시판이 없는 경우 (`게시판을 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 댓글 길이, 금지어, 작성 쿨다운 등) | +| 500 | Internal Server Error | 댓글 저장 중 예외 (`댓글 등록에 실패했습니다.`) | @@ -3605,7 +4356,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `success('...comment.delete_success')` 를 데이터 없이 호출하며, 삭제는 소프트 삭제입니다._ **응답 예시** @@ -3625,8 +4376,9 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write| sirsoft-board.{slug}.manager`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write\|sirsoft-board.{slug}.manager`)이 없거나, 게시판의 댓글 기능이 꺼진 경우, 또는 작성자 본인·게시판 관리자·비회원 댓글 비밀번호 확인 중 어느 조건도 만족하지 않는 경우 (`댓글 삭제 권한이 없습니다.`) | +| 404 | Not Found | 게시판 또는 댓글이 없는 경우 (`댓글을 찾을 수 없습니다.`) | +| 500 | Internal Server Error | 삭제 처리 중 예외 (`댓글 삭제에 실패했습니다.`) | @@ -3660,20 +4412,90 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 댓글 = `CommentResource`, 댓글 목록 항목과 동일 스키마)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `760` | 댓글 기본 키 | +| post_id | integer | `237` | 댓글이 속한 게시글 식별자 | +| parent_id | integer \| null | `null` | 대댓글의 상위 댓글 식별자 | +| content | string | `수정된 댓글 본문입니다.` | 수정된 댓글 본문 | +| author | object | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자","email":"…","avatar":null,"status":"active","status_label":"활성","is_guest":false}` | 작성자 정보 | +| is_secret | boolean | `false` | 비밀 댓글 여부 | +| status | string | `published` | 게시 상태 (PostStatus Enum 값) | +| status_label | string | `게시됨` | 게시 상태 표시 라벨 | +| depth | integer | `0` | 대댓글 계층 깊이 | +| replies_count | integer | `0` | 대댓글 수 (집계) | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | 표시용 일시 포맷 | +| updated_at | string | `2026-07-08 11:02:10` | 최종 수정 일시 (수정 후 갱신됨) | +| deleted_at | null | `null` | 소프트 삭제 일시 | +| is_cascade_deleted | boolean | `false` | 게시글 삭제로 함께 숨겨진(cascade) 댓글인지 여부 | +| ip_address | string \| null | `null` | 작성 IP (admin.manage 권한 보유자에게만 노출) | +| action_logs | array \| null | `null` | 운영 처리 이력 (admin.manage 권한 보유자만) | +| is_author | boolean | `true` | 현재 사용자가 작성자인지 여부 | +| is_guest_comment | boolean | `false` | 비회원 작성 댓글 여부 | +| is_already_reported | boolean | `false` | 현재 사용자가 이 댓글을 이미 신고했는지 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_write":true}` | 현재 사용자의 댓글 작성 권한 맵 | + +> 참고: 비회원 댓글 수정 시 함께 보내는 `password` 는 소유 검증용으로만 사용되며 저장 대상에서 제외됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "댓글이 수정되었습니다.", + "data": { + "id": 1, + "post_id": 1, + "parent_id": null, + "content": "수정된 댓글 본문입니다.", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_secret": false, + "status": "published", + "status_label": "게시됨", + "depth": 0, + "replies_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "updated_at": "2026-07-08 11:02:10", + "deleted_at": null, + "is_cascade_deleted": false, + "ip_address": null, + "action_logs": null, + "is_author": true, + "is_guest_comment": false, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_write": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write| sirsoft-board.{slug}.manager`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write\|sirsoft-board.{slug}.manager`)이 없거나, 게시판의 댓글 기능이 꺼진 경우, 또는 작성자 본인·게시판 관리자·비회원 댓글 비밀번호 확인 중 어느 조건도 만족하지 않는 경우 (`댓글 수정 권한이 없습니다.`) | +| 404 | Not Found | 게시판 또는 댓글이 없는 경우 (`댓글을 찾을 수 없습니다.`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 수정 처리 중 예외 (`댓글 수정에 실패했습니다.`) | @@ -3712,19 +4534,111 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (접수된 신고 케이스 = `ReportResource` — 댓글 신고 응답과 동일 스키마이며 `target_type` 만 `post` 로 다릅니다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 신고 케이스 기본 키 | +| board_id | integer | `1` | 신고가 접수된 게시판 식별자 | +| board | object | `{"id":1,"name":"API 문서 샘플 게시판","slug":"apidoc-sample-board","title":"API 문서 샘플 게시글","current_status":null,"deleted_at":null}` | 게시판 정보 + 신고 대상 제목/현재 상태 | +| target_type | string | `post` | 신고 대상 유형 (`post` 게시글 — ReportType Enum 값) | +| target_type_label | string | `게시글` | 신고 대상 유형 표시 라벨 | +| target_id | integer | `237` | 신고 대상(게시글) 식별자 | +| post_id | integer | `237` | 신고 대상 게시글 식별자 (게시글 신고에서는 `target_id` 와 동일) | +| content | null | `null` | 신고 대상 본문 (상세 조회 라우트에서만 채워짐) | +| content_mode | string | `html` | 신고 대상 본문 편집 모드 (html / text) | +| content_preview | string | `API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.` | 신고 대상 본문 미리보기 (앞 100자) | +| author | object | `{"uuid":"a234c2b1-…","name":"API 문서 샘플 사용자","email":"…","is_guest":false}` | 신고 대상 게시글의 작성자 | +| reporter | object \| null | `{"uuid":"…","name":"신고자","email":"reporter@example.com","is_guest":false}` | 최초 신고자 (첫 번째 신고 로그 기준) | +| reason_type | string | `spam` | 대표 신고 사유 유형 (abuse, hate_speech, spam, copyright, privacy, misinformation, sexual, violence, other) | +| reason_type_label | string | `스팸/광고` | 대표 신고 사유 표시 라벨 | +| status | string | `pending` | 신고 케이스 처리 상태 (접수 직후 `pending`) | +| status_label | string | `대기` | 처리 상태 표시 라벨 | +| status_variant | string | `warning` | 처리 상태 UI 배지 변형값 | +| processor | null | `null` | 처리 관리자 (uuid/name — 미처리 시 null) | +| processed_at | null | `null` | 처리 일시 (미처리 시 null) | +| metadata | null | `null` | 신고 메타(IP/User-Agent) — 상세 조회 라우트에서만 노출 | +| report_count | null | `null` | 케이스 누적 신고 건수 (목록 그룹 조회에서만 채워짐) | +| last_reported_at | string | `2026-07-08 10:41:34` | 마지막 신고 접수 일시 | +| is_reactivated | boolean | `false` | 반려/중단 후 재접수된 케이스인지 여부 | +| target_status | null | `null` | 신고 대상 현재 상태 (목록 그룹 조회에서만 채워짐) | +| target_trigger_type | null | `null` | 신고 대상 상태 변경 주체 (목록 그룹 조회에서만 채워짐) | +| target_status_label | null | `null` | 신고 대상 상태 표시 라벨 (목록 그룹 조회에서만 채워짐) | +| created_at | string | `2026-07-08 10:41:34` | 신고 케이스 생성 일시 | +| updated_at | string | `2026-07-08 10:41:34` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 신고의 신고자인지 여부 | +| abilities | object | `{"can_view":false,"can_manage":false}` | 현재 사용자의 신고 조회/관리 권한 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "신고가 접수되었습니다.", + "data": { + "id": 1, + "board_id": 1, + "board": { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "title": "API 문서 샘플 게시글", + "current_status": null, + "deleted_at": null + }, + "target_type": "post", + "target_type_label": "게시글", + "target_id": 1, + "post_id": 1, + "content": null, + "content_mode": "html", + "content_preview": "API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "is_guest": false + }, + "reporter": null, + "reason_type": null, + "reason_type_label": null, + "status": "pending", + "status_label": "대기", + "status_variant": "warning", + "processor": null, + "processed_at": null, + "metadata": null, + "report_count": null, + "last_reported_at": "2026-07-08 10:41:34", + "is_reactivated": false, + "target_status": null, + "target_trigger_type": null, + "target_status_label": null, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34", + "is_owner": true, + "abilities": { + "can_view": false, + "can_manage": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 게시판의 신고 기능이 꺼진 경우(`이 게시판은 신고 기능이 비활성화되어 있습니다.`), 본인이 작성한 글 신고(`본인이 작성한 글은 신고할 수 없습니다.`), 블라인드/삭제된 대상 신고(`신고할 수 없는 대상입니다.`) | +| 404 | Not Found | 게시판 슬러그에 해당하는 게시판이 없는 경우 | +| 409 | Conflict | 동일 대상에 이미 신고한 이력이 있는 경우 (`이미 신고한 내역이 있습니다.`) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 신고 접수 중 예외 (`신고 접수에 실패했습니다.`) | diff --git a/modules/_bundled/sirsoft-board/docs/api/reports.md b/modules/_bundled/sirsoft-board/docs/api/reports.md index 51dd3448..951d84ae 100644 --- a/modules/_bundled/sirsoft-board/docs/api/reports.md +++ b/modules/_bundled/sirsoft-board/docs/api/reports.md @@ -217,11 +217,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| affected_count | integer | `3` | 실제로 상태가 변경된 신고 건수 (영구삭제·동일 상태 등 전환 불가 건은 제외) | +| restored_count | integer | `1` | 상태 변경(반려 등)에 따라 공개 상태로 복구된 대상 콘텐츠 건수 | +| manual_blind_restored | integer | `1` | 그중 관리자 수동 블라인드(`admin` 트리거)였다가 함께 복구된 건수 | +| status_label | string | `반려` | 전환된 신고 상태의 사람이 읽는 라벨 (`ReportStatus::label()` 산물) | +| message | string | `3개 신고를 반려(으)로 변경했습니다.` | 화면에 그대로 노출 가능한 안내 문구 (봉투 최상위 `message` 와 동일 값) | **응답 예시** - +```json +{ + "success": true, + "message": "3개 신고를 반려(으)로 변경했습니다. 수동 블라인드 처리된 1건이 함께 복구되었습니다.", + "data": { + "affected_count": 3, + "restored_count": 1, + "manual_blind_restored": 1, + "status_label": "반려", + "message": "3개 신고를 반려(으)로 변경했습니다. 수동 블라인드 처리된 1건이 함께 복구되었습니다." + } +} +``` **에러 응답** @@ -230,6 +250,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.reports.manage`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 일괄 상태 변경 처리 중 오류가 발생한 경우 (`sirsoft-board::messages.reports.bulk_status_update_failed`) | @@ -268,11 +289,57 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| status_counts | object | `{"pending":{"count":2,"label":"접수"},"deleted":{"count":1,"label":"영구삭제"}}` | 선택된 신고들의 현재 상태별 집계 맵. 키는 상태 코드(pending/review/rejected/suspended/deleted), 값은 `{count, label}` | +| changeable_status_counts | object | `{"pending":{"count":2,"label":"접수"}}` | `status_counts` 에서 영구삭제(deleted)와 전환 대상 상태(`target_status`)를 제외한 집계 (실제로 변경될 항목만) | +| total_count | integer | `3` | 요청한 `ids` 의 총 개수 | +| changeable_count | integer | `2` | 실제로 상태 변경이 가능한 건수 (`total_count` − `unchangeable_count`) | +| deleted_count | integer | `1` | 선택 항목 중 영구삭제(deleted) 상태로 변경 불가한 건수 | +| same_status_count | integer | `0` | 선택 항목 중 이미 `target_status` 와 동일해 변경이 무의미한 건수 (`target_status` 가 deleted 이면 0) | +| unchangeable_count | integer | `1` | 변경 불가 총 건수 (`deleted_count` + `same_status_count`) | +| all_deleted | boolean | `false` | 선택 항목이 모두 영구삭제 상태인지 여부 | +| all_unchangeable | boolean | `false` | 선택 항목 전부가 변경 불가인지 여부 (`changeable_count === 0`) | +| target_status_label | string \| null | `반려` | `target_status` 의 사람이 읽는 라벨. `target_status` 미지정 시 null | +| restorable_blind_count | integer | `1` | `target_status` 가 `rejected` 일 때, 반려 처리로 공개 상태로 복구될 블라인드 대상 건수 (그 외 상태에서는 0) | **응답 예시** - +```json +{ + "success": true, + "message": "상태별 건수를 조회했습니다.", + "data": { + "status_counts": { + "pending": { + "count": 2, + "label": "접수" + }, + "deleted": { + "count": 1, + "label": "영구삭제" + } + }, + "changeable_status_counts": { + "pending": { + "count": 2, + "label": "접수" + } + }, + "total_count": 3, + "changeable_count": 2, + "deleted_count": 1, + "same_status_count": 0, + "unchangeable_count": 1, + "all_deleted": false, + "all_unchangeable": false, + "target_status_label": "반려", + "restorable_blind_count": 1 + } +} +``` **에러 응답** @@ -281,6 +348,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.reports.view`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 상태별 건수 집계 중 오류가 발생한 경우 (`sirsoft-board::messages.reports.status_counts_failed`) | @@ -310,7 +378,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** @@ -573,20 +641,109 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ReportResource` — 상태 변경 후 재조회한 신고 케이스)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| board_id | integer \| null | `1` | 게시판 ID (게시판 삭제 시 null) | +| board | object | `{"id":1,"name":"공지사항","slug":"notice","title":"...","current_status":"blinded","deleted_at":null}` | 신고 대상이 속한 게시판 정보 + 대상 제목/현재 상태. 게시판 삭제 시 첫 신고 로그의 스냅샷 값으로 폴백합니다. | +| target_type | string | `post` | 신고 대상 타입 (`post`, `comment`) | +| target_type_label | string | `게시글` | `target_type` 값의 사람이 읽는 라벨 | +| target_id | integer | `1` | 신고 대상 ID (동적 테이블의 ID) | +| post_id | integer \| null | `1` | 대상 게시글 ID. 대상이 댓글이면 상위 게시글 ID | +| content | null | `null` | 본문 내용 (상세(`show`) 요청에서만 채워지며, 상태 변경 응답에서는 null) | +| content_mode | string | `html` | 대상 본문의 형식 모드 (`text`/`html`). 스냅샷이 없으면 `text` | +| content_preview | string | `API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.` | 대상 본문의 미리보기 (앞 100자 발췌) | +| author | object | `{"uuid":"...","name":"홍길동","email":"user@example.com","is_guest":false}` | 신고 대상 콘텐츠의 작성자. 회원 정보가 없으면 스냅샷 이름 + `is_guest: true` | +| reporter | object \| null | `{"uuid":"...","name":"신고자","email":"reporter@example.com","is_guest":false}` | 대표 신고자(첫 번째 신고 로그) 정보. 신고 로그가 없으면 null | +| reason_type | string \| null | `abuse` | 대표 신고 사유 코드 (첫 신고 로그의 사유 Enum 값) | +| reason_type_label | string \| null | `욕설/비방` | `reason_type` 의 사람이 읽는 라벨 | +| status | string | `rejected` | 변경된 신고 상태 (`pending`, `review`, `rejected`, `suspended`, `deleted`) | +| status_label | string | `반려` | 상태의 사람이 읽는 라벨 (`ReportStatus::label()`) | +| status_variant | string | `info` | 상태 배지 색상/스타일 변형 키 (`ReportStatus::variant()`) | +| processor | object \| null | `{"uuid":"...","name":"관리자"}` | 상태를 변경한 처리 관리자 (uuid/name). 미지정 시 null | +| processed_at | string \| null | `2026-07-08 10:41:34` | 처리 일시 | +| metadata | null | `null` | 메타데이터 (상세(`show`) 요청에서만 채워지며, 상태 변경 응답에서는 null) | +| report_count | integer \| null | `null` | 케이스 신고 건수 집계. 목록(`paginateGrouped`) 응답에서만 채워지며 단건 재조회에서는 null | +| last_reported_at | string \| null | `2026-07-08 10:41:34` | 마지막 신고 일시 (재접수 시 갱신) | +| is_reactivated | boolean | `false` | 반려/중단 후 재접수된 케이스인지 여부 (`last_activated_at` 존재 여부) | +| target_status | null | `null` | 대상 콘텐츠의 현재 상태. 목록 조회의 서브쿼리로만 주입되며 단건 재조회에서는 null | +| target_trigger_type | null | `null` | 대상 상태를 유발한 트리거 유형. 목록 조회에서만 채워지며 단건 재조회에서는 null | +| target_status_label | null | `null` | `target_status` 의 라벨. `target_status` 가 없으면 null | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| updated_at | string | `2026-07-08 10:42:10` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자(`reporter_id`)인지 여부 | +| abilities | object | `{"can_view":true,"can_manage":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```json +{ + "success": true, + "message": "신고 상태가 변경되었습니다.", + "data": { + "id": 1, + "board_id": 1, + "board": { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "title": "API 문서 샘플 게시글", + "current_status": "published", + "deleted_at": null + }, + "target_type": "post", + "target_type_label": "게시글", + "target_id": 1, + "post_id": 1, + "content": null, + "content_mode": "html", + "content_preview": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "is_guest": false + }, + "reporter": null, + "reason_type": null, + "reason_type_label": null, + "status": "rejected", + "status_label": "반려", + "status_variant": "info", + "processor": { + "uuid": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "name": "관리자" + }, + "processed_at": "2026-07-08 10:42:10", + "metadata": null, + "report_count": null, + "last_reported_at": "2026-07-08 10:41:34", + "is_reactivated": false, + "target_status": null, + "target_trigger_type": null, + "target_status_label": null, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:42:10", + "is_owner": false, + "abilities": { + "can_view": true, + "can_manage": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-board.reports.manage`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.reports.manage`)이 없거나, 권한 스코프(본인만/역할 범위)를 벗어난 신고에 접근한 경우 (`auth.scope_denied`) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 영구삭제(`deleted`) 상태 신고의 상태를 변경하려 한 경우에도 422 (`sirsoft-board::messages.reports.cannot_change_deleted_status`) | +| 500 | Internal Server Error | 상태 변경 처리 중 오류가 발생한 경우 (`sirsoft-board::messages.reports.status_update_failed`) | diff --git a/modules/_bundled/sirsoft-board/docs/api/settings.md b/modules/_bundled/sirsoft-board/docs/api/settings.md index a96bf309..432e3097 100644 --- a/modules/_bundled/sirsoft-board/docs/api/settings.md +++ b/modules/_bundled/sirsoft-board/docs/api/settings.md @@ -363,36 +363,36 @@ HTTP/1.1 200 | notifications | body | array | 아니오 | — | 알림 채널 설정. `channels` 배열의 각 항목에 채널 식별자(id), 활성화 여부(is_active), 정렬 순서(sort_order)를 담아 저장합니다. | | basic_defaults | body | array | 아니오 | — | 기본 설정 카테고리 값. 게시판 타입·페이지당 글 수·정렬·댓글/답글·길이 제한·파일 업로드·기본 권한 등 basic_defaults 하위 키를 저장합니다. `basic_defaults.allowed_extensions` 는 `basic_defaults.use_file_upload` 가 `true` 일 때만 최소 1개가 필수이며, `false`/`null` 이면 검증에서 제외되어 빈 배열도 허용됩니다. `min_title_length`·`min_comment_length`·`new_display_hours` 의 하한은 `config('sirsoft-board.limits')` 선언을 따르며 `0` 을 허용합니다. | | report_policy | body | array | 아니오 | — | 신고 정책 카테고리 값. 자동 숨김 임계치/대상, 일일 신고 한도, 거부 누적 제한, 관리자·작성자 신고 알림 설정을 저장합니다. | -| report_policy.auto_hide_threshold | body | integer | 아니오 | min 0, max 100 | | -| report_policy.auto_hide_target | body | string | 아니오 | `post`, `comment`, `both` | | -| report_policy.daily_report_limit | body | integer | 아니오 | min 0, max 100 | | -| report_policy.rejection_limit_count | body | integer | 아니오 | min 0, max 50 | | -| report_policy.rejection_limit_days | body | integer | 아니오 | min 1, max 365 | | -| report_policy.notify_admin_on_report | body | boolean | 아니오 | — | | -| report_policy.notify_admin_on_report_scope | body | string | 아니오 | `per_case`, `per_report` | | -| report_policy.notify_admin_on_report_channels | body | array | 아니오 | — | | -| report_policy.notify_author_on_report_action | body | boolean | 아니오 | — | | -| report_policy.notify_author_on_report_action_channels | body | array | 아니오 | — | | +| report_policy.auto_hide_threshold | body | integer | 아니오 | min 0, max 100 | 자동 숨김 신고 수 (이 횟수 이상 신고되면 자동 숨김 처리) | +| report_policy.auto_hide_target | body | string | 아니오 | `post`, `comment`, `both` | 자동 숨김 대상 (게시글 / 댓글 / 둘 다) | +| report_policy.daily_report_limit | body | integer | 아니오 | min 0, max 100 | 사용자별 일일 신고 한도 | +| report_policy.rejection_limit_count | body | integer | 아니오 | min 0, max 50 | 신고 기각 한도 (누적 기각 횟수 제한) | +| report_policy.rejection_limit_days | body | integer | 아니오 | min 1, max 365 | 신고 기각 한도 산정 기간(일) | +| report_policy.notify_admin_on_report | body | boolean | 아니오 | — | 신고 접수 시 관리자 알림 발송 여부 | +| report_policy.notify_admin_on_report_scope | body | string | 아니오 | `per_case`, `per_report` | 관리자 신고 알림 발송 단위 (신고 대상 건별 / 개별 신고마다) | +| report_policy.notify_admin_on_report_channels | body | array | 아니오 | — | 관리자 신고 알림 발송 채널 목록 | +| report_policy.notify_author_on_report_action | body | boolean | 아니오 | — | 신고 처리 결과를 작성자에게 알릴지 여부 | +| report_policy.notify_author_on_report_action_channels | body | array | 아니오 | — | 작성자 신고 처리 알림 발송 채널 목록 | | report_permissions | body | array | 아니오 | — | 신고 관리 권한 역할. `view_roles`(조회 역할)와 `manage_roles`(관리 역할)의 역할 식별자 배열이며, 포함 시 설정 저장과 별개로 DB 권한 역할이 동기화됩니다. | -| report_permissions.view_roles | body | array | 아니오 | min 1 | | -| report_permissions.manage_roles | body | array | 아니오 | min 1 | | +| report_permissions.view_roles | body | array | 아니오 | min 1 | 신고 조회 권한 역할 목록 (`report_permissions` 포함 시 필수, 최소 1개) | +| report_permissions.manage_roles | body | array | 아니오 | min 1 | 신고 처리 권한 역할 목록 (`report_permissions` 포함 시 필수, 최소 1개) | | display | body | array | 아니오 | — | 표시 설정 카테고리 값. 날짜 표시 형식(date_display_format: standard/relative) 등을 저장합니다. | -| display.date_display_format | body | string | 아니오 | `standard`, `relative` | | +| display.date_display_format | body | string | 아니오 | `standard`, `relative` | 날짜 표시 형식 (standard: 절대 표기 / relative: "3분 전" 형태의 상대 표기) | | spam_security | body | array | 아니오 | — | 스팸·보안 카테고리 값. 글·댓글·신고 작성 쿨다운 시간(초)과 조회수 캐시 TTL을 저장합니다. | -| spam_security.post_cooldown_seconds | body | integer | 아니오 | min 0, max 3600 | | -| spam_security.comment_cooldown_seconds | body | integer | 아니오 | min 0, max 3600 | | -| spam_security.report_cooldown_seconds | body | integer | 아니오 | min 0, max 3600 | | -| spam_security.view_count_cache_ttl | body | integer | 아니오 | min 60, max 604800 | | +| spam_security.post_cooldown_seconds | body | integer | 아니오 | min 0, max 3600 | 게시글 작성 쿨다운(초). 도배 방지를 위한 연속 작성 대기 시간 (0이면 제한 없음) | +| spam_security.comment_cooldown_seconds | body | integer | 아니오 | min 0, max 3600 | 댓글 작성 쿨다운(초) | +| spam_security.report_cooldown_seconds | body | integer | 아니오 | min 0, max 3600 | 신고 쿨다운(초) | +| spam_security.view_count_cache_ttl | body | integer | 아니오 | min 60, max 604800 | 조회수 캐시 유효시간(초). 동일 사용자의 중복 조회수 증가를 막는 기간 | | seo | body | array | 아니오 | — | SEO 설정 카테고리 값. 게시판 목록/개별/글 상세 페이지의 메타 제목·설명 템플릿과 각 페이지 SEO 활성화 여부를 저장합니다. | -| seo.meta_boards_title | body | string | 아니오 | max 500 | | -| seo.meta_boards_description | body | string | 아니오 | max 1000 | | -| seo.meta_board_title | body | string | 아니오 | max 500 | | -| seo.meta_board_description | body | string | 아니오 | max 1000 | | -| seo.meta_post_title | body | string | 아니오 | max 500 | | -| seo.meta_post_description | body | string | 아니오 | max 1000 | | -| seo.seo_boards | body | boolean | 아니오 | — | | -| seo.seo_board | body | boolean | 아니오 | — | | -| seo.seo_post_detail | body | boolean | 아니오 | — | | +| seo.meta_boards_title | body | string | 아니오 | max 500 | 게시판 목록 페이지의 메타 제목 템플릿 (예: `{site_name}`) | +| seo.meta_boards_description | body | string | 아니오 | max 1000 | 게시판 목록 페이지의 메타 설명 템플릿 | +| seo.meta_board_title | body | string | 아니오 | max 500 | 개별 게시판 페이지의 메타 제목 템플릿 (예: `{board_name}`) | +| seo.meta_board_description | body | string | 아니오 | max 1000 | 개별 게시판 페이지의 메타 설명 템플릿 (예: `{board_description}`) | +| seo.meta_post_title | body | string | 아니오 | max 500 | 게시글 상세 페이지의 메타 제목 템플릿 (예: `{board_name} - {post_title}`) | +| seo.meta_post_description | body | string | 아니오 | max 1000 | 게시글 상세 페이지의 메타 설명 템플릿 | +| seo.seo_boards | body | boolean | 아니오 | — | 게시판 목록 페이지의 SEO 페이지 생성 활성화 여부 | +| seo.seo_board | body | boolean | 아니오 | — | 개별 게시판 페이지의 SEO 페이지 생성 활성화 여부 | +| seo.seo_post_detail | body | boolean | 아니오 | — | 게시글 상세 페이지의 SEO 페이지 생성 활성화 여부 | **요청 예시** @@ -816,11 +816,62 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 성공(전체 적용 완료) 시와 중단(전체 롤백) 시의 필드 구성이 다릅니다 — 두 경우 모두 HTTP 200 입니다._ + +성공 시 (`rolled_back: false`) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 설정이 적용된 게시판 수 (`BoardService::bulkApplySettings()` 반환값) | +| rolled_back | boolean | `false` | 롤백 발생 여부. 성공 시 항상 `false` | + +중단·롤백 시 (`rolled_back: true`, `BulkApplyAbortedException`) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| rolled_back | boolean | `true` | 적용 도중 실패해 전체 변경이 롤백되었음을 나타냅니다 | +| board | object\|null | `{"board_id":12,"slug":"notice","name":"공지사항"}` | 실패한 게시판 정보(`board_id`, `slug`, `name`). 컬럼 일괄 업데이트(단일 쿼리) 실패 시 게시판 특정이 불가하므로 `null` | +| board.board_id | integer | `12` | 실패한 게시판 ID | +| board.slug | string | `notice` | 실패한 게시판 슬러그 | +| board.name | string | `공지사항` | 실패한 게시판의 현재 로케일 이름 | +| failed_at | integer\|null | `2` | 실패 순번(1-base). 컬럼 업데이트 실패 시 `null` | +| total | integer | `5` | 일괄 적용 대상 게시판 총 수 | **응답 예시** - +성공 시 + +```json +{ + "success": true, + "message": "sirsoft-board::messages.settings.bulk_apply_success", + "data": { + "updated_count": 3, + "rolled_back": false + } +} +``` + +중단·롤백 시 (HTTP 200) + +```json +{ + "success": true, + "message": "sirsoft-board::messages.settings.bulk_apply_aborted", + "data": { + "rolled_back": true, + "board": { + "board_id": 12, + "slug": "notice", + "name": "공지사항" + }, + "failed_at": 2, + "total": 5 + } +} +``` + +> `message` 는 컨트롤러가 넘기는 다국어 키(`sirsoft-board::messages.settings.*`)이며, 현재 모듈 `lang/ko/messages.php` 에 `settings` 그룹이 정의되어 있지 않아 키 문자열이 그대로 노출됩니다(다른 엔드포인트 실측 응답과 동일). **에러 응답** @@ -828,7 +879,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.settings.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `fields` 누락/빈 배열, 허용되지 않은 필드명, `apply_all=false` 인데 `board_ids` 미지정, 존재하지 않는 `board_ids` 항목 등 | +| 500 | Internal Server Error | 일괄 적용 처리 중 롤백 예외 외의 오류가 발생한 경우 (`messages.settings.bulk_apply_failed`) | @@ -856,11 +908,23 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| cleared | boolean | `true` | 캐시 초기화 수행 여부. 성공 시 항상 `true` (ModuleSettings 캐시 + 게시판 캐시 전체 초기화) | **응답 예시** - +```json +{ + "success": true, + "message": "sirsoft-board::messages.settings.clear_cache_success", + "data": { + "cleared": true + } +} +``` **에러 응답** @@ -868,6 +932,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.settings.update`)이 없는 경우 | +| 500 | Internal Server Error | 캐시 초기화 중 오류가 발생한 경우 (`messages.settings.clear_cache_error`) | @@ -897,11 +962,34 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| category | string | `spam_security` | 요청 경로에서 전달된 카테고리명 (그대로 반환) | +| settings | object | `{"post_cooldown_seconds":0,"comment_cooldown_seconds":0,"report_cooldown_seconds":60,"view_count_cache_ttl":86400}` | 해당 카테고리의 설정값 맵 (기본값 + 저장값 병합 결과). 정의되지 않은 카테고리명이면 빈 객체 `{}` | +| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (`can_update` — `sirsoft-board.settings.update` 권한 보유 여부) | **응답 예시** - +```json +{ + "success": true, + "message": "sirsoft-board::messages.settings.fetch_success", + "data": { + "category": "spam_security", + "settings": { + "post_cooldown_seconds": 0, + "comment_cooldown_seconds": 0, + "report_cooldown_seconds": 60, + "view_count_cache_ttl": 86400 + }, + "abilities": { + "can_update": true + } + } +} +``` **에러 응답** @@ -910,6 +998,7 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.settings.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 500 | Internal Server Error | 설정 조회 중 오류가 발생한 경우 (`messages.settings.fetch_failed`) | diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php index 0e71eb38..0af8fe5a 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php @@ -5,12 +5,14 @@ namespace Modules\Sirsoft\Board\Http\Controllers\Admin; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; use Modules\Sirsoft\Board\Exceptions\PostNotFoundException; +use Modules\Sirsoft\Board\Http\Requests\Admin\PostFormDataRequest; +use Modules\Sirsoft\Board\Http\Requests\Admin\PostFormMetaRequest; +use Modules\Sirsoft\Board\Http\Requests\Admin\PostIndexRequest; use Modules\Sirsoft\Board\Http\Requests\BlindPostRequest; use Modules\Sirsoft\Board\Http\Requests\RestorePostRequest; use Modules\Sirsoft\Board\Http\Requests\StorePostRequest; @@ -51,12 +53,11 @@ class PostController extends AdminBaseController /** * 게시글 목록을 조회합니다. * - * @param Request $request HTTP 요청 + * @param PostIndexRequest $request 검증된 목록 조회 요청 * @param string $slug 게시판 슬러그 * @return JsonResponse 게시글 목록 응답 */ - // audit:allow controller-base-request-injection reason: GET 목록 조회. all()로 필터·페이징 파라미터만 읽음 (검증 불필요) - public function index(Request $request, string $slug): JsonResponse + public function index(PostIndexRequest $request, string $slug): JsonResponse { // 권한은 라우트 미들웨어에서 체크됨 (sirsoft-board.{slug}.admin.posts.read) try { @@ -392,12 +393,11 @@ class PostController extends AdminBaseController * - 수정 모드: 기존 게시글 데이터 * - 답변글 모드: 기본값이 설정된 폼 데이터 * - * @param Request $request HTTP 요청 + * @param PostFormDataRequest $request 검증된 폼 데이터 요청 * @param string $slug 게시판 슬러그 * @return JsonResponse 폼 입력 데이터 응답 */ - // audit:allow controller-base-request-injection reason: GET 수정 폼 데이터 조회. 경로 파라미터(slug) + 쿼리만 read-only 참조 (검증 불필요) - public function getFormData(Request $request, string $slug): JsonResponse + public function getFormData(PostFormDataRequest $request, string $slug): JsonResponse { // 권한은 라우트 미들웨어에서 체크됨 (sirsoft-board.{slug}.admin.posts.write) try { @@ -480,12 +480,11 @@ class PostController extends AdminBaseController * * 게시판 정보, 원글 정보, 작성자 정보 등 화면 표시에 필요한 데이터를 반환합니다. * - * @param Request $request HTTP 요청 + * @param PostFormMetaRequest $request 검증된 폼 메타 요청 * @param string $slug 게시판 슬러그 * @return JsonResponse 폼 메타 데이터 응답 */ - // audit:allow controller-base-request-injection reason: GET 작성/수정 폼 메타 조회. 경로 파라미터(slug) + post_id 쿼리만 read-only 참조 (검증 불필요) - public function getFormMeta(Request $request, string $slug): JsonResponse + public function getFormMeta(PostFormMetaRequest $request, string $slug): JsonResponse { // 권한은 라우트 미들웨어에서 체크됨 (sirsoft-board.{slug}.admin.posts.write) try { diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostFormDataRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostFormDataRequest.php new file mode 100644 index 00000000..43c3beca --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostFormDataRequest.php @@ -0,0 +1,37 @@ +> 검증 규칙 + */ + public function rules(): array + { + return [ + 'post_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'parent_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostFormMetaRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostFormMetaRequest.php new file mode 100644 index 00000000..040da68b --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostFormMetaRequest.php @@ -0,0 +1,34 @@ +> 검증 규칙 (요청 파라미터 없음) + */ + public function rules(): array + { + return []; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostIndexRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostIndexRequest.php new file mode 100644 index 00000000..7099a40a --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/PostIndexRequest.php @@ -0,0 +1,38 @@ +> 검증 규칙 + */ + public function rules(): array + { + return [ + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'search' => ['sometimes', 'nullable', 'string', 'max:255'], + ]; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index ed582d6c..8b85c83f 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -175,6 +175,7 @@ - 쿠폰 사용조건의 상품 검색에서 결과가 많을 때 목록 끝까지 스크롤해도 다음 묶음이 더 불러와지지 않던 문제를 수정했습니다. - 관리자가 주문상품을 일괄 구매확정할 때와 구매자가 직접 구매확정할 때, 구매확정 시점이 최초 확정 시각으로 동일하게 보존됩니다 — 이미 확정된 주문을 다시 확정 처리해도 시점이 덮어써지지 않습니다. - 구매자 주문 상세의 결제 정보 영역에 결제 플러그인이 제공하는 공급가액·부가세·영수증 링크·에스크로 구매확정 버튼이 표시되지 않던 문제를 수정했습니다. +- 관리자가 카테고리 이미지를 등록·삭제하거나 순서를 바꾼 뒤 표시되는 안내 문구가 번역된 문장 대신 내부 식별자가 그대로 보이던 문제를 수정했습니다. 이제 사용 중인 언어에 맞는 안내 문구가 표시됩니다. ## [1.0.4] - 2026-07-16 diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md b/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md index c2b1aa0d..91c9fa62 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md @@ -225,7 +225,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('sirsoft-ecommerce::messages.address.deleted')` 를 데이터 인수 없이 호출하므로 `data` 는 `null` 입니다._ **응답 예시** @@ -387,7 +387,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('sirsoft-ecommerce::messages.address.default_set')` 를 데이터 인수 없이 호출하므로 `data` 는 `null` 입니다. 변경된 목록은 배송지 목록 조회(`GET /user/addresses`)로 재조회합니다._ **응답 예시** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md b/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md index bcbc54fa..e168d6d8 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md @@ -192,11 +192,60 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`BrandResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 브랜드의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 브랜드","en":"API Doc Sample Brand"}` | 브랜드 이름 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 브랜드` | `name` 의 현재 로케일 해석 값 (표시용 문자열) | +| slug | string | `apidoc-sample-brand` | URL 친화 식별자 (slug) | +| url | string | `apidoc-sample-brand` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | +| website | string \| null | `null` | 브랜드 공식 웹사이트 URL (미입력 시 null) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | 활성 여부 | +| icon | string | `tag` | SortableMenuList 컴포넌트용 아이콘 식별자 (고정값 `tag`) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| products_count | integer | `0` | 브랜드에 연결된 상품 수 (생성 직후이므로 0) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 생성 직후에는 `creator`/`updater` 관계가 로드되지 않으므로(`whenLoaded`) 응답에서 생략됩니다. **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "브랜드가 등록되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 브랜드", + "en": "API Doc Sample Brand" + }, + "localized_name": "API 문서 샘플 브랜드", + "slug": "apidoc-sample-brand", + "url": "apidoc-sample-brand", + "website": null, + "sort_order": 0, + "is_active": true, + "icon": "tag", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "products_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -294,11 +343,60 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`BrandResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 브랜드의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 브랜드","en":"API Doc Sample Brand"}` | 브랜드 이름 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 브랜드` | `name` 의 현재 로케일 해석 값 (표시용 문자열) | +| slug | string | `apidoc-sample-brand` | URL 친화 식별자 (slug) | +| url | string | `apidoc-sample-brand` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | +| website | string \| null | `https://www.asus.com` | 브랜드 공식 웹사이트 URL (미입력 시 null) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | 활성 여부 | +| icon | string | `tag` | SortableMenuList 컴포넌트용 아이콘 식별자 (고정값 `tag`) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 15:00:16` | 최종 수정 일시 (이번 수정 시각으로 갱신) | +| products_count | integer | `0` | 브랜드에 연결된 상품 수 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 수정 직후에는 `creator`/`updater` 관계가 로드되지 않으므로(`whenLoaded`) 응답에서 생략됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "브랜드가 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 브랜드", + "en": "API Doc Sample Brand" + }, + "localized_name": "API 문서 샘플 브랜드", + "slug": "apidoc-sample-brand", + "url": "apidoc-sample-brand", + "website": "https://www.asus.com", + "sort_order": 0, + "is_active": true, + "icon": "tag", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:16", + "products_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md index 914cd724..3e6a62fe 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md @@ -42,17 +42,34 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `2` | 실제로 삭제된 장바구니 아이템 건수 (`CartService::deleteItems()` 반환값. 성공 메시지의 `:deleted_count` 치환값과 동일) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "2개 상품이 삭제되었습니다.", + "data": { + "deleted_count": 2 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`ids` 누락/빈 배열, 존재하지 않는 장바구니 ID — `error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 삭제 처리 중 예외 발생 (`messages.cart.delete_failed`) | @@ -216,17 +233,104 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[{"id":962,"product_id":201,"quantity":1, …}]` | 이번 요청으로 담긴 장바구니 아이템 목록 (CartItemResource — 아래 "장바구니 아이템 필드" 참조) | +| cart_count | integer | `3` | 담기 후 장바구니에 들어있는 전체 아이템 수 (`CartService::bulkAddToCart()` 반환값) | + +**장바구니 아이템 필드** (`items[]` — CartItemResource) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `962` | 장바구니 아이템 ID (수량/옵션 변경·삭제 시 path 파라미터) | +| product_id | integer | `201` | 담긴 상품 ID | +| product_option_id | integer | `1086` | 선택된 상품 옵션 ID | +| quantity | integer | `1` | 구매 수량 | +| additional_options | array | `[]` | 선택된 추가옵션 해석 결과 (항목별 `additional_option_id`, `value_id`, `group_name`, `name`, `price_adjustment`, `price_adjustment_formatted`, `custom_text`) | +| additional_options_total | integer | `0` | 추가옵션 단위 추가금 합계 (수량 미곱셈) | +| created_at | string | `2026-07-14 10:00:00` | 담은 일시 (사용자 타임존 포맷) | +| updated_at | string | `2026-07-14 10:00:00` | 최종 수정 일시 (사용자 타임존 포맷) | +| product | object\|null | `{"id":201,"name":"티셔츠", …}` | 상품 요약 (`id`, `name`, `product_code`, `thumbnail_url`, `sales_status`, `display_status`) | +| product_option | object\|null | `{"id":1086,"selling_price":19000, …}` | 옵션 요약 (`id`, `option_code`, `option_name`, `option_name_localized`, `option_values`, `option_values_localized`, `list_price`, `list_price_formatted`, `multi_currency_list_price`, `selling_price`, `selling_price_formatted`, `multi_currency_selling_price`, `discount_rate`, `stock_quantity`, `is_active`) | +| available | boolean | `true` | 현재 구매 가능 여부 (`Product::isPurchasable()`) | +| unavailable_reason | string\|null | `sold_out` | 구매 불가 사유 (판매상태 `suspended`/`sold_out`/`coming_soon` 또는 전시상태 `hidden`. 구매 가능 시 `null`) | +| is_shippable_to_selected_country | boolean | `true` | 선택된 배송 국가로 배송 가능한지 (배송정책 기준) | +| selected_shipping_country | string | `KR` | 판정에 사용된 배송 국가 코드 | +| subtotal | integer | `19000` | 표시 소계 = (옵션 판매가 + 추가옵션 단위 합계) × 수량 (옵션이 로드된 경우에만 존재) | +| subtotal_formatted | string | `19,000원` | 소계 통화 포맷 문자열 | +| multi_currency_subtotal | object | `{"KRW":19000}` | 통화별 환산 소계 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "장바구니에 상품이 추가되었습니다.", + "data": { + "items": [ + { + "id": 962, + "product_id": 201, + "product_option_id": 1086, + "quantity": 1, + "additional_options": [], + "additional_options_total": 0, + "created_at": "2026-07-14 10:00:00", + "updated_at": "2026-07-14 10:00:00", + "product": { + "id": 201, + "name": "티셔츠", + "product_code": "P-201", + "thumbnail_url": null, + "sales_status": "on_sale", + "display_status": "visible" + }, + "product_option": { + "id": 1086, + "option_code": "OPT-1086", + "option_name": "색상/사이즈", + "option_name_localized": "색상/사이즈", + "option_values": ["BLACK", "L"], + "option_values_localized": ["블랙", "L"], + "list_price": 25000, + "list_price_formatted": "25,000원", + "multi_currency_list_price": {"KRW": 25000}, + "selling_price": 19000, + "selling_price_formatted": "19,000원", + "multi_currency_selling_price": {"KRW": 19000}, + "discount_rate": 24.0, + "stock_quantity": 50, + "is_active": true + }, + "available": true, + "unavailable_reason": null, + "is_shippable_to_selected_country": true, + "selected_shipping_country": "KR", + "subtotal": 19000, + "subtotal_formatted": "19,000원", + "multi_currency_subtotal": {"KRW": 19000} + } + ], + "cart_count": 3 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 400 | Bad Request | 비회원인데 `X-Cart-Key` 헤더가 없거나 형식(`ck_`+32자 영숫자)이 아닌 경우 | +| 403 | Forbidden | 타인 소유 장바구니 항목 조작 (`CartOperationException: access_denied`) | +| 404 | Not Found | 대상 장바구니 항목 없음 (`CartOperationException: item_not_found`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패 (`error.errors`) 또는 재고 부족·판매 중지·구매 대상 제한·구매 수량 한도 위반 (`CartUnavailableException` — `error` 에 `code: cart_unavailable`, `unavailable_items`, `has_stock_issue`, `has_status_issue`, `has_restriction_issue`, `has_min_qty_issue`, `has_max_qty_issue`), 옵션 없음/타상품 옵션 (`CartOperationException: option_not_found`/`invalid_option`) | +| 500 | Internal Server Error | 담기 처리 중 예외 발생 (`messages.cart.add_failed`) | @@ -278,7 +382,10 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 비회원인데 `X-Cart-Key` 헤더가 없거나 형식(`ck_`+32자 영숫자)이 아닌 경우 (`CartKeyRequest` 검증) | +| 500 | Internal Server Error | 삭제 처리 중 예외 발생 (`messages.cart.delete_failed`) | @@ -388,7 +495,9 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 키 발급 처리 중 예외 발생 (`messages.cart.key_issue_failed` — "장바구니 키 발급에 실패했습니다.") | @@ -416,18 +525,36 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| merged_count | integer | `2` | 비회원 장바구니에서 회원 계정으로 옮겨진 아이템 건수 (`CartService::mergeGuestCartToUser()` 반환값) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 상품이 장바구니로 병합되었습니다.", + "data": { + "merged_count": 2 + } +} +``` + +> `messages.cart.merged` 는 `:count` 플레이스홀더를 갖지만 컨트롤러가 messageParams 를 전달하지 않으므로 응답 메시지에는 치환되지 않은 원문이 그대로 실립니다. 화면 표기는 `data.merged_count` 를 사용하십시오. **에러 응답** -| 상태 | 조건 | 응답 | +| 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | 미로그인 상태이거나 `X-Cart-Key` 헤더가 없음/형식 불일치 | `errors.auth` 또는 `errors.cart_key` | -| 500 | 병합 처리 실패 | `messages.cart.merge_failed` | +| 422 | Unprocessable Entity | 비로그인 상태로 호출(`auth` 오류 — "로그인이 필요합니다."), `X-Cart-Key` 헤더 누락 또는 형식(`ck_`+32자 영숫자) 불일치 (`MergeGuestCartRequest` 검증) | +| 500 | Internal Server Error | 병합 처리 중 예외 발생 (`messages.cart.merge_failed`) | @@ -587,17 +714,30 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "장바구니에서 상품이 삭제되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 타인 소유 장바구니 항목 삭제 시도 (`CartOperationException: access_denied`) | +| 404 | Not Found | `id` 에 해당하는 장바구니 항목이 없는 경우 (`CartOperationException: item_not_found`) | +| 422 | Unprocessable Entity | 비회원인데 `X-Cart-Key` 헤더가 없거나 형식(`ck_`+32자 영숫자)이 아닌 경우 (`DeleteCartItemRequest` 검증) | +| 500 | Internal Server Error | 삭제 처리 중 예외 발생 (`messages.cart.delete_failed`) | @@ -641,18 +781,77 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| item | object | `{"id":962,"product_option_id":1090, …}` | 옵션·수량이 반영된 장바구니 아이템 1건 (CartItemResource — 필드 구성은 POST `/cart` 의 "장바구니 아이템 필드" 표와 동일) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "옵션이 변경되었습니다.", + "data": { + "item": { + "id": 962, + "product_id": 201, + "product_option_id": 1090, + "quantity": 2, + "additional_options": [], + "additional_options_total": 0, + "created_at": "2026-07-14 10:00:00", + "updated_at": "2026-07-14 10:12:00", + "product": { + "id": 201, + "name": "티셔츠", + "product_code": "P-201", + "thumbnail_url": null, + "sales_status": "on_sale", + "display_status": "visible" + }, + "product_option": { + "id": 1090, + "option_code": "OPT-1090", + "option_name": "색상/사이즈", + "option_name_localized": "색상/사이즈", + "option_values": ["WHITE", "M"], + "option_values_localized": ["화이트", "M"], + "list_price": 25000, + "list_price_formatted": "25,000원", + "multi_currency_list_price": {"KRW": 25000}, + "selling_price": 19000, + "selling_price_formatted": "19,000원", + "multi_currency_selling_price": {"KRW": 19000}, + "discount_rate": 24.0, + "stock_quantity": 40, + "is_active": true + }, + "available": true, + "unavailable_reason": null, + "is_shippable_to_selected_country": true, + "selected_shipping_country": "KR", + "subtotal": 38000, + "subtotal_formatted": "38,000원", + "multi_currency_subtotal": {"KRW": 38000} + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 타인 소유 장바구니 항목 조작 (`CartOperationException: access_denied`) | +| 404 | Not Found | `id` 에 해당하는 장바구니 항목이 없는 경우 (`CartOperationException: item_not_found`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패(`error.errors`), 옵션 없음/타상품 옵션 지정 (`CartOperationException: option_not_found`/`invalid_option`), 재고 부족·판매 중지·구매 대상 제한·구매 수량 한도 위반 (`CartUnavailableException` — `error.code = cart_unavailable`, `unavailable_items`, `has_stock_issue`, `has_status_issue`, `has_restriction_issue`, `has_min_qty_issue`, `has_max_qty_issue`) | +| 500 | Internal Server Error | 옵션 변경 처리 중 예외 발생 (`messages.cart.update_failed`) | @@ -690,18 +889,107 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[{"id":962,"quantity":2, …}]` | 수량 반영 후의 장바구니 전체 아이템 목록 (CartItemResource — 필드 구성은 POST `/cart` 의 "장바구니 아이템 필드" 표와 동일. 프론트가 refetch 없이 화면을 갱신하도록 전체를 함께 반환) | +| item_count | integer | `1` | 장바구니 아이템 개수 | +| calculation | object | `{"items":[…],"summary":{…},"promotions":{…},"validation_errors":[]}` | 선택 아이템 기준 금액 계산 결과 (GET `/cart` 의 `calculation` 과 동일 구조 — 소계·할인·배송비·적립·최종 결제금액 등) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "수량이 변경되었습니다.", + "data": { + "items": [ + { + "id": 962, + "product_id": 201, + "product_option_id": 1086, + "quantity": 2, + "additional_options": [], + "additional_options_total": 0, + "created_at": "2026-07-14 10:00:00", + "updated_at": "2026-07-14 10:15:00", + "product": { + "id": 201, + "name": "티셔츠", + "product_code": "P-201", + "thumbnail_url": null, + "sales_status": "on_sale", + "display_status": "visible" + }, + "product_option": { + "id": 1086, + "option_code": "OPT-1086", + "option_name": "색상/사이즈", + "option_name_localized": "색상/사이즈", + "option_values": ["BLACK", "L"], + "option_values_localized": ["블랙", "L"], + "list_price": 25000, + "list_price_formatted": "25,000원", + "multi_currency_list_price": {"KRW": 25000}, + "selling_price": 19000, + "selling_price_formatted": "19,000원", + "multi_currency_selling_price": {"KRW": 19000}, + "discount_rate": 24.0, + "stock_quantity": 50, + "is_active": true + }, + "available": true, + "unavailable_reason": null, + "is_shippable_to_selected_country": true, + "selected_shipping_country": "KR", + "subtotal": 38000, + "subtotal_formatted": "38,000원", + "multi_currency_subtotal": {"KRW": 38000} + } + ], + "item_count": 1, + "calculation": { + "items": [], + "summary": { + "subtotal": 38000, + "subtotal_formatted": "38,000원", + "total_discount": 0, + "discount_formatted": "0원", + "total_shipping_fee": 0, + "shipping_fee_formatted": "0원", + "payment_amount": 38000, + "payment_amount_formatted": "38,000원", + "final_amount": 38000, + "final_amount_formatted": "38,000원" + }, + "promotions": { + "coupon_issue_ids": [], + "item_coupons": [], + "discount_code": null, + "product_promotions": {"coupons": [], "discount_codes": [], "events": []}, + "order_promotions": {"coupons": [], "discount_codes": [], "events": []} + }, + "validation_errors": [] + } + } +} +``` + +> `calculation.summary` 는 GET `/cart` 응답과 동일한 전체 키 집합(`product_coupon_discount`, `code_discount`, `taxable_amount`, `points_earning` 등)을 포함합니다. 위 예시는 대표 키만 발췌한 것입니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 타인 소유 장바구니 항목 조작 (`CartOperationException: access_denied`) | +| 404 | Not Found | `id` 에 해당하는 장바구니 항목이 없는 경우 (`CartOperationException: item_not_found`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패(`quantity` 누락/범위 초과 — `error.errors`) 또는 재고 부족·판매 중지·구매 수량 한도 위반 (`CartUnavailableException` — `error.code = cart_unavailable`, `unavailable_items`, `has_stock_issue`, `has_status_issue`, `has_restriction_issue`, `has_min_qty_issue`, `has_max_qty_issue`) | +| 500 | Internal Server Error | 수량 변경 처리 중 예외 발생 (`messages.cart.update_failed`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md index 0115a299..a0e2d144 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md @@ -218,11 +218,72 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CategoryResource`). 생성 직후 `fresh(['images'])` 로 재조회하므로 `images` 관계만 포함되고 `parent`/`children` 키는 나타나지 않습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object\|null | `null` | 설명 (다국어 필드는 로케일별 값 객체, 미설정 시 null) | +| localized_name | string | `의류` | `name` 의 현재 로케일 해석 값 | +| parent_id | integer\|null | `null` | 상위 카테고리 ID (최상위면 null) | +| path | string | `1` | 조상부터 자기 자신까지의 ID를 `/`로 이은 materialized path | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (미지정 시 형제 마지막 순번 자동 부여) | +| is_active | boolean | `true` | 활성 여부 | +| slug | string | `clothing` | URL 친화 식별자 (slug) | +| url | string | `clothing` | SortableMenuItem 표시용 URL (slug 값을 그대로 사용) | +| icon | string | `folder` | SortableMenuList 표시용 아이콘 (리소스에서 고정값 `folder`) | +| meta_title | string\|null | `null` | SEO 메타 제목 (미설정 시 null) | +| meta_description | string\|null | `null` | SEO 메타 설명 (미설정 시 null) | +| created_at | string | `2026-07-08 01:44:49` | 생성 일시 (`Y-m-d H:i:s`) | +| updated_at | string | `2026-07-08 01:44:49` | 최종 수정 일시 (`Y-m-d H:i:s`) | +| images | array | `[]` | 카테고리 이미지 배열 (각 항목: id/hash/original_filename/mime_type/size/size_formatted/download_url/order/is_image/alt_text). `temp_key` 로 연결된 임시 이미지가 여기에 포함됨 | +| products_count | integer | `0` | 연결된 상품 개수 (집계) | +| children_count | integer | `0` | 하위 카테고리 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "카테고리가 등록되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 카테고리", + "en": "API Doc Sample Category" + }, + "description": null, + "localized_name": "API 문서 샘플 카테고리", + "parent_id": null, + "path": "1", + "depth": 0, + "sort_order": 0, + "is_active": true, + "slug": "apidoc-sample-category", + "url": "apidoc-sample-category", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-08 01:44:49", + "updated_at": "2026-07-08 01:44:49", + "images": [], + "products_count": 0, + "children_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -251,8 +312,8 @@ Content-Type: application/json | temp_key | body | string | 아니오 | max 64 | 사전 업로드한 임시 이미지를 이 카테고리에 연결하기 위한 FileUploader temp_key | | collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | | alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | -| alt_text.ko | body | string | 아니오 | max 255 | | -| alt_text.en | body | string | 아니오 | max 255 | | +| alt_text.ko | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `ko` 로케일 값 | +| alt_text.en | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `en` 로케일 값 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.category-image.filter_upload_validation_rules`). @@ -295,19 +356,56 @@ Content-Disposition: form-data; name="alt_text.en" **응답 필드** (`data` 내부) - +_단건 응답: FileUploader 컴포넌트가 `response.data?.data` 형식을 기대하므로 컨트롤러가 `data` 안에 한 번 더 `data` 객체를 감싸 반환합니다. 아래는 `data.data` 객체의 필드입니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 업로드된 카테고리 이미지의 기본 키 | +| hash | string | `9f2c1b8e4a...` | 이미지 고유 해시 (다운로드 URL 식별자) | +| original_filename | string | `banner.png` | 업로드 당시의 원본 파일명 | +| mime_type | string | `image/png` | 파일 MIME 타입 | +| size | integer | `102400` | 파일 크기 (바이트) | +| size_formatted | string | `100 KB` | 사람이 읽기 쉬운 형식으로 변환한 파일 크기 | +| download_url | string | `/api/modules/sirsoft-ecommerce/admin/categories/images/download/9f2c1b8e4a...` | 이미지 다운로드 URL | +| order | integer | `1` | 이미지 표시 순서 (`sort_order`, 미설정 시 1) | +| is_image | boolean | `true` | MIME 타입이 `image/` 로 시작하는지 여부 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.category_images.uploaded", + "data": { + "data": { + "id": 1, + "hash": "9f2c1b8e4a7d5c0361b2e8f4a9d7c015", + "original_filename": "banner.png", + "mime_type": "image/png", + "size": 102400, + "size_formatted": "100 KB", + "download_url": "/api/modules/sirsoft-ecommerce/admin/categories/images/download/9f2c1b8e4a7d5c0361b2e8f4a9d7c015", + "order": 1, + "is_image": true + } + } +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.category_images.uploaded` 키를 사용하는데, 현재 모듈 `lang/ko/messages.php` 에 `category_images` 섹션이 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 반환됩니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 파일 저장 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `file` 은 필수이며 `jpeg,png,jpg,gif,svg,webp` 이미지만 허용, 최대 10MB | @@ -346,19 +444,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.category_images.reordered" +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.category_images.reordered` 키를 사용하는데, 현재 모듈 `lang/ko/messages.php` 에 `category_images` 섹션이 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 반환됩니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 순서 갱신 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `order` 는 1건 이상 필수이며 각 항목에 정수 `id` 와 0 이상의 정수 `order` 가 필요 | @@ -388,19 +498,31 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.category_images.deleted" +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.category_images.deleted` 키를 사용하는데, 현재 모듈 `lang/ko/messages.php` 에 `category_images` 섹션이 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 반환됩니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 이미지 삭제 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | path 의 이미지 `id` 에 해당하는 카테고리 이미지가 없는 경우 (`messages.category_images.not_found`) | @@ -443,19 +565,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.categories.order_updated" +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.categories.order_updated` 키를 사용하는데, 현재 모듈 `lang/ko/messages.php` 의 `categories` 섹션에 `order_updated` 키가 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 반환됩니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 순서 갱신 트랜잭션 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `parent_menus` / `child_menus` 중 최소 하나가 필요하며, 각 항목의 `id` 는 실제 존재하는 카테고리여야 하고 `order` 는 0 이상의 정수 | @@ -588,8 +722,8 @@ HTTP/1.1 200 | temp_key | body | string | 아니오 | max 64 | 사전 업로드한 임시 이미지를 이 카테고리에 연결하기 위한 FileUploader temp_key | | collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | | alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | -| alt_text.ko | body | string | 아니오 | max 255 | | -| alt_text.en | body | string | 아니오 | max 255 | | +| alt_text.ko | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `ko` 로케일 값 | +| alt_text.en | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `en` 로케일 값 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.category-image.filter_upload_validation_rules`). @@ -632,20 +766,57 @@ Content-Disposition: form-data; name="alt_text.en" **응답 필드** (`data` 내부) - +_단건 응답: FileUploader 컴포넌트가 `response.data?.data` 형식을 기대하므로 컨트롤러가 `data` 안에 한 번 더 `data` 객체를 감싸 반환합니다. 아래는 `data.data` 객체의 필드입니다 (임시 업로드 엔드포인트와 동일 형식, 다만 이미지가 path 의 `categoryId` 에 즉시 귀속됩니다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 업로드된 카테고리 이미지의 기본 키 | +| hash | string | `9f2c1b8e4a...` | 이미지 고유 해시 (다운로드 URL 식별자) | +| original_filename | string | `banner.png` | 업로드 당시의 원본 파일명 | +| mime_type | string | `image/png` | 파일 MIME 타입 | +| size | integer | `102400` | 파일 크기 (바이트) | +| size_formatted | string | `100 KB` | 사람이 읽기 쉬운 형식으로 변환한 파일 크기 | +| download_url | string | `/api/modules/sirsoft-ecommerce/admin/categories/images/download/9f2c1b8e4a...` | 이미지 다운로드 URL | +| order | integer | `1` | 이미지 표시 순서 (`sort_order`, 미설정 시 1) | +| is_image | boolean | `true` | MIME 타입이 `image/` 로 시작하는지 여부 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.category_images.uploaded", + "data": { + "data": { + "id": 2, + "hash": "1c47e9a5b60d38f27ea4c9d015b7e8f3", + "original_filename": "banner.png", + "mime_type": "image/png", + "size": 102400, + "size_formatted": "100 KB", + "download_url": "/api/modules/sirsoft-ecommerce/admin/categories/images/download/1c47e9a5b60d38f27ea4c9d015b7e8f3", + "order": 1, + "is_image": true + } + } +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.category_images.uploaded` 키를 사용하는데, 현재 모듈 `lang/ko/messages.php` 에 `category_images` 섹션이 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 반환됩니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 파일 저장 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `file` 은 필수이며 `jpeg,png,jpg,gif,svg,webp` 이미지만 허용, 최대 10MB | @@ -743,16 +914,78 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CategoryResource`). 수정 직후 `fresh(['images'])` 로 재조회하므로 `images` 관계만 포함되고 `parent`/`children` 키는 나타나지 않습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object\|null | `null` | 설명 (다국어 필드는 로케일별 값 객체, 미설정 시 null) | +| localized_name | string | `의류` | `name` 의 현재 로케일 해석 값 | +| parent_id | integer\|null | `null` | 상위 카테고리 ID (변경 시 depth/path 재계산) | +| path | string | `1` | 조상부터 자기 자신까지의 ID를 `/`로 이은 materialized path | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | 활성 여부 | +| slug | string | `clothing` | URL 친화 식별자 (slug) | +| url | string | `clothing` | SortableMenuItem 표시용 URL (slug 값을 그대로 사용) | +| icon | string | `folder` | SortableMenuList 표시용 아이콘 (리소스에서 고정값 `folder`) | +| meta_title | string\|null | `null` | SEO 메타 제목 (미설정 시 null) | +| meta_description | string\|null | `null` | SEO 메타 설명 (미설정 시 null) | +| created_at | string | `2026-07-08 01:44:49` | 생성 일시 (`Y-m-d H:i:s`) | +| updated_at | string | `2026-07-08 06:00:17` | 최종 수정 일시 (`Y-m-d H:i:s`) | +| images | array | `[]` | 카테고리 이미지 배열 (각 항목: id/hash/original_filename/mime_type/size/size_formatted/download_url/order/is_image/alt_text) | +| products_count | integer | `0` | 연결된 상품 개수 (집계) | +| children_count | integer | `0` | 하위 카테고리 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "카테고리가 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 카테고리", + "en": "API Doc Sample Category" + }, + "description": null, + "localized_name": "API 문서 샘플 카테고리", + "parent_id": null, + "path": "1", + "depth": 0, + "sort_order": 0, + "is_active": true, + "slug": "apidoc-sample-category", + "url": "apidoc-sample-category", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-08 01:44:49", + "updated_at": "2026-07-08 06:00:17", + "images": [], + "products_count": 0, + "children_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 대상 카테고리가 없거나 수정 트랜잭션 중 예외 발생 (컨트롤러가 예외를 잡아 `exceptions.operation_failed` 로 응답) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | @@ -951,7 +1184,9 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 카테고리 트리 조회 중 예외가 발생한 경우 (컨트롤러가 예외를 잡아 `messages.categories.fetch_failed` 로 응답) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md index 80241562..a7081ce3 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md @@ -39,17 +39,41 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투(`data`)를 반환하지 않습니다. 성공 시 이미지 원본 바이너리를 `StreamedResponse` 로 스트리밍합니다 (`CategoryImageService::download()` → `StorageInterface::response()`)._ + +| 응답 요소 | 값 | 용도/설명 | +| --- | --- | --- | +| 본문 | 이미지 원본 바이너리 | `category_images.path` 에 저장된 파일을 스토리지에서 스트리밍 | +| `Content-Type` 헤더 | `image/jpeg` (레코드의 `mime_type` 값) | 업로드 시 저장된 MIME 타입을 그대로 사용 | +| `Cache-Control` 헤더 | `public, max-age=31536000` | 브라우저/CDN 1년 캐싱 | +| `Content-Disposition` 헤더 | 원본 파일명(`original_filename`) 포함 | 다운로드 시 표시될 파일명 | **응답 예시** - +```http +HTTP/1.1 200 OK +Content-Type: image/jpeg +Cache-Control: public, max-age=31536000 +Content-Disposition: inline; filename="category-main.jpg" + +<이미지 바이너리 데이터> +``` + +실패 시에는 JSON 봉투로 응답합니다. + +```json +{ + "success": false, + "message": "카테고리 이미지를 찾을 수 없습니다." +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 400 | Bad Request | 스트리밍 처리 중 예외 발생 (`exceptions.operation_failed` → "작업 처리 중 오류가 발생했습니다.") | +| 404 | Not Found | `hash` 에 해당하는 카테고리 이미지 레코드가 없거나, 레코드는 있으나 스토리지에 실제 파일이 없는 경우 (`CategoryImageService::download()` 가 `null` 반환) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md index 98db3b5f..f6a0af26 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md @@ -38,7 +38,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만) — `CheckoutController@destroy` 는 `ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.checkout.deleted')` 를 데이터 인수 없이 호출합니다._ **응답 예시** @@ -51,14 +51,17 @@ HTTP/1.1 200 ```json { "success": true, - "message": "Checkout has been deleted.", + "message": "주문서가 삭제되었습니다.", "data": null } ``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | 삭제할 임시 주문이 없음 (`TempOrderService::deleteTempOrder()` 가 false 반환 — 이미 만료·미존재). 메시지: `임시 주문 정보를 찾을 수 없습니다.` | +| 500 | Internal Server Error | 삭제 처리 중 예외 발생. 메시지: `주문서 삭제에 실패했습니다.` | @@ -92,17 +95,256 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CheckoutDataService::buildResponseData()` 가 구성)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| temp_order_id | integer | `1` | 임시 주문 ID (주문 확정 시 사용) | +| items | array | `[...]` | 주문 상품 목록 (`CheckoutItemResource` — 아래 "items[] 항목" 표) | +| calculation | object | `{...}` | 주문 계산 결과 (`OrderCalculationResult::toArray()` — 아래 "calculation" 표) | +| promotions | object | `{"item_coupons":{},"order_coupon_issue_id":null,"shipping_coupon_issue_id":null}` | 현재 임시 주문에 적용된 쿠폰 선택 상태 | +| use_points | integer | `0` | 현재 사용 설정된 마일리지 포인트 | +| shipping_address | object\|null | `{"country_code":"KR","zipcode":"06234","region":null,"city":null,"address":null}` | 배송비 계산에 사용된 배송 주소 (미전달 시 `null`) | +| expires_at | string | `2026-07-14T12:34:56+09:00` | 임시 주문 만료 시각 (ISO 8601) | +| available_coupons | array | `[]` | 주문금액/배송비 대상 사용 가능 쿠폰 목록 (비회원은 빈 배열) | +| mileage | object\|null | `{...}` | 마일리지 잔액 정보 (비회원은 `null` — 아래 "mileage" 표) | +| validation_errors | array | `[]` | 조건 위반으로 자동 제외된 쿠폰의 사유 목록 (min_amount / per_user_limit / not_combinable 등) | +| has_unshippable_items | boolean | `false` | 선택된 배송국가로 배송 불가한 상품이 1개라도 있으면 `true` (주문하기 차단 플래그) | +| selected_shipping_country | string | `KR` | 현재 선택된 배송 국가 코드 | +| free_shipping | object | `{...}` | 무료배송 기준액 정보 (아래 "free_shipping" 표) | +| unavailable_items | array | `[]` | 구매 불가 상품 목록 (존재할 때만 포함) | +| has_stock_issue | boolean | `false` | 구매 불가 사유에 재고 부족이 포함되면 `true` (`unavailable_items` 존재 시에만 포함) | +| has_status_issue | boolean | `false` | 구매 불가 사유에 판매 상태 문제가 포함되면 `true` (`unavailable_items` 존재 시에만 포함) | + +`items[]` 항목 (`CheckoutItemResource::toArray()`): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer\|null | `1` | 장바구니 아이템 ID (바로구매는 `null`) | +| product_id | integer | `1` | 상품 ID | +| product_option_id | integer | `1` | 상품 옵션 ID | +| quantity | integer | `1` | 주문 수량 | +| additional_options | array | `[]` | 선택한 추가 옵션 (`group_name` / `name` / `price_adjustment`) | +| additional_options_total | integer | `0` | 추가 옵션 단가 합계 | +| product | object\|null | `{...}` | 상품 정보 (이름·썸네일 등) | +| product_option | object\|null | `{...}` | 옵션 정보 (옵션명·판매가 등) | +| is_shippable_to_selected_country | boolean | `true` | 선택된 배송국가로 이 상품이 배송 가능한지 | +| subtotal | integer | `10000` | 표시 소계 = (옵션 판매가 + 추가옵션 단가 합계) × 수량 | +| subtotal_formatted | string | `10,000원` | 소계 통화 포맷 | +| multi_currency_subtotal | object | `{"KRW":{...}}` | 소계 다통화 환산 | +| unit_price | integer | `10000` | 계산 기준 단가 | +| unit_price_formatted | string | `10,000원` | 단가 통화 포맷 | +| product_coupon_discount_amount | integer | `0` | 상품 쿠폰 할인액 | +| product_coupon_discount_formatted | string | `0원` | 상품 쿠폰 할인액 포맷 | +| code_discount_amount | integer | `0` | 할인코드 할인액 | +| code_discount_formatted | string | `0원` | 할인코드 할인액 포맷 | +| order_coupon_discount_share | integer | `0` | 주문 쿠폰 할인의 이 라인 분담액 | +| order_coupon_discount_share_formatted | string | `0원` | 분담액 포맷 | +| total_discount | integer | `0` | 이 라인의 총 할인액 | +| total_discount_formatted | string | `0원` | 총 할인액 포맷 | +| points_used_share | integer | `0` | 사용 마일리지의 이 라인 분담액 | +| points_used_share_formatted | string | `0원` | 분담액 포맷 | +| points_earning | integer | `0` | 이 라인의 적립 예정 마일리지 | +| final_amount | integer | `10000` | 이 라인의 최종 금액 | +| final_amount_formatted | string | `10,000원` | 최종 금액 포맷 | +| multi_currency_unit_price | object | `{"KRW":{"amount":10000,"formatted":"10,000원"}}` | 단가 다통화 환산 | +| multi_currency_product_coupon_discount | object | `{"KRW":{"amount":0,"formatted":"0원"}}` | 상품 쿠폰 할인 다통화 환산 | +| multi_currency_total_discount | object | `{"KRW":{"amount":0,"formatted":"0원"}}` | 총 할인 다통화 환산 | +| multi_currency_final_amount | object | `{"KRW":{"amount":10000,"formatted":"10,000원"}}` | 최종 금액 다통화 환산 | +| available_coupons | array | `[]` | 이 상품에 적용 가능한 상품 쿠폰 목록 | +| disabled_coupon_ids | array | `[]` | 다른 상품 라인에서 이미 소진되어(per_user_limit) 이 라인에서는 선택 불가한 쿠폰 발급 ID | + +`calculation` (`OrderCalculationResult::toArray()`): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[...]` | 라인별 계산 결과 | +| summary | object | `{...}` | 합계 정보 (`Summary::toArray()` — 아래 표) | +| promotions | object | `{...}` | 실제 적용된 프로모션 요약 (상품/주문 프로모션 분리 + `coupon_issue_ids` / `item_coupons` / `discount_code` 평탄 키) | +| validation_errors | array | `[]` | 쿠폰 검증 오류 목록 | +| metadata | object | `{}` | 플러그인 확장용 메타데이터 (비어 있으면 생략) | + +`calculation.summary` (`Summary::toArray()`): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| subtotal | integer | `10000` | 상품 금액 소계 (할인 전) | +| subtotal_formatted | string | `10,000원` | 소계 통화 포맷 | +| product_coupon_discount | integer | `0` | 상품/카테고리 쿠폰 할인 합계 | +| product_coupon_discount_formatted | string | `0원` | 상품 쿠폰 할인 포맷 | +| code_discount | integer | `0` | 할인코드 할인 합계 | +| code_discount_formatted | string | `0원` | 할인코드 할인 포맷 | +| order_coupon_discount | integer | `0` | 주문 쿠폰 할인 합계 | +| order_coupon_discount_formatted | string | `0원` | 주문 쿠폰 할인 포맷 | +| total_coupon_discount | integer | `0` | 상품 + 주문 쿠폰 할인 합계 | +| total_coupon_discount_formatted | string | `0원` | 쿠폰 할인 합계 포맷 | +| total_discount | integer | `0` | 총 할인금액 | +| discount_formatted | string | `0원` | 총 할인금액 포맷 | +| base_shipping_total | integer | `3000` | 기본 배송비 합계 | +| extra_shipping_total | integer | `0` | 추가 배송비(도서산간) 합계 | +| total_shipping | integer | `3000` | 총 배송비 (기본 + 추가) | +| total_shipping_fee | integer | `3000` | 총 배송비 (별칭 키) | +| shipping_fee_formatted | string | `3,000원` | 총 배송비 포맷 | +| shipping_discount | integer | `0` | 배송비 할인 | +| shipping_discount_formatted | string | `0원` | 배송비 할인 포맷 | +| taxable_amount | integer | `10000` | 과세 금액 합계 | +| tax_free_amount | integer | `0` | 면세 금액 합계 | +| points_earning | integer | `0` | 적립 예정 마일리지 합계 | +| total_mileage | integer | `0` | 적립 예정 마일리지 (별칭 키) | +| mileage_formatted | string | `0P` | 적립 예정 마일리지 포맷 | +| points_used | integer | `0` | 사용 마일리지 합계 | +| points_used_formatted | string | `0원` | 사용 마일리지 차감액 통화 포맷 | +| payment_amount | integer | `13000` | 결제금액 (마일리지 사용 전) | +| payment_amount_formatted | string | `13,000원` | 결제금액 포맷 | +| final_amount | integer | `13000` | 최종 지불금액 | +| final_amount_formatted | string | `13,000원` | 최종 지불금액 포맷 | +| selected_payment_currency | string | `KRW` | 선택된 결제 통화 (설정된 경우에만 포함) | +| multi_currency | object | `{"KRW":{...}}` | 합계 금액의 다통화 환산 (설정된 경우에만 포함) | +| coupon_discount | integer | `0` | 상품 쿠폰 할인 (deprecated — 하위 호환) | +| coupon_discount_formatted | string | `0원` | 상품 쿠폰 할인 포맷 (deprecated) | +| order_discount | integer | `0` | 주문 쿠폰 할인 (deprecated — 하위 호환) | +| order_discount_formatted | string | `0원` | 주문 쿠폰 할인 포맷 (deprecated) | + +`mileage` (`UserMileageService::getBalance()` + 체크아웃 추가 키, 비회원은 `null`): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| enabled | boolean | `true` | 마일리지 적립 기능 활성화 여부 | +| available | integer | `0` | 사용 가능 마일리지 잔액 | +| pending | integer | `0` | 적립 대기 중인 마일리지 | +| expiring_soon | integer | `0` | 곧 소멸 예정 마일리지 | +| expiring_date | string\|null | `2026-08-31` | 소멸 예정일 (없으면 `null`) | +| total_earned | integer | `0` | 누적 적립 마일리지 | +| total_used | integer | `0` | 누적 사용 마일리지 | +| by_currency | object | `{}` | 통화별 마일리지 내역 | +| max_usable | integer | `0` | 이번 주문에서 사용 가능한 최대 마일리지 | +| usable | boolean | `true` | 마일리지 "사용" 가능 여부 (기본 통화 사용 규칙 미설정 시 `false`) | + +`free_shipping` (`CheckoutDataService::buildFreeShippingInfo()`): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| enabled | boolean | `false` | 무료배송 기준액 기능 활성화 여부 | +| threshold_base | integer | `50000` | 무료배송 기준액 (기본 통화 정수) | +| threshold_multi_currency | object | `{"KRW":{...}}` | 기준액의 결제통화 환산 (기준액 0 이면 `[]`) | +| remaining_base | integer | `40000` | 무료배송까지 남은 금액 (기본 통화) | +| remaining_multi_currency | object | `{"KRW":{...}}` | 남은 금액의 결제통화 환산 (남은 금액 0 이면 `[]`) | **응답 예시** - +```json +{ + "success": true, + "message": "주문서 정보를 조회했습니다.", + "data": { + "temp_order_id": 1, + "items": [ + { + "id": 1, + "product_id": 1, + "product_option_id": 1, + "quantity": 1, + "additional_options": [], + "additional_options_total": 0, + "product": { "id": 1, "name": "샘플 상품" }, + "product_option": { "id": 1, "name": "기본 옵션" }, + "is_shippable_to_selected_country": true, + "subtotal": 10000, + "subtotal_formatted": "10,000원", + "multi_currency_subtotal": {}, + "unit_price": 10000, + "unit_price_formatted": "10,000원", + "product_coupon_discount_amount": 0, + "product_coupon_discount_formatted": "0원", + "code_discount_amount": 0, + "code_discount_formatted": "0원", + "order_coupon_discount_share": 0, + "order_coupon_discount_share_formatted": "0원", + "total_discount": 0, + "total_discount_formatted": "0원", + "points_used_share": 0, + "points_used_share_formatted": "0원", + "points_earning": 0, + "final_amount": 10000, + "final_amount_formatted": "10,000원", + "available_coupons": [], + "disabled_coupon_ids": [] + } + ], + "calculation": { + "items": [], + "summary": { + "subtotal": 10000, + "subtotal_formatted": "10,000원", + "total_discount": 0, + "discount_formatted": "0원", + "base_shipping_total": 3000, + "extra_shipping_total": 0, + "total_shipping": 3000, + "shipping_fee_formatted": "3,000원", + "points_earning": 0, + "points_used": 0, + "points_used_formatted": "0원", + "payment_amount": 13000, + "payment_amount_formatted": "13,000원", + "final_amount": 13000, + "final_amount_formatted": "13,000원" + }, + "promotions": { + "coupon_issue_ids": [], + "item_coupons": {}, + "discount_code": null + }, + "validation_errors": [] + }, + "promotions": { + "item_coupons": {}, + "order_coupon_issue_id": null, + "shipping_coupon_issue_id": null + }, + "use_points": 0, + "shipping_address": { + "country_code": "KR", + "zipcode": "06234", + "region": null, + "city": null, + "address": null + }, + "expires_at": "2026-07-14T12:34:56+09:00", + "available_coupons": [], + "mileage": { + "enabled": true, + "available": 0, + "pending": 0, + "expiring_soon": 0, + "expiring_date": null, + "total_earned": 0, + "total_used": 0, + "by_currency": {}, + "max_usable": 0, + "usable": true + }, + "validation_errors": [], + "has_unshippable_items": false, + "selected_shipping_country": "KR", + "free_shipping": { + "enabled": false, + "threshold_base": 0, + "threshold_multi_currency": [], + "remaining_base": 0, + "remaining_multi_currency": [] + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 404 | Not Found | 임시 주문이 만료되었거나 존재하지 않음. 메시지: `임시 주문 정보를 찾을 수 없습니다.` | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 조회/재계산 중 예외 발생 | @@ -153,17 +395,52 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CheckoutController@store` 가 직접 조립 — 성공 시 HTTP 201)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| temp_order_id | integer | `1` | 생성된 임시 주문 ID (이후 조회/수정/주문 확정에 사용) | +| calculation | object | `{...}` | 주문 계산 결과 (`OrderCalculationResult::toArray()` — `items` / `summary` / `promotions` / `validation_errors`. 상세는 GET /checkout 의 calculation 표 참조) | +| expires_at | string | `2026-07-14T12:34:56+09:00` | 임시 주문 만료 시각 (ISO 8601) | **응답 예시** - +```json +{ + "success": true, + "message": "주문서가 생성되었습니다.", + "data": { + "temp_order_id": 1, + "calculation": { + "items": [], + "summary": { + "subtotal": 10000, + "subtotal_formatted": "10,000원", + "total_discount": 0, + "total_shipping": 3000, + "payment_amount": 13000, + "final_amount": 13000, + "final_amount_formatted": "13,000원" + }, + "promotions": { + "coupon_issue_ids": [], + "item_coupons": {}, + "discount_code": null + }, + "validation_errors": [] + }, + "expires_at": "2026-07-14T12:34:56+09:00" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 400 | Bad Request | 장바구니가 비어 있음. 메시지: `장바구니가 비어있습니다.` | +| 422 | Unprocessable Entity | 보유 잔액을 초과하는 마일리지 사용 요청 (`error.code: mileage_exceeds_balance`). 메시지: `주문서 생성에 실패했습니다.` | +| 500 | Internal Server Error | 임시 주문 생성 중 예외 발생. 메시지: `주문서 생성에 실패했습니다.` | @@ -216,17 +493,93 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. GET `/api/modules/sirsoft-ecommerce/checkout` 과 동일한 구조입니다 (`CheckoutDataService::buildResponseData()` 를 그대로 사용)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| temp_order_id | integer | `1` | 임시 주문 ID | +| items | array | `[...]` | 재계산된 주문 상품 목록 (`CheckoutItemResource` — GET 문서의 `items[]` 표와 동일) | +| calculation | object | `{...}` | 재계산된 주문 계산 결과 (`items` / `summary` / `promotions` / `validation_errors`) | +| promotions | object | `{"item_coupons":{},"order_coupon_issue_id":null,"shipping_coupon_issue_id":null}` | 반영된 쿠폰 선택 상태 (미전송 필드는 기존 값 유지) | +| use_points | integer | `0` | 반영된 마일리지 사용 포인트 | +| shipping_address | object\|null | `{"country_code":"KR","zipcode":"06234"}` | 배송비 계산에 사용된 배송 주소 | +| expires_at | string | `2026-07-14T12:34:56+09:00` | 임시 주문 만료 시각 (ISO 8601) | +| available_coupons | array | `[]` | 주문금액/배송비 대상 사용 가능 쿠폰 (비회원은 빈 배열) | +| mileage | object\|null | `{...}` | 마일리지 잔액 정보 (비회원은 `null`) | +| validation_errors | array | `[]` | 조건 위반으로 자동 제외된 쿠폰의 사유 목록 | +| has_unshippable_items | boolean | `false` | 선택된 배송국가로 배송 불가한 상품 존재 여부 | +| selected_shipping_country | string | `KR` | 현재 선택된 배송 국가 코드 | +| free_shipping | object | `{...}` | 무료배송 기준액 정보 (`enabled` / `threshold_base` / `threshold_multi_currency` / `remaining_base` / `remaining_multi_currency`) | **응답 예시** - +```json +{ + "success": true, + "message": "주문서가 수정되었습니다.", + "data": { + "temp_order_id": 1, + "items": [], + "calculation": { + "items": [], + "summary": { + "subtotal": 10000, + "subtotal_formatted": "10,000원", + "total_discount": 1000, + "discount_formatted": "1,000원", + "total_shipping": 3000, + "shipping_fee_formatted": "3,000원", + "points_used": 0, + "payment_amount": 12000, + "final_amount": 12000, + "final_amount_formatted": "12,000원" + }, + "promotions": { + "coupon_issue_ids": [10], + "item_coupons": {}, + "discount_code": null + }, + "validation_errors": [] + }, + "promotions": { + "item_coupons": {}, + "order_coupon_issue_id": 10, + "shipping_coupon_issue_id": null + }, + "use_points": 0, + "shipping_address": { + "country_code": "KR", + "zipcode": "06234" + }, + "expires_at": "2026-07-14T12:34:56+09:00", + "available_coupons": [], + "mileage": { + "enabled": true, + "available": 0, + "max_usable": 0, + "usable": true + }, + "validation_errors": [], + "has_unshippable_items": false, + "selected_shipping_country": "KR", + "free_shipping": { + "enabled": false, + "threshold_base": 0, + "threshold_multi_currency": [], + "remaining_base": 0, + "remaining_multi_currency": [] + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 임시 주문이 만료되었거나 존재하지 않음 (`TempOrderNotFoundException`). 메시지: `임시 주문 정보를 찾을 수 없습니다.` | +| 422 | Unprocessable Entity | 보유 잔액을 초과하는 마일리지 사용 요청 (`error.code: mileage_exceeds_balance`). 메시지: `주문서 수정에 실패했습니다.` | +| 500 | Internal Server Error | 재계산 중 예외 발생. 메시지: `주문서 수정에 실패했습니다.` | @@ -254,15 +607,30 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| expires_at | string | `2026-07-14T13:04:56+09:00` | 연장된 임시 주문 만료 시각 (ISO 8601) | **응답 예시** - +```json +{ + "success": true, + "message": "주문서 유효시간이 연장되었습니다.", + "data": { + "expires_at": "2026-07-14T13:04:56+09:00" + } +} +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | 연장할 임시 주문이 이미 만료되었거나 존재하지 않음 (`TempOrderService::extendExpiration()` 이 null 반환). 메시지: `임시 주문 정보를 찾을 수 없습니다.` | +| 500 | Internal Server Error | 연장 처리 중 예외 발생. 메시지: `주문서 유효시간 연장에 실패했습니다.` | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md index a2699c70..9a6b948d 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md @@ -192,11 +192,58 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ClaimReasonResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `8` | 생성된 클래임 사유의 기본 키 (내부 식별자) | +| type | string | `refund` | 클래임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | +| code | string | `apidoc_sample` | 사유 식별 코드 (같은 type 내 고유) | +| name | object | `{"ko":"API 문서 샘플 사유","en":"API Doc Sample Reason"}` | 다국어 사유명 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 사유` | `name` 의 현재 로케일 해석 값 | +| fault_type | string | `customer` | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | +| fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (Enum label 파생) | +| is_user_selectable | boolean | `true` | 회원 취소/반품 신청 화면에서 선택지로 노출할지 여부 | +| is_active | boolean | `true` | 활성 여부 (false 면 선택지에서 제외) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (`sirsoft-ecommerce.settings.update` 권한 기반) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "클래임 사유가 등록되었습니다.", + "data": { + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유", + "en": "API Doc Sample Reason" + }, + "localized_name": "API 문서 샘플 사유", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -204,7 +251,7 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 같은 `type` 내에 동일한 `code` 가 이미 존재하면 `code` 필드 검증 실패 | @@ -401,20 +448,68 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ClaimReasonResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `8` | 수정된 클래임 사유의 기본 키 (내부 식별자) | +| type | string | `refund` | 클래임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | +| code | string | `apidoc_sample` | 사유 식별 코드 (같은 type 내 고유) | +| name | object | `{"ko":"API 문서 샘플 사유(수정)","en":"API Doc Sample Reason (Updated)"}` | 다국어 사유명 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 사유(수정)` | `name` 의 현재 로케일 해석 값 | +| fault_type | string | `seller` | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | +| fault_type_label | string | `판매자 귀책` | `fault_type` 값의 사람이 읽는 라벨 (Enum label 파생) | +| is_user_selectable | boolean | `true` | 회원 취소/반품 신청 화면에서 선택지로 노출할지 여부 | +| is_active | boolean | `true` | 활성 여부 (false 면 선택지에서 제외) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:18` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (`sirsoft-ecommerce.settings.update` 권한 기반) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "클래임 사유가 수정되었습니다.", + "data": { + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유(수정)", + "en": "API Doc Sample Reason (Updated)" + }, + "localized_name": "API 문서 샘플 사유(수정)", + "fault_type": "seller", + "fault_type_label": "판매자 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:18", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 대상 사유가 없거나 갱신에 실패한 경우 (서비스 예외 → `exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 같은 `type` 내 다른 사유가 동일한 `code` 를 이미 사용 중이면 `code` 필드 검증 실패 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md index 30cae75b..e6d58135 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md @@ -534,19 +534,55 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 쿠폰 발급 내역 `CouponIssue` 모델 직렬화 — 전용 Resource 를 거치지 않고 모델의 컬럼/캐스트가 그대로 노출됩니다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `8611` | 발급 내역 ID (기본 키) | +| coupon_id | integer | `157` | 발급된 쿠폰 정의(마스터)의 식별자 | +| user_id | integer | `1` | 발급받은 회원 식별자 | +| coupon_code | string | `DL-8QK2ZX4M` | 발급 시 생성된 쿠폰 코드 (다운로드 발급은 `DL-` + 8자리 랜덤 대문자) | +| status | string | `available` | 발급 건 상태 (available=사용가능, used=사용완료, expired=만료, cancelled=취소) | +| issued_at | string | `2026-07-14T02:24:18.000000Z` | 발급일시 (발급 처리 시각) | +| expired_at | string \| null | `2026-08-07T02:24:18.000000Z` | 만료일시 (valid_type=period 이면 쿠폰의 `valid_to`, days_from_issue 이면 발급일 + `valid_days`, 둘 다 아니면 null) | +| used_at | null | `null` | 사용일시 (발급 직후에는 항상 null) | +| order_id | null | `null` | 사용된 주문 식별자 (발급 직후에는 항상 null) | +| discount_amount | string \| null | `null` | 실제 적용된 할인 금액 (사용 전에는 null, decimal(15,2)) | +| created_at | string | `2026-07-14T02:24:18.000000Z` | 생성 일시 | +| updated_at | string | `2026-07-14T02:24:18.000000Z` | 최종 수정 일시 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "쿠폰이 다운로드되었습니다.", + "data": { + "coupon_id": 157, + "user_id": 1, + "coupon_code": "DL-8QK2ZX4M", + "status": "available", + "issued_at": "2026-07-14T02:24:18.000000Z", + "expired_at": "2026-08-07T02:24:18.000000Z", + "updated_at": "2026-07-14T02:24:18.000000Z", + "created_at": "2026-07-14T02:24:18.000000Z", + "id": 8611 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | 쿠폰 다운로드 제한 횟수를 초과했습니다. | 회원 1인당 발급 제한(`per_user_limit`) 을 이미 채운 경우 | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | `coupon_id` 가 정수가 아니거나 존재하지 않는 쿠폰인 경우 (`error.errors` 에 필드별 메시지) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md b/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md index 02138136..e34ae463 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md @@ -99,18 +99,35 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| preferred_currency | string | `USD` | 저장된 선호 결제 통화 코드 (요청의 `currency` 값이 그대로 반환됨) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "결제 통화가 변경되었습니다.", + "data": { + "preferred_currency": "USD" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | `currency` 누락("결제 통화를 선택해 주세요.") 또는 등록되지 않은/환율 미설정 통화 지정("등록된 통화만 선택할 수 있습니다.") | +| 500 | Internal Server Error | 통화 저장 중 예외 발생 시 ("결제 통화 변경에 실패했습니다.") | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md b/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md index 800a2df1..d114266a 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md @@ -203,19 +203,62 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ExtraFeeTemplateResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| zipcode | string | `06234` | 추가배송비를 적용할 우편번호 (단일 또는 `-` 로 이은 범위) | +| fee | integer | `3000` | 해당 우편번호에 부과할 추가 배송비 (상점 기본 통화 기준 반올림) | +| fee_formatted | string | `3,000원` | `fee` 값의 표시용 포맷 문자열 (기본 통화 포맷) | +| region | string \| null | `경기 안산 풍도동` | 지역명 (도서산간 등 관리자 참고용 표시 라벨, 미입력 시 null) | +| description | string \| null | `도서산간 지역` | 설명 (미입력 시 null) | +| is_active | boolean | `true` | 활성 여부 (true 이면 배송비 계산에 할증 반영) | +| created_by | string \| null | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 등록자 UUID (creator 관계 파생, 없으면 null) | +| updated_by | string \| null | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 최종 수정자 UUID (updater 관계 파생, 없으면 null) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 포맷) | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 (사용자 타임존 기준 포맷) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (모두 `sirsoft-ecommerce.settings.update` 권한 기반) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "추가배송비 템플릿이 등록되었습니다.", + "data": { + "id": 1, + "zipcode": "06234", + "fee": 3000, + "fee_formatted": "3,000원", + "region": "경기 안산 풍도동", + "description": "도서산간 지역", + "is_active": true, + "created_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 저장 중 예외 발생 시 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `zipcode` 는 숫자 또는 `숫자-숫자` 범위 형식이어야 하며 이미 등록된 우편번호면 중복 오류 | @@ -479,19 +522,38 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (일괄 삭제 결과 요약)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `3` | 실제로 삭제된 템플릿 건수 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 추가배송비 템플릿이 삭제되었습니다.", + "data": { + "deleted_count": 3 + } +} +``` + +> 컨트롤러가 메시지 치환 파라미터를 전달하지 않으므로 `message` 의 `:count` 자리표시자는 치환되지 않은 채로 내려갑니다. 실제 삭제 건수는 `data.deleted_count` 를 사용하세요. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 삭제 중 예외 발생 시 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `ids` 는 1건 이상이어야 하며 각 값은 존재하는 템플릿 ID 여야 함 | @@ -528,19 +590,38 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (일괄 등록 결과 요약)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| created_count | integer | `2` | 실제로 등록(또는 갱신)된 템플릿 건수 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": ":count개 추가배송비 템플릿이 등록되었습니다.", + "data": { + "created_count": 2 + } +} +``` + +> 컨트롤러가 메시지 치환 파라미터를 전달하지 않으므로 `message` 의 `:count` 자리표시자는 치환되지 않은 채로 내려갑니다. 실제 등록 건수는 `data.created_count` 를 사용하세요. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 일괄 등록 중 예외 발생 시 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 항목별로 `items.*.zipcode` (필수, 숫자 또는 `숫자-숫자` 범위, 최대 20자) 와 `items.*.fee` (필수, 0 이상) 가 검증됨 | @@ -579,19 +660,38 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (일괄 변경 결과 요약)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 활성 여부가 실제로 변경된 템플릿 건수 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 추가배송비 템플릿의 사용여부가 변경되었습니다.", + "data": { + "updated_count": 3 + } +} +``` + +> 컨트롤러가 메시지 치환 파라미터를 전달하지 않으므로 `message` 의 `:count` 자리표시자는 치환되지 않은 채로 내려갑니다. 실제 변경 건수는 `data.updated_count` 를 사용하세요. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 일괄 변경 중 예외 발생 시 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `ids` 는 1건 이상이며 각 값은 존재하는 템플릿 ID, `is_active` 는 필수 boolean | @@ -621,7 +721,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** @@ -719,20 +819,63 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ExtraFeeTemplateResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| zipcode | string | `06234` | 추가배송비를 적용할 우편번호 (단일 또는 `-` 로 이은 범위) | +| fee | integer | `5000` | 해당 우편번호에 부과할 추가 배송비 (상점 기본 통화 기준 반올림) | +| fee_formatted | string | `5,000원` | `fee` 값의 표시용 포맷 문자열 (기본 통화 포맷) | +| region | string \| null | `경기 안산 풍도동` | 지역명 (도서산간 등 관리자 참고용 표시 라벨, 미입력 시 null) | +| description | string \| null | `도서산간 지역` | 설명 (미입력 시 null) | +| is_active | boolean | `true` | 활성 여부 (true 이면 배송비 계산에 할증 반영) | +| created_by | string \| null | `null` | 등록자 UUID (creator 관계 파생, 없으면 null) | +| updated_by | string \| null | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 최종 수정자 UUID (수정 시 현재 로그인 사용자로 갱신) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 포맷) | +| updated_at | string | `2026-07-08 15:00:20` | 최종 수정 일시 (사용자 타임존 기준 포맷) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (모두 `sirsoft-ecommerce.settings.update` 권한 기반) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "추가배송비 템플릿이 수정되었습니다.", + "data": { + "id": 1, + "zipcode": "06234", + "fee": 5000, + "fee_formatted": "5,000원", + "region": "경기 안산 풍도동", + "description": "도서산간 지역", + "is_active": true, + "created_by": null, + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:20", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 수정 중 예외 발생 시 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `zipcode` 는 숫자 또는 `숫자-숫자` 범위 형식이어야 하며 자신을 제외한 다른 템플릿과 중복될 수 없음 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md b/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md index 524b29c5..b85d7685 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md @@ -48,16 +48,38 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| guest_order_token | string | `eyJpdiI6...` | 비회원 주문 조회 토큰 (30분 유효). 이후 비회원 주문 상세/취소/환불예상 호출 시 `X-Guest-Order-Token` 헤더로 전달 | +| expires_at | string | `2026-07-14T15:02:11+09:00` | 토큰 만료 일시 (ISO 8601) | +| order | object | — | 최소 주문 요약 (상세는 토큰 보호 엔드포인트에서 제공) | +| order.order_number | string | `20260714000012` | 인증된 주문의 주문번호 | +| order.order_status | string | `payment_complete` | 주문 상태 (`pending_order`, `pending_payment`, `payment_complete`, `shipping_hold`, `preparing`, `shipping_ready`, `shipping`, `delivered`, `confirmed`, `cancelled`) | **응답 예시** - +```json +{ + "success": true, + "message": "주문 정보를 조회했습니다.", + "data": { + "guest_order_token": "eyJpdiI6...", + "expires_at": "2026-07-14T15:02:11+09:00", + "order": { + "order_number": "20260714000012", + "order_status": "payment_complete" + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 404 | Not Found | 주문 없음 / 회원 주문 / 전화번호 불일치 / 비밀번호 오류 / 잠금 — 정보 노출 방지를 위해 모두 동일한 "주문을 찾을 수 없습니다" 로 처리 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -101,18 +123,145 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`GuestOrderResource` — 취소 후 갱신된 주문 상세)._ + + + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_number | string | `20260714000012` | 주문번호 | +| order_status | string | `cancelled` | 주문 상태 (`pending_order`, `pending_payment`, `payment_complete`, `shipping_hold`, `preparing`, `shipping_ready`, `shipping`, `delivered`, `confirmed`, `cancelled`) | +| order_status_label | string\|null | `주문취소` | 주문 상태의 다국어 라벨 | +| order_status_variant | string\|null | `danger` | 주문 상태 뱃지 표시용 variant | +| is_partially_cancelled | boolean | `false` | 일부 옵션만 취소된 주문인지 여부 (별도 상태가 아닌 파생 플래그, 보조 뱃지용) | +| subtotal_amount | number | `12000` | 상품 합계 금액 (주문 시점 통화 기준) | +| subtotal_amount_formatted | string | `12,000원` | 상품 합계 금액 표기 문자열 | +| total_discount_amount | number | `0` | 총 할인 금액 | +| total_discount_amount_formatted | string | `0원` | 총 할인 금액 표기 문자열 | +| total_shipping_amount | number | `3000` | 총 배송비 | +| total_shipping_amount_formatted | string | `3,000원` | 총 배송비 표기 문자열 | +| total_amount | number | `15000` | 주문 총액 | +| total_amount_formatted | string | `15,000원` | 주문 총액 표기 문자열 | +| total_paid_amount | number | `15000` | 실제 결제 금액 | +| total_paid_amount_formatted | string | `15,000원` | 실제 결제 금액 표기 문자열 | +| total_cancelled_amount | number | `15000` | 누적 취소 금액 | +| total_cancelled_amount_formatted | string | `15,000원` | 누적 취소 금액 표기 문자열 | +| total_refunded_amount | number | `15000` | 누적 PG 환불 금액 | +| total_refunded_amount_formatted | string | `15,000원` | 누적 PG 환불 금액 표기 문자열 | +| total_refunded_points_amount | number | `0` | 누적 마일리지 환불 금액 | +| total_refunded_points_amount_formatted | string | `0원` | 누적 마일리지 환불 금액 표기 문자열 | +| total_points_used_amount | number | `0` | 사용한 마일리지 금액 | +| total_points_used_amount_formatted | string | `0원` | 사용 마일리지 표기 문자열 | +| total_deposit_used_amount | number | `0` | 사용한 예치금 금액 | +| total_deposit_used_amount_formatted | string | `0원` | 사용 예치금 표기 문자열 | +| total_earned_points_amount | number | `0` | 적립 예정/확정 마일리지 금액 | +| total_earned_points_amount_formatted | string | `0원` | 적립 마일리지 표기 문자열 | +| mc_subtotal_amount | object\|null | `{"KRW":"12,000원","USD":"$8.60"}` | 상품 합계의 다중 통화 스냅샷 (통화코드 → 표기 문자열) | +| mc_total_discount_amount | object\|null | — | 총 할인의 다중 통화 스냅샷 | +| mc_total_shipping_amount | object\|null | — | 총 배송비의 다중 통화 스냅샷 | +| mc_total_amount | object\|null | — | 주문 총액의 다중 통화 스냅샷 | +| mc_total_points_used_amount | object\|null | — | 사용 마일리지의 다중 통화 스냅샷 | +| mc_total_deposit_used_amount | object\|null | — | 사용 예치금의 다중 통화 스냅샷 | +| item_count | integer | `1` | 주문 품목 종류 수 | +| total_quantity | integer | `2` | 주문 총 수량 (옵션 수량 합) | +| ordered_at | string\|null | `2026-07-14T14:32:11+09:00` | 주문 일시 (ISO 8601) | +| ordered_at_formatted | string\|null | `2026-07-14 14:32` | 주문 일시 (사용자 타임존 표기) | +| paid_at | string\|null | `2026-07-14T14:33:02+09:00` | 결제 완료 일시 (ISO 8601) | +| paid_at_formatted | string\|null | `2026-07-14 14:33` | 결제 완료 일시 (사용자 타임존 표기) | +| confirmed_at | string\|null | `null` | 구매확정 일시 (ISO 8601) | +| confirmed_at_formatted | string\|null | `null` | 구매확정 일시 (사용자 타임존 표기) | +| cancelled_at | string\|null | `2026-07-14T15:01:44+09:00` | 취소 일시 (ISO 8601) | +| cancelled_at_formatted | string\|null | `2026-07-14 15:01` | 취소 일시 (사용자 타임존 표기) | +| orderer_name | string\|null | `홍길동` | 주문자 이름 | +| orderer_phone | string\|null | `010-1234-5678` | 주문자 전화번호 | +| orderer_email | string\|null | `guest@example.com` | 주문자 이메일 | +| recipient_name | string\|null | `홍길동` | 수령인 이름 | +| recipient_phone | string\|null | `010-1234-5678` | 수령인 연락처 | +| recipient_zipcode | string\|null | `06234` | 배송지 우편번호 | +| recipient_address | string\|null | `서울특별시 강남구 테헤란로 1` | 배송지 기본 주소 | +| recipient_detail_address | string\|null | `101호` | 배송지 상세 주소 | +| delivery_memo | string\|null | `부재 시 경비실에 맡겨주세요` | 배송 메모 | +| delivery_memo_label | string\|null | `부재 시 경비실` | 배송 메모 프리셋 라벨 | +| options | array | — | 주문 옵션(품목) 목록 (`OrderOptionResource`, 관계 로드 시에만 포함) | +| shipping_address | object\|null | — | 배송지 상세 (`OrderAddressResource`) | +| payment | object\|null | — | 결제 정보 (`OrderPaymentResource`, 관계 로드 시에만 포함) | +| shippings | array | — | 배송 정보 목록 (`OrderShippingResource`, 관계 로드 시에만 포함) | +| cancels | array | — | 취소 이력 목록 (`OrderCancelResource` — 취소 사유·상세·취소일시) | +| abilities | object | `{"can_cancel": false}` | 주문 상태로 판정한 허용 액션 (회원 권한이 아닌 상태 기반). `can_cancel` 은 환경설정 `order_settings.cancellable_statuses` 기준 | + +회원용 `OrderResource` 와 달리 관리자/내부 메모(`admin_memo`, `customer_memo`), 회원 정보(`user`, `user_id`), 내부 계산 스냅샷(`promotions_applied_snapshot` 등)은 노출하지 않습니다. **응답 예시** - +```json +{ + "success": true, + "message": "주문이 취소되었습니다.", + "data": { + "order_number": "20260714000012", + "order_status": "cancelled", + "order_status_label": "주문취소", + "order_status_variant": "danger", + "is_partially_cancelled": false, + "subtotal_amount": 12000, + "subtotal_amount_formatted": "12,000원", + "total_discount_amount": 0, + "total_discount_amount_formatted": "0원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_amount": 15000, + "total_amount_formatted": "15,000원", + "total_paid_amount": 15000, + "total_paid_amount_formatted": "15,000원", + "total_cancelled_amount": 15000, + "total_cancelled_amount_formatted": "15,000원", + "total_refunded_amount": 15000, + "total_refunded_amount_formatted": "15,000원", + "total_refunded_points_amount": 0, + "total_refunded_points_amount_formatted": "0원", + "total_points_used_amount": 0, + "total_points_used_amount_formatted": "0원", + "total_deposit_used_amount": 0, + "total_deposit_used_amount_formatted": "0원", + "total_earned_points_amount": 0, + "total_earned_points_amount_formatted": "0원", + "item_count": 1, + "total_quantity": 2, + "ordered_at": "2026-07-14T14:32:11+09:00", + "ordered_at_formatted": "2026-07-14 14:32", + "paid_at": "2026-07-14T14:33:02+09:00", + "paid_at_formatted": "2026-07-14 14:33", + "confirmed_at": null, + "confirmed_at_formatted": null, + "cancelled_at": "2026-07-14T15:01:44+09:00", + "cancelled_at_formatted": "2026-07-14 15:01", + "orderer_name": "홍길동", + "orderer_phone": "010-1234-5678", + "orderer_email": "guest@example.com", + "recipient_name": "홍길동", + "recipient_phone": "010-1234-5678", + "recipient_zipcode": "06234", + "recipient_address": "서울특별시 강남구 테헤란로 1", + "recipient_detail_address": "101호", + "delivery_memo": null, + "delivery_memo_label": null, + "options": [], + "shipping_address": {}, + "shippings": [], + "cancels": [], + "abilities": { + "can_cancel": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치 (토큰이 지목한 주문 외에는 접근 불가) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 (`error.errors` 에 필드별 메시지), 또는 취소 처리 실패 (`주문 취소에 실패했습니다.`) | @@ -329,18 +478,87 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AdjustmentResult::toPreviewArray()` — 실제 취소는 수행하지 않는 환불 예상 계산 결과)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| refund_amount | number | `12000` | PG 환불 예상 금액 (음수면 추가결제 필요) | +| refund_points_amount | number | `0` | 마일리지 환불 예상 금액 | +| original_paid_amount | number | `15000` | 원 결제 금액 | +| recalculated_paid_amount | number | `3000` | 취소 후 재계산된 결제 금액 | +| shipping_difference | number | `0` | 배송비 차이 (양수: 환불 / 음수: 추가결제) | +| discount_difference | number | `0` | 할인 차이 (양수: 할인 감소분) | +| additional_payment_amount | number | `0` | 추가결제 필요 금액 (환불액이 음수일 때만 > 0) | +| cancelled_items | array | — | 취소 대상 항목 정보 (`order_option_id`, `cancel_quantity`, `cancel_amount`) | +| refund_priority | string | `pg_first` | 적용된 환불 배분 우선순위 (`pg_first`, `points_first`) | +| remaining_pg_balance | number | `0` | 환불 후 잔여 PG 결제 잔액 | +| remaining_points_balance | number | `0` | 환불 후 잔여 마일리지 잔액 | +| refund_total | number | `12000` | 총 환불 예상액 (PG 환불액 + 마일리지 환불액) | +| refund_formatted | object | — | 환불 총액·잔액의 표기 문자열 (base 통화 + 결제 통화 병기 — 취소 모달 표기 SSoT) | +| restored_coupons | array | — | 취소로 복원되는 쿠폰 정보 (`coupon_name`, `discount_amount`) | +| shipping_details | array | — | 배송비 정책별 상세 (`policy_name`, `base_difference`, `extra_difference`, `total_difference`) | +| mc_refund_amount | object\|null | — | PG 환불 금액의 다중 통화 표기 (통화코드 → 금액) | +| mc_refund_points_amount | object\|null | — | 마일리지 환불 금액의 다중 통화 표기 | +| mc_refund_shipping_amount | object\|null | — | 배송비 환불 금액의 다중 통화 표기 | +| original_snapshot | object | — | 재계산 전 주문 금액 스냅샷 | +| recalculated_snapshot | object | — | 재계산 후 주문 금액 스냅샷 | +| mc_original_snapshot | object\|null | — | 재계산 전 다중 통화 스냅샷 | +| mc_recalculated_snapshot | object\|null | — | 재계산 후 다중 통화 스냅샷 | +| original_coupons | array | — | 원 주문에 적용된 쿠폰 상세 (`name`, `target_type`, `discount_amount`) | +| recalculated_coupons | array | — | 재계산 후 유지되는 쿠폰 상세 | +| cancel_blocked | boolean | `false` | 취소 차단 여부 (부분취소로 쿠폰 조건이 깨져 추가결제가 필요해지는 경우 true) | +| cancel_blocked_reason | string\|null | `null` | 차단 사유 메시지 (차단이 아니면 `null`) | **응답 예시** - +```json +{ + "success": true, + "message": "환불 예상금액을 조회했습니다.", + "data": { + "refund_amount": 12000, + "refund_points_amount": 0, + "original_paid_amount": 15000, + "recalculated_paid_amount": 3000, + "shipping_difference": 0, + "discount_difference": 0, + "additional_payment_amount": 0, + "cancelled_items": [ + { + "order_option_id": 34, + "cancel_quantity": 2, + "cancel_amount": 12000 + } + ], + "refund_priority": "pg_first", + "remaining_pg_balance": 3000, + "remaining_points_balance": 0, + "refund_total": 12000, + "refund_formatted": {}, + "restored_coupons": [], + "shipping_details": [], + "mc_refund_amount": null, + "mc_refund_points_amount": null, + "mc_refund_shipping_amount": null, + "original_snapshot": {}, + "recalculated_snapshot": {}, + "mc_original_snapshot": null, + "mc_recalculated_snapshot": null, + "original_coupons": [], + "recalculated_coupons": [], + "cancel_blocked": false, + "cancel_blocked_reason": null + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 환불 예상금액 계산 실패 (`환불 예상금액 조회에 실패했습니다.`) | @@ -370,17 +588,51 @@ Accept: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`GuestOrderResource` — 구매확정 후 갱신된 주문 상세). 필드 구성은 [비회원 주문 취소 응답 필드 표](#guest-order-resource)와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_number | string | `20260714000012` | 주문번호 | +| order_status | string | `confirmed` | 구매확정 반영된 주문 상태 | +| order_status_label | string\|null | `구매확정` | 주문 상태의 다국어 라벨 | +| confirmed_at | string\|null | `2026-07-14T16:10:03+09:00` | 구매확정 일시 (ISO 8601) | +| options | array | — | 주문 옵션 목록 — 확정된 옵션의 상태가 갱신되어 포함 | +| abilities | object | `{"can_cancel": false}` | 주문 상태 기반 허용 액션 | +| _(그 외)_ | — | — | 금액·일시·주문자/수취인·배송지·결제·배송·취소이력 필드는 [비회원 주문 취소 응답 필드 표](#guest-order-resource)와 동일 | **응답 예시** - +```json +{ + "success": true, + "message": "구매확정이 완료되었습니다.", + "data": { + "order_number": "20260714000012", + "order_status": "confirmed", + "order_status_label": "구매확정", + "order_status_variant": "success", + "is_partially_cancelled": false, + "total_amount": 15000, + "total_amount_formatted": "15,000원", + "confirmed_at": "2026-07-14T16:10:03+09:00", + "confirmed_at_formatted": "2026-07-14 16:10", + "options": [], + "shipping_address": {}, + "shippings": [], + "cancels": [], + "abilities": { + "can_cancel": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치, 또는 `optionId` 가 해당 주문에 속하지 않는 경우 | +| 422 | Unprocessable Entity | 구매확정 불가 상태 (배송 미완료 등 — `현재 상태에서는 구매확정할 수 없습니다.`) | @@ -439,18 +691,54 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`GuestOrderResource` — 배송지 수정 후 갱신된 주문 상세). 필드 구성은 [비회원 주문 취소 응답 필드 표](#guest-order-resource)와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_number | string | `20260714000012` | 주문번호 | +| recipient_name | string\|null | `홍길동` | 수정 반영된 수령인 이름 | +| recipient_phone | string\|null | `010-1234-5678` | 수정 반영된 수령인 연락처 | +| recipient_zipcode | string\|null | `06234` | 수정 반영된 우편번호 | +| recipient_address | string\|null | `서울특별시 강남구 테헤란로 1` | 수정 반영된 기본 주소 | +| recipient_detail_address | string\|null | `101호` | 수정 반영된 상세 주소 | +| delivery_memo | string\|null | `부재 시 경비실에 맡겨주세요` | 수정 반영된 배송 메모 | +| shipping_address | object\|null | — | 배송지 상세 (`OrderAddressResource` — 국제 주소 필드 포함) | +| _(그 외)_ | — | — | 상태·금액·일시·옵션·결제·배송·취소이력·abilities 필드는 [비회원 주문 취소 응답 필드 표](#guest-order-resource)와 동일 | **응답 예시** - +```json +{ + "success": true, + "message": "배송지가 변경되었습니다.", + "data": { + "order_number": "20260714000012", + "order_status": "payment_complete", + "order_status_label": "결제완료", + "recipient_name": "홍길동", + "recipient_phone": "010-1234-5678", + "recipient_zipcode": "06234", + "recipient_address": "서울특별시 강남구 테헤란로 1", + "recipient_detail_address": "101호", + "delivery_memo": "부재 시 경비실에 맡겨주세요", + "delivery_memo_label": null, + "shipping_address": {}, + "options": [], + "shippings": [], + "cancels": [], + "abilities": { + "can_cancel": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 (`error.errors` 에 필드별 메시지), 또는 배송이 이미 시작되어 수정 불가 (`배송 전 상태에서만 배송지를 변경할 수 있습니다.`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md b/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md index c434fe64..4e7810e8 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md @@ -40,11 +40,23 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 삭제 성공 여부 (컨트롤러가 상수 `true` 로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "문의가 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** @@ -53,6 +65,8 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.delete`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteInquiry()` 의 `RuntimeException` | +| 500 | Internal Server Error | 삭제 처리 중 예기치 못한 예외 (`문의 삭제에 실패했습니다.`) | @@ -82,11 +96,23 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 답변 삭제 성공 여부 (컨트롤러가 상수 `true` 로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** @@ -95,6 +121,8 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 삭제 처리 중 예기치 못한 예외 (`답변 삭제에 실패했습니다.`) | @@ -130,11 +158,25 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 답변이 등록된 문의(피벗 `product_inquiries`)의 기본 키 | +| is_answered | boolean | `true` | 답변 등록 후의 문의 답변 완료 여부 | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 등록되었습니다.", + "data": { + "id": 1, + "is_answered": true + } +} +``` **에러 응답** @@ -143,7 +185,8 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) | @@ -179,11 +222,23 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 답변을 수정한 문의(피벗 `product_inquiries`)의 기본 키 (요청 path 의 `inquiryId` 를 그대로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 수정되었습니다.", + "data": { + "id": 1 + } +} +``` **에러 응답** @@ -192,7 +247,8 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 수정 처리 중 예기치 못한 예외 (`답변 수정에 실패했습니다.`) | @@ -372,18 +428,32 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 삭제 성공 여부 (컨트롤러가 상수 `true` 로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "문의가 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 문의 작성자(`user_id`)가 로그인 사용자와 다른 경우 (`해당 문의에 대한 권한이 없습니다.`) | +| 404 | Not Found | 해당 ID 의 문의가 존재하지 않는 경우 (`문의를 찾을 수 없습니다.`) | +| 500 | Internal Server Error | 삭제 처리 중 예기치 못한 예외 (`문의 삭제에 실패했습니다.`) — 문의 게시판 미설정 등 서비스 `RuntimeException` 포함 | @@ -401,10 +471,10 @@ Authorization: Bearer {YOUR_TOKEN} | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | -| title | body | string | 아니오 | min 2, max 200 | 제목 | +| title | body | string | 아니오 | — | 문의 제목 (연동 게시판 Post 의 제목으로 저장. 게시판 설정에 따라 min/max 길이 규칙이 Filter 훅으로 추가됨) | | category | body | string | 아니오 | — | 문의 분류 (게시판 설정 기반 유형 슬러그, 연동 게시판 Post 로 저장) | -| content | body | string | 예 | min 10, max 10000 | 본문 내용 | -| is_secret | body | boolean | 아니오 | — | secret 여부 | +| content | body | string | 예 | — | 문의 본문 (필수. 게시판 설정에 따라 min/max 길이 규칙이 Filter 훅으로 추가됨) | +| is_secret | body | boolean | 아니오 | — | 비밀글 여부 (게시판 비밀글 모드 설정에 따라 적용) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.inquiry.update_validation_rules`). @@ -427,19 +497,33 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정한 문의(피벗 `product_inquiries`)의 기본 키 (요청 path 의 `inquiryId` 를 그대로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "문의가 수정되었습니다.", + "data": { + "id": 1 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 문의 작성자(`user_id`)가 로그인 사용자와 다른 경우 (`해당 문의에 대한 권한이 없습니다.`) | +| 404 | Not Found | 해당 ID 의 문의가 존재하지 않는 경우 (`문의를 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateInquiry()` 의 `RuntimeException` | +| 500 | Internal Server Error | 수정 처리 중 예기치 못한 예외 (`문의 수정에 실패했습니다.`) | @@ -469,18 +553,33 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 답변 삭제 성공 여부 (컨트롤러가 상수 `true` 로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 삭제 처리 중 예기치 못한 예외 (`답변 삭제에 실패했습니다.`) | @@ -516,19 +615,35 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 답변이 등록된 문의(피벗 `product_inquiries`)의 기본 키 | +| is_answered | boolean | `true` | 답변 등록 후의 문의 답변 완료 여부 | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 등록되었습니다.", + "data": { + "id": 1, + "is_answered": true + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) | @@ -564,19 +679,33 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 답변을 수정한 문의(피벗 `product_inquiries`)의 기본 키 (요청 path 의 `inquiryId` 를 그대로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 수정되었습니다.", + "data": { + "id": 1 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 수정 처리 중 예기치 못한 예외 (`답변 수정에 실패했습니다.`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md index 134c535a..f8236706 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md @@ -268,11 +268,94 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (성공 시 HTTP 201). 생성 직후 모델을 그대로 직렬화하므로 관계 기반 필드(`user_name`·`user_uuid`·`granted_by_name`·`granted_by_uuid`·`order_number`)는 eager load 되지 않아 응답에서 생략됩니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `529` | 생성된 거래의 기본 키 | +| user_id | integer | `166` | 거래 대상 회원 ID (요청의 `user_id` uuid 를 내부 ID 로 해석한 값) | +| currency | string | `KRW` | 거래 기록 통화 코드 (요청 `currency`) | +| type | string | `admin_earn` | 거래 유형 (`action=earn` → `admin_earn`, `action=deduct` → `admin_deduct`) | +| type_label | string | `관리자 지급` | `type` 값의 사람이 읽는 라벨 | +| admin_badge_group | string | `amber` | 관리자 내역 화면 배지 색상 그룹 (수동/회수계=amber) | +| user_display_category | string | `adjust` | 회원 마이페이지 표시용 4분류 (수동 지급/차감은 adjust) | +| amount | integer | `1000` | 거래 금액 (지급=양수, 차감=음수) | +| amount_formatted | string | `1,000원` | `amount` 의 표시용 포맷 문자열 | +| remaining_amount | integer | `1000` | 잔여 금액 (지급 시 amount 와 동일, 차감 거래는 0) | +| remaining_amount_formatted | string | `1,000원` | `remaining_amount` 의 표시용 포맷 문자열 | +| balance_after | integer | `1000` | 거래 직후 해당 통화 잔액 스냅샷 | +| order_id | null | `null` | 수동 거래이므로 연결 주문 없음 | +| order_option_id | null | `null` | 수동 거래이므로 연결 주문 옵션 없음 | +| order_cancel_id | null | `null` | 수동 거래이므로 연결 주문취소 없음 | +| source_transaction_id | null | `null` | 수동 거래이므로 원본 거래 없음 | +| granted_by | integer | `1` | 처리한 관리자 user ID (인증 사용자) | +| description | string | `관리자 마일리지 지급 (1,000)` | 설명 (요청 `description` 미지정 시 활동로그 기본 문구) | +| memo | string \| null | `수기 보상` | 관리자 메모 (요청 `memo`) | +| expires_at | string \| null | `2027-07-02T15:29:30+00:00` | 만료 일시 (지급 시 정책 기본 또는 `expires_at`, 차감 거래는 null) | +| expires_at_formatted | string \| null | `2027-07-03 00:29:30` | `expires_at` 의 표시용 포맷 문자열 | +| expires_at_date | string \| null | `2027-07-03` | `expires_at` 의 사이트 타임존 기준 날짜 | +| expired_at | null | `null` | 소멸 일시 (신규 거래는 항상 null) | +| expired_at_formatted | null | `null` | `expired_at` 의 표시용 포맷 문자열 | +| created_at | string | `2026-07-07T05:47:31+00:00` | 생성 일시 | +| created_at_formatted | string | `2026-07-07 14:47:31` | `created_at` 의 표시용 포맷 문자열 | +| created_at_date | string | `2026-07-07` | `created_at` 의 사이트 타임존 기준 날짜 | +| is_earning | boolean | `true` | 적립계 여부 (`admin_earn`=true, `admin_deduct`=false) | +| can_edit_expiry | boolean | `true` | 만료일 편집 가능 여부 (적립계 + 미소멸 + 잔여>0) | +| expired_amount | integer | `0` | 이 적립 lot 을 source 로 소멸된 금액 합계 (단건 응답은 집계 미주입 → 0 폴백) | +| expired_amount_formatted | string | `0원` | `expired_amount` 의 표시용 포맷 문자열 | +| expiry_state | string | `active` | 적립건 소멸 상태 (신규 거래는 active) | +| abilities | object | `{"can_manage":true,"can_edit":true}` | 현재 사용자가 이 거래에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "마일리지 거래가 처리되었습니다.", + "data": { + "id": 529, + "user_id": 166, + "currency": "KRW", + "type": "admin_earn", + "type_label": "관리자 지급", + "admin_badge_group": "amber", + "user_display_category": "adjust", + "amount": 1000, + "amount_formatted": "1,000원", + "remaining_amount": 1000, + "remaining_amount_formatted": "1,000원", + "balance_after": 1000, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": null, + "granted_by": 1, + "description": "관리자 마일리지 지급 (1,000)", + "memo": "수기 보상", + "expires_at": "2027-07-02T15:29:30+00:00", + "expires_at_formatted": "2027-07-03 00:29:30", + "expires_at_date": "2027-07-03", + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-07-07T05:47:31+00:00", + "created_at_formatted": "2026-07-07 14:47:31", + "created_at_date": "2026-07-07", + "is_earning": true, + "can_edit_expiry": true, + "expired_amount": 0, + "expired_amount_formatted": "0원", + "expiry_state": "active", + "abilities": { + "can_manage": true, + "can_edit": true + } + } +} +``` **에러 응답** @@ -280,7 +363,7 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.manage`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반했거나, 도메인 규칙 위반(`MileageValidationException` — 예: 차감액이 잔액 초과) 시. 후자는 `message`=`마일리지 처리에 실패했습니다.` + `error.errors.general[]` 에 사유 문장 | @@ -321,11 +404,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| extended_count | integer | `2` | 실제로 만료일이 연장된 적립 lot 수 (잔여 없는/대상 외 lot 은 제외되므로 요청한 `lot_ids` 수보다 작을 수 있음) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "마일리지 유효기간이 연장되었습니다.", + "data": { + "extended_count": 2 + } +} +``` **에러 응답** @@ -371,11 +470,94 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 거래 전체). 관계 기반 필드(`user_name`·`user_uuid`·`granted_by_name`·`granted_by_uuid`·`order_number`)는 eager load 되지 않아 응답에서 생략됩니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `528` | 수정한 거래의 기본 키 (path `{id}`) | +| user_id | integer | `166` | 거래 대상 회원 ID | +| currency | string | `KRW` | 거래 기록 통화 코드 | +| type | string | `admin_earn` | 거래 유형 (적립계만 수정 가능 — purchase_earn·admin_earn) | +| type_label | string | `관리자 지급` | `type` 값의 사람이 읽는 라벨 | +| admin_badge_group | string | `amber` | 관리자 내역 화면 배지 색상 그룹 | +| user_display_category | string | `adjust` | 회원 마이페이지 표시용 4분류 | +| amount | integer | `1000` | 거래 금액 (원장 불변 — 수정 불가) | +| amount_formatted | string | `1,000원` | `amount` 의 표시용 포맷 문자열 | +| remaining_amount | integer | `1000` | 잔여 금액 (FIFO 차감으로 소진) | +| remaining_amount_formatted | string | `1,000원` | `remaining_amount` 의 표시용 포맷 문자열 | +| balance_after | integer | `1000` | 거래 직후 잔액 스냅샷 | +| order_id | integer \| null | `null` | 연결 주문 ID | +| order_option_id | integer \| null | `null` | 연결 주문 옵션 ID | +| order_cancel_id | integer \| null | `null` | 연결 주문취소 ID | +| source_transaction_id | integer \| null | `null` | 원본 거래 ID | +| granted_by | integer \| null | `1` | 부여 주체 user ID (NULL=시스템 자동) | +| description | string \| null | `관리자 마일리지 지급 (1,000)` | 설명 | +| memo | string \| null | `사유 보정` | 갱신된 관리자 메모 (요청에 `memo` 를 포함한 경우 반영, 빈 값이면 null) | +| expires_at | string \| null | `2027-08-01T15:00:00+00:00` | 갱신된 만료 일시 (요청에 `expires_at` 을 포함한 경우 반영) | +| expires_at_formatted | string \| null | `2027-08-02 00:00:00` | `expires_at` 의 표시용 포맷 문자열 | +| expires_at_date | string \| null | `2027-08-02` | `expires_at` 의 사이트 타임존 기준 날짜 | +| expired_at | null | `null` | 소멸 일시 (소멸된 lot 은 만료일 수정 불가) | +| expired_at_formatted | null | `null` | `expired_at` 의 표시용 포맷 문자열 | +| created_at | string | `2026-07-07T05:47:31+00:00` | 생성 일시 | +| created_at_formatted | string | `2026-07-07 14:47:31` | `created_at` 의 표시용 포맷 문자열 | +| created_at_date | string | `2026-07-07` | `created_at` 의 사이트 타임존 기준 날짜 | +| is_earning | boolean | `true` | 적립계 여부 (수정 대상은 항상 true) | +| can_edit_expiry | boolean | `true` | 만료일 편집 가능 여부 (적립계 + 미소멸 + 잔여>0) | +| expired_amount | integer | `0` | 소멸 금액 합계 (단건 응답은 집계 미주입 → 0 폴백) | +| expired_amount_formatted | string | `0원` | `expired_amount` 의 표시용 포맷 문자열 | +| expiry_state | string | `active` | 적립건 소멸 상태 (active·partial_expired·fully_expired) | +| abilities | object | `{"can_manage":true,"can_edit":true}` | 현재 사용자가 이 거래에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "마일리지 거래가 수정되었습니다.", + "data": { + "id": 528, + "user_id": 166, + "currency": "KRW", + "type": "admin_earn", + "type_label": "관리자 지급", + "admin_badge_group": "amber", + "user_display_category": "adjust", + "amount": 1000, + "amount_formatted": "1,000원", + "remaining_amount": 1000, + "remaining_amount_formatted": "1,000원", + "balance_after": 1000, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": null, + "granted_by": 1, + "description": "관리자 마일리지 지급 (1,000)", + "memo": "사유 보정", + "expires_at": "2027-08-01T15:00:00+00:00", + "expires_at_formatted": "2027-08-02 00:00:00", + "expires_at_date": "2027-08-02", + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-07-07T05:47:31+00:00", + "created_at_formatted": "2026-07-07 14:47:31", + "created_at_date": "2026-07-07", + "is_earning": true, + "can_edit_expiry": true, + "expired_amount": 0, + "expired_amount_formatted": "0원", + "expiry_state": "active", + "abilities": { + "can_manage": true, + "can_edit": true + } + } +} +``` **에러 응답** @@ -384,7 +566,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.manage`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반했거나, 도메인 규칙 위반(`MileageValidationException`) 시. `message`=`마일리지 처리에 실패했습니다.` + `error.errors.general[]` 에 사유 문장 — 거래 없음(`마일리지 거래를 찾을 수 없습니다.`), 적립계 아님(`적립 거래만 수정할 수 있습니다.`), 소멸/전액 사용된 적립건(`이미 소멸되었거나 모두 사용된 적립건은 유효기간을 변경할 수 없습니다.`), 만료일이 적립일시 이전(`유효기간은 적립일시보다 이전일 수 없습니다.`) | @@ -414,7 +596,44 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_목록 응답: `data.data[]` 배열 항목의 필드. 페이지네이션 없는 컬렉션이라 `data.pagination` 은 없고, 컬렉션 레벨 `data.abilities`(`can_manage`)와 통화 필터 후보 `data.currencies`(이 엔드포인트는 주입하지 않아 항상 `[]`)가 함께 내려옵니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `1` | 목록에서의 순번 (HasRowNumber 파생) | +| id | integer | `531` | 연결 거래의 기본 키 | +| user_id | integer | `166` | 거래 대상 회원 ID | +| currency | string | `KRW` | 거래 기록 통화 코드 | +| type | string | `order_use` | 거래 유형 (적립건이면 이를 FIFO 로 소비한 차감 거래, 차감/복원건이면 원본 적립·취소 연결 거래) | +| type_label | string | `주문 사용` | `type` 값의 사람이 읽는 라벨 | +| admin_badge_group | string | `blue` | 관리자 내역 화면 배지 색상 그룹 | +| user_display_category | string | `use` | 회원 마이페이지 표시용 4분류 | +| amount | integer | `-500` | 거래 금액 (양수=적립, 음수=차감) | +| amount_formatted | string | `-500원` | `amount` 의 표시용 포맷 문자열 | +| remaining_amount | integer | `0` | 잔여 금액 (적립건만 양수) | +| remaining_amount_formatted | string | `0원` | `remaining_amount` 의 표시용 포맷 문자열 | +| balance_after | integer | `500` | 거래 직후 잔액 스냅샷 | +| order_id | integer \| null | `436` | 연결 주문 ID | +| order_option_id | integer \| null | `824` | 연결 주문 옵션 ID | +| order_cancel_id | integer \| null | `null` | 연결 주문취소 ID | +| source_transaction_id | integer \| null | `528` | 원본(source) 거래 ID — FIFO 소비된 적립 lot 또는 복원 원본 | +| granted_by | integer \| null | `null` | 부여 주체 user ID (NULL=시스템 자동) | +| description | string \| null | `주문 마일리지 사용` | 설명 | +| memo | string \| null | `null` | 관리자 메모 | +| expires_at | string \| null | `null` | 만료 일시 | +| expires_at_formatted | string \| null | `null` | `expires_at` 의 표시용 포맷 문자열 | +| expires_at_date | string \| null | `null` | `expires_at` 의 사이트 타임존 기준 날짜 | +| expired_at | string \| null | `null` | 소멸 일시 | +| expired_at_formatted | string \| null | `null` | `expired_at` 의 표시용 포맷 문자열 | +| created_at | string | `2026-07-07T05:47:31+00:00` | 생성 일시 | +| created_at_formatted | string | `2026-07-07 14:47:31` | `created_at` 의 표시용 포맷 문자열 | +| created_at_date | string | `2026-07-07` | `created_at` 의 사이트 타임존 기준 날짜 | +| is_earning | boolean | `false` | 적립계 여부 | +| can_edit_expiry | boolean | `false` | 만료일 편집 가능 여부 | +| expired_amount | integer | `0` | 소멸 금액 합계 (연결 거래 조회는 집계 미주입 → 0 폴백) | +| expired_amount_formatted | string | `0원` | `expired_amount` 의 표시용 포맷 문자열 | +| expiry_state | string | `active` | 적립건 소멸 상태 (적립계만 의미) | +| abilities | object | `{"can_manage":true,"can_edit":false}` | 현재 사용자가 이 거래에 수행 가능한 작업 불리언 맵 | **응답 예시** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md index 18d96c08..1ccf83de 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md @@ -229,11 +229,29 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| max_usable | integer | `1000` | 해당 주문금액(`order_amount`)에 사용 가능한 최대 마일리지. 보유 잔액·최대 사용 한도(percent/fixed)·주문금액 상한 중 최솟값을 취한 뒤 사용 단위(`use_unit`)로 내림 보정한 값 (기본 통화 사용 규칙 미설정 시 `0`) | +| available | integer | `12910` | 현재 사용 가능한 마일리지 잔액 (`getBalance()` 의 `available` 값과 동일) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용 가능한 최대 마일리지를 조회했습니다.", + "data": { + "max_usable": 1000, + "available": 12910 + } +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/options.md b/modules/_bundled/sirsoft-ecommerce/docs/api/options.md index c8b78e81..46f4b59a 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/options.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/options.md @@ -59,11 +59,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductOptionService::bulkUpdatePriceByMixedIds()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 판매가가 변경된 옵션 건수 (`product_ids` 로 확장된 옵션 + `option_ids` 로 개별 선택된 옵션을 중복 제거한 대상 수) | +| requested_product_count | integer | `1` | 요청에 담긴 `product_ids` 배열의 원소 수 (옵션만 선택한 경우 `0`) | **응답 예시** - +```json +{ + "success": true, + "message": ":count개 옵션의 가격이 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_product_count": 1 + } +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.options.bulk_price_updated` 를 그대로 사용합니다. 컨트롤러가 `messageParams` 를 전달하지 않으므로 `:count` 플레이스홀더는 치환되지 않은 채 응답됩니다 (변경 건수는 `data.updated_count` 로 확인). **에러 응답** @@ -71,7 +87,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / Service 단계에서 `ValidationException` 발생 시 `옵션 가격 일괄 변경에 실패했습니다.` | +| 500 | Internal Server Error | 그 외 예외 발생 시 (`옵션 가격 일괄 변경에 실패했습니다.`) | @@ -118,11 +135,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductOptionService::bulkUpdateStockByMixedIds()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 재고가 변경된 옵션 건수 (`product_ids` 로 확장된 옵션 + `option_ids` 로 개별 선택된 옵션을 중복 제거한 대상 수) | +| requested_product_count | integer | `1` | 요청에 담긴 `product_ids` 배열의 원소 수 (옵션만 선택한 경우 `0`) | **응답 예시** - +```json +{ + "success": true, + "message": ":count개 옵션의 재고가 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_product_count": 1 + } +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.options.bulk_stock_updated` 를 그대로 사용합니다. 컨트롤러가 `messageParams` 를 전달하지 않으므로 `:count` 플레이스홀더는 치환되지 않은 채 응답됩니다 (변경 건수는 `data.updated_count` 로 확인). **에러 응답** @@ -130,7 +163,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / Service 단계에서 `ValidationException` 발생 시 `옵션 재고 일괄 변경에 실패했습니다.` | +| 500 | Internal Server Error | 그 외 예외 발생 시 (`옵션 재고 일괄 변경에 실패했습니다.`) | @@ -147,15 +181,15 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| ids | body | array | 예 | min 1 | 대상 옵션 식별자 배열. 각 원소는 `"productId-optionId"` 형식 문자열 (`regex:/^\d+-\d+$/`) | | bulk_changes | body | array | 아니오 | — | 옵션 일괄 변경 조건 (`price_adjustment`/`stock_quantity` 각각 method+value, 설정된 필드가 개별 수정보다 우선 적용) | -| bulk_changes.price_adjustment | body | array | 아니오 | — | | -| bulk_changes.price_adjustment.method | body | string | 아니오 | `set`, `add`, `percent` | | -| bulk_changes.price_adjustment.value | body | number | 아니오 | — | | -| bulk_changes.stock_quantity | body | array | 아니오 | — | | -| bulk_changes.stock_quantity.method | body | string | 아니오 | `set`, `add`, `subtract` | | -| bulk_changes.stock_quantity.value | body | integer | 아니오 | min 0 | | -| items | body | array | 아니오 | — | 처리 대상 항목 배열 | +| bulk_changes.price_adjustment | body | array | 아니오 | — | 옵션 추가금액(`price_adjustment`) 일괄 변경 조건 객체. 존재 시 하위 `method`/`value` 필수 | +| bulk_changes.price_adjustment.method | body | string | 아니오 | `set`, `add`, `percent` | 추가금액 변경 방식 (set 지정값으로 설정 / add 기존값에 가산 / percent 기존값에 비율 적용) | +| bulk_changes.price_adjustment.value | body | number | 아니오 | — | 추가금액 변경 값 (`method` 에 따라 설정값·가감액·비율로 해석. 소수 통화 대응) | +| bulk_changes.stock_quantity | body | array | 아니오 | — | 옵션 재고수량(`stock_quantity`) 일괄 변경 조건 객체. 존재 시 하위 `method`/`value` 필수 | +| bulk_changes.stock_quantity.method | body | string | 아니오 | `set`, `add`, `subtract` | 재고 변경 방식 (set 특정 수량으로 설정 / add 증가 / subtract 감소) | +| bulk_changes.stock_quantity.value | body | integer | 아니오 | min 0 | 재고 변경 값 (`method` 에 따라 설정 수량·증감 수량으로 해석) | +| items | body | array | 아니오 | — | 개별 인라인 수정 항목 배열. 항목별 키: `product_id`(필수, 존재하는 상품), `option_id`(필수, 존재하는 옵션), `option_name`(로케일별 문자열 배열, 각 max 255), `sku`(max 100), `list_price`(min 0), `price_adjustment`, `stock_quantity`(min 0), `safe_stock_quantity`(min 0), `is_default`, `is_active`. `bulk_changes` 에 설정된 필드는 개별값보다 우선 적용되어 무시됨 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.option.bulk_update_validation_rules`). @@ -193,11 +227,25 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductOptionService::bulkUpdate()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| options_updated | integer | `5` | 적용된 변경 횟수. 일괄 변경(`bulk_changes.price_adjustment`, `bulk_changes.stock_quantity`)은 각 조건마다 대상 옵션 수만큼 누적되고, 개별 인라인 수정(`items`)은 실제로 변경 필드가 남은 항목마다 1씩 누적됩니다 (대상 옵션이 없으면 `0`) | **응답 예시** - +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.options.bulk_updated", + "data": { + "options_updated": 5 + } +} +``` + +> 컨트롤러가 사용하는 메시지 키 `messages.options.bulk_updated` 는 모듈 lang(ko/en)에 정의되어 있지 않습니다(정의된 키는 `messages.options.bulk_update_success`). 따라서 `message` 에는 번역문 대신 키 문자열이 그대로 반환됩니다. **에러 응답** @@ -205,7 +253,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / Service 단계에서 `ValidationException` 발생 시 `옵션 일괄 업데이트에 실패했습니다.` | +| 500 | Internal Server Error | 그 외 예외 발생 시 (`옵션 일괄 업데이트에 실패했습니다.`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index 00eace9b..327f6884 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -329,11 +329,29 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`OrderService::bulkUpdate()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 변경된 주문 건수 (상태 변경 건수와 배송정보 변경 건수 중 큰 값) | +| requested_count | integer | `3` | 요청한 `ids` 배열의 건수 (대상 주문 수) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 주문이 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_count": 3 + } +} +``` **에러 응답** @@ -341,7 +359,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 상태 전이 규칙 위반(현재 상태 → 목표 상태 불가), 배송 관련 상태 전환 시 `carrier_id`/`tracking_number` 누락, 취소→판매상태 복원 시 재고 부족 등도 여기에 해당 (`일괄 처리에 실패했습니다.`) | +| 500 | Server Error | 일괄 처리 중 예기치 못한 오류 (`일괄 처리에 실패했습니다.`) | @@ -371,11 +390,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 소프트 삭제 성공 여부 (항상 `true`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문이 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** @@ -618,11 +653,66 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 수정된 주문을 `OrderResource` 로 반환하므로 필드 구성은 `GET /admin/orders/{order}` (주문 상세) 의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| order_number | string | `20260706-1405449337` | 주문번호 | +| order_status | string | `preparing` | 수정 반영된 주문상태 (OrderStatusEnum) | +| order_status_label | string | `상품준비중` | `order_status` 값의 사람이 읽는 라벨 | +| admin_memo | string | `고객 요청으로 주소 정정` | 수정 반영된 관리자 메모 | +| recipient_name | string | `심채원` | 수정 반영된 수령인 이름 | +| recipient_phone | string | `010-3955-6018` | 수정 반영된 수령인 휴대전화 | +| recipient_zipcode | string | `38022` | 수정 반영된 수령인 우편번호 | +| recipient_address | string | `부산광역시 양천구 공항대로 9` | 수정 반영된 수령인 기본 주소 | +| recipient_detail_address | string | `101동 202호` | 수정 반영된 수령인 상세 주소 | +| delivery_memo | string | `parcel_box` | 수정 반영된 배송 메모 | +| shipping_address | object | `{"id":1,"address_type":"shipping",…}` | 배송지 상세 (OrderAddressResource) | +| options / payments / shippings / cancels | array | `[…]` | 주문 상세와 동일한 하위 리소스 목록 | +| updated_at | string | `2026-07-06T14:05:45+00:00` | 최종 수정 일시 | +| abilities | object | `{"can_read":true,"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 나머지 금액/일시/다중통화 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문이 수정되었습니다.", + "data": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "preparing", + "order_status_label": "상품준비중", + "admin_memo": "고객 요청으로 주소 정정", + "recipient_name": "심채원", + "recipient_phone": "010-3955-6018", + "recipient_zipcode": "38022", + "recipient_address": "부산광역시 양천구 공항대로 9", + "recipient_detail_address": "101동 202호", + "delivery_memo": "parcel_box", + "delivery_memo_label": "택배함에 넣어주세요", + "options": [], + "shipping_address": {}, + "payments": [], + "shippings": [], + "cancels": [], + "updated_at": "2026-07-06T14:05:45+00:00", + "abilities": { + "can_read": true, + "can_update": true + } + } +} +``` + +> `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 수정 관련 필드만 발췌). **에러 응답** @@ -631,7 +721,8 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 주문상태 전이 규칙 위반 등 Service 단계 검증 실패도 포함 (`messages.orders.update_failed`) | +| 500 | Server Error | 수정 처리 중 예기치 못한 오류 (`messages.orders.update_failed`) | @@ -685,11 +776,59 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 취소 처리 후 재조회한 주문을 `OrderResource` 로 반환하므로 필드 구성은 `GET /admin/orders/{order}` (주문 상세) 의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| order_number | string | `20260706-1405449337` | 주문번호 | +| order_status | string | `cancelled` | 취소 반영된 주문상태 (전체취소 시 `cancelled`, 부분취소 시 기존 상태 유지) | +| is_partially_cancelled | boolean | `true` | 부분취소 여부 (일부 옵션만 취소된 경우 `true`) | +| total_cancelled_amount | integer | `31000` | 취소 반영 후 총 취소금액 | +| total_refunded_amount | integer | `31000` | 취소 반영 후 총 환불금액 (PG 환불액) | +| total_refunded_points_amount | integer | `0` | 취소 반영 후 환불된 포인트 | +| cancelled_at | string | `2026-07-11T02:10:00+00:00` | 취소 일시 (전체취소 시 기록) | +| cancels | array | `[{"reason":"change_of_mind","reason_detail":null,…}]` | 취소 이력 목록 (OrderCancelResource — 취소 사유·상세·취소일시, 최근순) | +| options | array | `[{"id":1,"option_status":"cancelled",…}]` | 주문 옵션 목록 (취소된 옵션은 `option_status: cancelled`) | +| payments | array | `[{"payment_status":"cancelled",…}]` | 결제 이력 (PG 취소 동반 시 결제상태 반영) | +| abilities | object | `{"can_read":true,"can_update":true,"can_cancel":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 나머지 금액/일시/다중통화 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문이 취소되었습니다.", + "data": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "cancelled", + "order_status_label": "취소완료", + "is_partially_cancelled": false, + "total_amount": 184000, + "total_cancelled_amount": 184000, + "total_refunded_amount": 184000, + "total_refunded_points_amount": 0, + "cancelled_at": "2026-07-11T02:10:00+00:00", + "cancels": [], + "options": [], + "payments": [], + "abilities": { + "can_read": true, + "can_update": true, + "can_cancel": false + } + } +} +``` + +> `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 취소 관련 필드만 발췌). **에러 응답** @@ -698,7 +837,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 취소 처리 실패 — 취소 불가 상태, PG 취소 실패, 가상계좌 환불계좌 정보 누락 등 (`주문 취소에 실패했습니다.` + `errors.detail` 에 예외 메시지) | @@ -1020,11 +1159,53 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 입금확인 후 재조회한 주문을 `OrderResource` 로 반환하므로 필드 구성은 `GET /admin/orders/{order}` (주문 상세) 의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| order_number | string | `20260706-1405449337` | 주문번호 | +| order_status | string | `payment_complete` | 입금확인 반영된 주문상태 (`mark_order_complete` 지정 시 주문완료 상태) | +| order_status_label | string | `결제완료` | `order_status` 값의 사람이 읽는 라벨 | +| total_paid_amount | integer | `184000` | 입금 반영된 총 실제 결제금액 | +| total_due_amount | integer | `0` | 입금 반영 후 남은 결제예정금액 | +| depositor_name | string | `홍길동` | 확인된 무통장 입금자명 | +| paid_at | string | `2026-07-11T02:10:00+00:00` | 결제완료 일시 (입금확인 시점 기록) | +| payments | array | `[{"payment_status":"paid",…}]` | 결제 이력 (OrderPaymentResource — 무통장 결제건이 `paid` 로 전이) | +| abilities | object | `{"can_read":true,"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 나머지 금액/일시/다중통화 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "입금이 확인되어 결제완료 처리되었습니다.", + "data": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "payment_complete", + "order_status_label": "결제완료", + "total_amount": 184000, + "total_paid_amount": 184000, + "total_due_amount": 0, + "depositor_name": "홍길동", + "paid_at": "2026-07-11T02:10:00+00:00", + "payments": [], + "abilities": { + "can_read": true, + "can_update": true + } + } +} +``` + +> `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 입금확인 관련 필드만 발췌). **에러 응답** @@ -1033,7 +1214,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 입금액 불일치(`입금액이 결제예정금액과 일치하지 않습니다.` + `errors.detail`), 또는 입금확인 처리 실패 — 무통장 결제가 아닌 주문·이미 결제완료된 주문 등 (`입금확인 처리에 실패했습니다.` + `errors.detail`) | @@ -1073,11 +1254,88 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AdjustmentResult::toPreviewArray()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| refund_amount | number | `31000` | PG 환불 예상금액 (음수면 추가결제 필요) | +| refund_points_amount | number | `0` | 마일리지(포인트) 환불 예상금액 | +| original_paid_amount | number | `184000` | 재계산 전 원 결제금액 | +| recalculated_paid_amount | number | `153000` | 취소 반영 후 재계산된 결제금액 | +| shipping_difference | number | `0` | 배송비 차이 (양수 환불 / 음수 추가결제) | +| discount_difference | number | `0` | 할인 차이 (양수: 할인 감소분) | +| additional_payment_amount | number | `0` | 추가결제 필요 금액 (환불액이 음수일 때의 절댓값, 없으면 0) | +| cancelled_items | array | `[{"order_option_id":1,"cancel_quantity":1,"cancel_amount":31000}]` | 취소 대상 아이템별 취소 수량·취소 금액 | +| refund_priority | string | `pg_first` | 적용된 환불 배분 우선순위 (RefundPriorityEnum — `pg_first` / `points_first`) | +| remaining_pg_balance | number | `153000` | 환불 후 잔여 PG 결제 잔액 | +| remaining_points_balance | number | `0` | 환불 후 잔여 포인트 잔액 | +| refund_total | number | `31000` | 총 환불 예상금액 (PG 환불액 + 포인트 환불액) | +| refund_formatted | object | `{"refund_total":"31,000원", …}` | 환불 총액·잔액의 base 통화 포맷 문자열 + 결제 통화 병기 (취소 모달 표기 SSoT) | +| restored_coupons | array | `[{"coupon_name":"첫구매 무료배송","discount_amount":0}]` | 취소로 복원되는 쿠폰 정보 | +| shipping_details | array | `[{"policy_name":"기본 배송정책","base_difference":0,"extra_difference":0,"total_difference":0}]` | 배송정책별 배송비 차액 상세 | +| mc_refund_amount | object \| null | `{"KRW":{"amount":31000,"formatted":"31,000원"}, …}` | PG 환불금액 다중 통화 | +| mc_refund_points_amount | object \| null | `{"KRW":{"amount":0,"formatted":"0원"}, …}` | 포인트 환불금액 다중 통화 | +| mc_refund_shipping_amount | object \| null | `{"KRW":{"amount":0,"formatted":"0원"}, …}` | 배송비 환불금액 다중 통화 | +| original_snapshot | object | `{"total_paid_amount":184000,"total_points_used_amount":0, …}` | 재계산 전 주문 금액 스냅샷 | +| recalculated_snapshot | object | `{"total_paid_amount":153000, …}` | 재계산 후 주문 금액 스냅샷 | +| mc_original_snapshot | object \| null | `{"mc_subtotal_amount":{…},"mc_total_paid_amount":{…}}` | 원 주문 다중 통화 스냅샷 | +| mc_recalculated_snapshot | object \| null | `{"mc_subtotal_amount":{…},"mc_total_paid_amount":{…}}` | 재계산 다중 통화 스냅샷 | +| original_coupons | array | `[{"name":"첫구매 무료배송","target_type":"shipping_fee","discount_amount":0}]` | 원 주문에 적용된 쿠폰 상세 | +| recalculated_coupons | array | `[]` | 재계산 후 유지되는 쿠폰 상세 (조건 미달 시 소멸) | +| cancel_blocked | boolean | `false` | 취소 차단 여부 (부분취소로 추가결제가 필요해지는 실결제 주문이면 `true`) | +| cancel_blocked_reason | string \| null | `null` | 차단 사유 문구 (차단이 아니면 `null`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "환불 예상금액을 조회했습니다.", + "data": { + "refund_amount": 31000, + "refund_points_amount": 0, + "original_paid_amount": 184000, + "recalculated_paid_amount": 153000, + "shipping_difference": 0, + "discount_difference": 0, + "additional_payment_amount": 0, + "cancelled_items": [ + { + "order_option_id": 1, + "cancel_quantity": 1, + "cancel_amount": 31000 + } + ], + "refund_priority": "pg_first", + "remaining_pg_balance": 153000, + "remaining_points_balance": 0, + "refund_total": 31000, + "refund_formatted": {}, + "restored_coupons": [], + "shipping_details": [], + "mc_refund_amount": { + "KRW": { + "amount": 31000, + "formatted": "31,000원" + } + }, + "mc_refund_points_amount": null, + "mc_refund_shipping_amount": null, + "original_snapshot": {}, + "recalculated_snapshot": {}, + "mc_original_snapshot": null, + "mc_recalculated_snapshot": null, + "original_coupons": [], + "recalculated_coupons": [], + "cancel_blocked": false, + "cancel_blocked_reason": null + } +} +``` **에러 응답** @@ -1087,6 +1345,7 @@ Content-Type: application/json | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 환불 예상금액 계산 중 오류 (`환불 예상금액 조회에 실패했습니다.`) | @@ -1340,11 +1599,51 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`OrderOptionService::bulkChangeStatusWithQuantity()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| changed_count | integer | `2` | 상태가 변경된 주문 옵션 건수 | +| split_count | integer | `1` | 부분 수량 전환으로 옵션이 분할된 건수 | +| results | array | `[{"order_option_id":1, …}]` | 옵션별 처리 결과 배열 (아래 하위 필드) | +| results[].order_option_id | integer | `1` | 상태를 변경한 원본 주문 옵션 ID | +| results[].split_order_option_id | integer \| null | `57` | 부분 수량 전환으로 새로 분할 생성된 옵션 ID (분할 없으면 `null`) | +| results[].merged_into_order_option_id | integer \| null | `null` | 동일 상태 기존 옵션으로 병합된 경우 그 대상 옵션 ID (병합 없으면 `null`) | +| results[].quantity_changed | integer | `1` | 이번에 상태 전환한 수량 | +| results[].is_full_quantity | boolean | `false` | 옵션의 전체 수량을 전환했는지 여부 (부분 전환이면 `false`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 옵션의 상태가 변경되었습니다.", + "data": { + "changed_count": 2, + "split_count": 1, + "results": [ + { + "order_option_id": 1, + "split_order_option_id": 57, + "merged_into_order_option_id": null, + "quantity_changed": 1, + "is_full_quantity": false + }, + { + "order_option_id": 2, + "split_order_option_id": null, + "merged_into_order_option_id": null, + "quantity_changed": 3, + "is_full_quantity": true + } + ] + } +} +``` **에러 응답** @@ -1353,7 +1652,9 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 옵션 상태 변경 실패 — 옵션 전이 규칙 위반, 취소 후 복원 시 재고 부족 등 (`옵션 상태 변경에 실패했습니다.`) | +| 428 | Identity Verification Required | 결제완료(`payment_complete`) 전이 시 본인인증(IDV) 정책이 enforce 이고 미인증인 경우 | +| 500 | Server Error | 옵션 상태 변경 중 예기치 못한 오류 (`옵션 상태 변경에 실패했습니다.`) | @@ -1393,7 +1694,7 @@ Content-Type: application/json - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`). 재설정한 평문 비밀번호는 응답/로그에 노출하지 않습니다._ **응답 예시** @@ -1418,7 +1719,8 @@ HTTP/1.1 200 | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지), 또는 회원 주문(`user_id` 가 있는 주문)에 호출한 경우 (`비회원 주문만 조회 비밀번호를 재설정할 수 있습니다.`) | +| 500 | Server Error | 재설정 처리 중 예기치 못한 오류 (`비회원 조회 비밀번호 재설정에 실패했습니다.`) | @@ -1456,11 +1758,21 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "이메일이 발송되었습니다.", + "data": null +} +``` **에러 응답** @@ -1507,18 +1819,28 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`). 주문 상태는 변경되지 않고 `order_payments` 에 취소 이력만 기록됩니다._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "결제가 취소되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `orderNumber` 에 해당하는 주문이 없거나 접근 권한이 없는 경우도 포함 (CancelPaymentRequest 의 주문 해석 실패) | @@ -1658,15 +1980,30 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | +| orderer.name | body | string | 예 | max 50 | 대상의 이름/명칭 | +| orderer.phone | body | string | 예 | max 20 | 전화번호 | +| shipping.recipient_name | body | string | 예 | max 50 | shipping.recipient 이름 (식별자) | +| shipping.recipient_phone | body | string | 아니오 | max 20 | 수령인 연락처 | +| shipping.recipient_tel | body | string | 아니오 | max 20 | 수령인 일반전화 (`shipping.recipient_phone` 미입력 시 필수 — 둘 중 하나는 반드시 입력) | +| shipping.country_code | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| shipping.zipcode | body | string | 아니오 | max 10 | 우편번호 | +| shipping.address | body | string | 아니오 | max 255 | 기본 주소 | +| shipping.address_detail | body | string | 예 | max 255 | 상세 주소 | +| shipping.address_type_code | body | string | 아니오 | `R`, `J` | 국내 주소 표기 방식 (`R` 도로명 / `J` 지번) | +| shipping.address_line_1 | body | string | 아니오 | max 255 | 주소 1행 (기본 주소) | +| shipping.address_line_2 | body | string | 아니오 | max 255 | 주소 2행 (상세 주소) | +| shipping.intl_city | body | string | 아니오 | max 100 | 도시 (국제 주소) | +| shipping.intl_state | body | string | 아니오 | max 100 | 주/도 (국제 주소) | +| shipping.intl_postal_code | body | string | 아니오 | max 20 | 우편번호 (국제 주소) | | payment_method | body | string | 예 | — | 결제수단. 코어 8종(`card`/`vbank`/`dbank`/`bank`/`phone`/`point`/`deposit`/`free`) 과 PG 플러그인이 등록한 확장 결제수단 ID(예: `nhnkcp_naverpay`, `kginicis_lpay`)를 모두 허용한다. 확장 결제수단도 1급 시민으로 그대로 저장된다. 카탈로그에 없는 값은 422 | | expected_total_amount | body | number | 예 | min 0 | 프론트가 계산한 예상 결제금액 (서버 재계산값과 대조해 금액 위변조 검증) | | shipping_memo | body | string | 아니오 | max 500 | 배송 요청사항 메모 | | depositor_name | body | string | 아니오 | max 50 | depositor 이름 (식별자) | -| dbank.bank_code | body | string | 아니오 | max 10 | | -| dbank.bank_name | body | string | 아니오 | max 50 | dbank.bank 이름 (식별자) | -| dbank.account_number | body | string | 아니오 | max 50 | | -| dbank.account_holder | body | string | 아니오 | max 50 | | -| dbank.due_days | body | integer | 아니오 | min 1, max 30 | | +| dbank.bank_code | body | string | 아니오 | max 10 | 수동 무통장입금 계좌의 은행코드 (`payment_method=dbank` 이면 필수) | +| dbank.bank_name | body | string | 아니오 | max 50 | 수동 무통장입금 계좌의 은행명 (표시용) | +| dbank.account_number | body | string | 아니오 | max 50 | 수동 무통장입금 입금 계좌번호 (`payment_method=dbank` 이면 필수) | +| dbank.account_holder | body | string | 아니오 | max 50 | 수동 무통장입금 계좌 예금주 (`payment_method=dbank` 이면 필수) | +| dbank.due_days | body | integer | 아니오 | min 1, max 30 | 입금 기한 일수 (주문일로부터 며칠 이내 입금, 1~30일) | | save_shipping_address | body | boolean | 아니오 | — | 회원 주소록에 이번 배송지 저장 여부 (회원 주문 한정) | | cash_receipt_requested | body | boolean | 아니오 | — | 현금영수증 신청 여부 (true 면 아래 3개 필드가 필수) | | cash_receipt_type | body | string | 아니오 | — | 발급 용도 (`income` 소득공제 / `expense` 지출증빙) | @@ -1675,7 +2012,7 @@ HTTP/1.1 200 | refund_bank.bank_code | body | string | 아니오 | max 10 | 환불 계좌 은행코드 (세 필드는 전부 입력하거나 전부 비워야 함) | | refund_bank.account_number | body | string | 아니오 | max 50 | 환불 계좌번호 | | refund_bank.holder | body | string | 아니오 | max 50 | 환불 계좌 예금주 | -| orderer.email | body | email | 예 | max 255 | | +| orderer.email | body | email | 예 | max 255 | 이메일 주소 | | guest_lookup_password | body | string | 예 | min 8, max 255 | 비회원 주문 조회 비밀번호 (비회원만 필수, 8자 이상 · 해시로 저장) | | guest_lookup_password_confirmation | body | string | 예 | — | 조회 비밀번호 확인 (guest_lookup_password 와 일치해야 함) | @@ -1731,18 +2068,87 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (HTTP 201). 회원은 `order` 에 `OrderResource`, 비회원은 민감 필드를 가린 `GuestOrderResource` 가 담깁니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order | object | `{"id":181,"order_number":"20260711-0210001234", …}` | 생성된 주문 (회원: `OrderResource` / 비회원: `GuestOrderResource`) | +| redirect_url | string | `/shop/orders/20260711-0210001234/complete` | 주문완료 페이지 경로 (프론트가 이동할 URL) | +| requires_pg_payment | boolean | `true` | PG 결제창 호출이 필요한지 여부 (무통장·전액 마일리지 등 non-PG 는 `false`) | +| pg_provider | string | `sirsoft-tosspayments` | PG 플러그인 식별자 (`requires_pg_payment=true` 일 때만 포함) | +| pg_payment_handler | string | `sirsoft-tosspayments.requestPayment` | 프론트가 dispatch 할 결제 진입 핸들러 풀네임 (provider 가 선언한 경우에만 포함) | +| pg_payment_data | object | `{"order_number":"…","amount":184000,"currency":"KRW", …}` | PG SDK 결제창 호출 파라미터 (`requires_pg_payment=true` 일 때만 포함 — 아래 표 참조) | + +`pg_payment_data` 하위 필드 (프로바이더 비의존 공통): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_number | string | `20260711-0210001234` | 주문번호 (PG 주문 식별자) | +| order_name | string | `코튼 후드티 #12 외 2건` | 결제창에 표시할 주문명 (첫 상품명 + 외 N건) | +| amount | integer | `184000` | PG 청구금액 — 결제 통화의 최소 화폐단위 정수 (KRW 원 / USD 센트) | +| currency | string | `KRW` | PG 청구 통화 (주문 스냅샷 환율로 환산된 결제 통화) | +| customer_name | string | `유정우` | 주문자명 (배송지에서 조회) | +| customer_email | string | `ji792@mail.test` | 주문자 이메일 | +| customer_phone | string | `01055144949` | 주문자 휴대전화 (숫자만) | +| customer_key | string \| null | `user_12` | 회원 식별 키 (비회원은 `null`) | +| escrow_products | array | `[{"id":12,"name":"코튼 후드티 #12","code":"YD1JTVLJEMKAUTKS","unitPrice":31000,"quantity":1}]` | 에스크로 결제(가상계좌·계좌이체)용 상품 상세 (`unitPrice` 는 개당가, 비에스크로는 무시) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "주문이 완료되었습니다.", + "data": { + "order": { + "id": 181, + "order_number": "20260711-0210001234", + "order_status": "pending_payment", + "total_amount": 184000, + "total_due_amount": 184000 + }, + "redirect_url": "/shop/orders/20260711-0210001234/complete", + "requires_pg_payment": true, + "pg_provider": "sirsoft-tosspayments", + "pg_payment_handler": "sirsoft-tosspayments.requestPayment", + "pg_payment_data": { + "order_number": "20260711-0210001234", + "order_name": "코튼 후드티 #12 외 2건", + "amount": 184000, + "currency": "KRW", + "customer_name": "유정우", + "customer_email": "ji792@mail.test", + "customer_phone": "01055144949", + "customer_key": "user_12", + "escrow_products": [ + { + "id": 12, + "name": "코튼 후드티 #12", + "code": "YD1JTVLJEMKAUTKS", + "unitPrice": 31000, + "quantity": 1 + } + ] + } + } +} +``` + +> `order` 는 주문 상세와 동일한 `OrderResource`(회원) / `GuestOrderResource`(비회원) 전체 구조입니다 (위 예시는 주요 필드만 발췌). **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `payment_method` 가 결제수단 카탈로그에 없는 값이면 여기서 차단된다 | +| 404 | Not Found | 임시 주문(주문서)이 없거나 만료된 경우 (`주문서를 찾을 수 없습니다.` 계열 — `exceptions.temp_order_not_found`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 예상 결제금액 불일치(`expected_total_amount` ≠ 서버 재계산값), 결제 통화 미지원(`errors.code = unsupported_payment_currency`), 재고 부족(`errors.insufficient_items`), 구매 불가 상품(`errors.code = cart_unavailable`), 주문 확정 재계산 검증 실패(쿠폰 만료·최소주문금액 미달 등 — `errors.code = order_calculation_validation_failed`). `payment_method` 가 결제수단 카탈로그에 없는 값이면 여기서 차단된다 | +| 428 | Identity Verification Required | 결제 진입 본인인증(IDV) 정책이 활성이고 미인증(grace 만료)인 경우 | +| 500 | Server Error | 주문 생성 중 예기치 못한 오류 (`주문 생성에 실패했습니다.`) | @@ -1893,14 +2299,42 @@ _단건 응답: `data` 객체의 필드._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문 정보를 조회했습니다.", + "data": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "payment_complete", + "order_status_label": "결제완료", + "total_amount": 184000, + "total_amount_formatted": "184,000원", + "options": [], + "shipping_address": {}, + "payments": [], + "shippings": [], + "cancels": [], + "abilities": { + "can_read": true, + "can_cancel": true + } + } +} +``` + +> `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 주요 필드만 발췌). **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 주문이 없거나 본인 주문이 아닌 경우 (정보 노출 방지를 위해 권한 없음도 404 — `주문을 찾을 수 없습니다.`) | @@ -1944,11 +2378,53 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 취소 처리 후 재조회한 주문을 `OrderResource` 로 반환하므로 필드 구성은 주문 상세와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| order_number | string | `20260706-1405449337` | 주문번호 | +| order_status | string | `cancelled` | 취소 반영된 주문상태 (전체취소 시 `cancelled`) | +| is_partially_cancelled | boolean | `false` | 부분취소 여부 (`items` 지정 시 `true`) | +| total_cancelled_amount | integer | `184000` | 총 취소금액 | +| total_refunded_amount | integer | `184000` | 총 환불금액 | +| cancelled_at | string | `2026-07-11T02:10:00+00:00` | 취소 일시 | +| cancels | array | `[{"reason":"change_of_mind", …}]` | 취소 이력 (OrderCancelResource) | +| options | array | `[{"option_status":"cancelled", …}]` | 주문 옵션 목록 (취소된 옵션 반영) | +| abilities | object | `{"can_read":true,"can_cancel":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 나머지 금액/일시/다중통화 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문이 취소되었습니다.", + "data": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "cancelled", + "order_status_label": "취소완료", + "is_partially_cancelled": false, + "total_cancelled_amount": 184000, + "total_refunded_amount": 184000, + "cancelled_at": "2026-07-11T02:10:00+00:00", + "cancels": [], + "options": [], + "abilities": { + "can_read": true, + "can_cancel": false + } + } +} +``` + +> `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 취소 관련 필드만 발췌). **에러 응답** @@ -1957,7 +2433,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.cancel`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패(본인 주문 아님·취소 불가 상태 포함), 또는 취소 처리 실패 (`exceptions.order_cancel_failed`) | @@ -2175,11 +2651,78 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AdjustmentResult::toPreviewArray()` 반환 배열 — 관리자 `POST /admin/orders/{order}/estimate-refund` 와 동일 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| refund_amount | number | `31000` | PG 환불 예상금액 (음수면 추가결제 필요) | +| refund_points_amount | number | `0` | 마일리지(포인트) 환불 예상금액 | +| original_paid_amount | number | `184000` | 재계산 전 원 결제금액 | +| recalculated_paid_amount | number | `153000` | 취소 반영 후 재계산된 결제금액 | +| shipping_difference | number | `0` | 배송비 차이 (양수 환불 / 음수 추가결제) | +| discount_difference | number | `0` | 할인 차이 (양수: 할인 감소분) | +| additional_payment_amount | number | `0` | 추가결제 필요 금액 (없으면 0) | +| cancelled_items | array | `[{"order_option_id":1,"cancel_quantity":1,"cancel_amount":31000}]` | 취소 대상 아이템별 취소 수량·금액 | +| refund_priority | string | `pg_first` | 적용된 환불 배분 우선순위 | +| remaining_pg_balance | number | `153000` | 환불 후 잔여 PG 결제 잔액 | +| remaining_points_balance | number | `0` | 환불 후 잔여 포인트 잔액 | +| refund_total | number | `31000` | 총 환불 예상금액 (PG + 포인트) | +| refund_formatted | object | `{"refund_total":"31,000원", …}` | 환불 금액의 통화 포맷 문자열 (취소 모달 표기) | +| restored_coupons | array | `[]` | 취소로 복원되는 쿠폰 정보 | +| shipping_details | array | `[]` | 배송정책별 배송비 차액 상세 | +| mc_refund_amount / mc_refund_points_amount / mc_refund_shipping_amount | object \| null | `{"KRW":{"amount":31000,"formatted":"31,000원"}}` | 환불 금액 다중 통화 | +| original_snapshot / recalculated_snapshot | object | `{"total_paid_amount":184000, …}` | 재계산 전/후 금액 스냅샷 | +| mc_original_snapshot / mc_recalculated_snapshot | object \| null | `{"mc_total_paid_amount":{…}}` | 다중 통화 스냅샷 | +| original_coupons / recalculated_coupons | array | `[]` | 재계산 전/후 쿠폰 적용 상세 | +| cancel_blocked | boolean | `false` | 취소 차단 여부 (부분취소로 추가결제가 필요해지는 실결제 주문이면 `true`) | +| cancel_blocked_reason | string \| null | `null` | 차단 사유 문구 (차단이 아니면 `null`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "환불 예상금액을 조회했습니다.", + "data": { + "refund_amount": 31000, + "refund_points_amount": 0, + "original_paid_amount": 184000, + "recalculated_paid_amount": 153000, + "shipping_difference": 0, + "discount_difference": 0, + "additional_payment_amount": 0, + "cancelled_items": [ + { + "order_option_id": 1, + "cancel_quantity": 1, + "cancel_amount": 31000 + } + ], + "refund_priority": "pg_first", + "remaining_pg_balance": 153000, + "remaining_points_balance": 0, + "refund_total": 31000, + "refund_formatted": {}, + "restored_coupons": [], + "shipping_details": [], + "mc_refund_amount": null, + "mc_refund_points_amount": null, + "mc_refund_shipping_amount": null, + "original_snapshot": {}, + "recalculated_snapshot": {}, + "mc_original_snapshot": null, + "mc_recalculated_snapshot": null, + "original_coupons": [], + "recalculated_coupons": [], + "cancel_blocked": false, + "cancel_blocked_reason": null + } +} +``` **에러 응답** @@ -2188,7 +2731,8 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.cancel`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (본인 주문 아님·취소 불가 옵션 포함) | +| 500 | Server Error | 환불 예상금액 계산 중 오류 (`exceptions.order_estimate_refund_failed`) | @@ -2219,11 +2763,44 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order | object | `{"id":1,"order_status":"confirmed","options":[…], …}` | 구매확정 반영 후 재조회한 주문 (`OrderResource` — 필드 구성은 주문 상세와 동일). 확정된 옵션의 `option_status` 가 `confirmed` 로 전이하며, 전 옵션 확정 시 주문상태도 `confirmed` 가 됩니다 | + +> `order` 의 세부 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "구매확정이 완료되었습니다.", + "data": { + "order": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "confirmed", + "order_status_label": "구매확정", + "confirmed_at": "2026-07-11T02:10:00+00:00", + "options": [ + { + "id": 1, + "option_status": "confirmed", + "option_status_label": "구매확정" + } + ] + } + } +} +``` + +> `order` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 구매확정 관련 필드만 발췌). **에러 응답** @@ -2232,6 +2809,7 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.confirm`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 구매확정 불가 — 본인 주문이 아니거나 배송완료 전 등 확정 불가 상태 (`exceptions.order_option_cannot_confirm`) | @@ -2261,11 +2839,38 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CartService::reorderFromOrder()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| added_count | integer | `2` | 장바구니에 실제로 담긴 옵션 건수 | +| skipped | array | `[{"product_name":"코튼 후드티 #12","reason":"재고가 부족합니다."}]` | 품절·단종 등으로 담지 못한 항목 목록 (`product_name` + `reason`) | +| skipped[].product_name | string | `코튼 후드티 #12` | 담지 못한 상품명 (현재 로케일로 로컬라이즈) | +| skipped[].reason | string | `재고가 부족합니다.` | 담지 못한 사유 (재고 부족·옵션 미존재 등) | +| cart_count | integer | `5` | 재주문 반영 후 현재 장바구니의 총 아이템 수 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "과거 주문의 상품을 장바구니에 추가했습니다.", + "data": { + "added_count": 2, + "skipped": [ + { + "product_name": "코튼 후드티 #12", + "reason": "재고가 부족합니다." + } + ], + "cart_count": 5 + } +} +``` **에러 응답** @@ -2273,6 +2878,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 재주문 실패 — 주문이 없거나 본인 주문이 아닌 경우 (`재주문에 실패했습니다.`) | @@ -2334,19 +2940,49 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order | object | `{"id":1,"shipping_address":{…},"recipient_name":"심채원", …}` | 배송지 변경 반영 후 주문 (`OrderResource` — 필드 구성은 주문 상세와 동일). `shipping_address` 및 플래튼된 수령인/주소 필드에 변경 내용이 반영됩니다 | + +> `order` 의 세부 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송지가 변경되었습니다.", + "data": { + "order": { + "id": 1, + "order_number": "20260706-1405449337", + "recipient_name": "심채원", + "recipient_phone": "010-3955-6018", + "recipient_zipcode": "38022", + "recipient_address": "부산광역시 양천구 공항대로 9", + "recipient_detail_address": "101동 202호", + "delivery_memo": "parcel_box", + "shipping_address": {} + } + } +} +``` + +> `order` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 배송지 관련 필드만 발췌). **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 주문이 없거나 본인 주문이 아닌 경우 (`주문을 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 배송지 변경 불가 상태 — 이미 배송이 시작된 주문 등 (`배송 전 상태에서만 배송지를 변경할 수 있습니다.`) | @@ -2487,15 +3123,105 @@ _단건 응답: `data` 객체의 필드._ | is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | | abilities | object | `{"can_read":true,"can_update":true,"can_cancel":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | -**응답 예시** +비회원 응답(`GuestOrderResource`) 필드 — 회원 응답에 있는 `id`·`user`·`admin_memo`·`promotions_applied_snapshot` 등 민감/내부 필드는 포함되지 않습니다: - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_number | string | `20260706-1405449337` | 주문번호 (비회원 식별 기준 — 내부 `id` 는 미노출) | +| order_status | string | `payment_complete` | 주문상태 (OrderStatusEnum) | +| order_status_label | string | `결제완료` | `order_status` 의 사람이 읽는 라벨 | +| order_status_variant | string | `info` | `order_status` 의 표시 변형 키 (UI 배지 색상) | +| is_partially_cancelled | boolean | `false` | 부분취소 여부 (options 로드 시) | +| subtotal_amount / subtotal_amount_formatted | integer / string | `184000` / `184,000원` | 상품 합계 (할인 전) | +| total_discount_amount / total_discount_amount_formatted | integer / string | `0` / `0원` | 총 할인금액 | +| total_shipping_amount / total_shipping_amount_formatted | integer / string | `0` / `0원` | 총 배송비 | +| total_amount / total_amount_formatted | integer / string | `184000` / `184,000원` | 최종 주문금액 | +| total_paid_amount / total_paid_amount_formatted | integer / string | `184000` / `184,000원` | 총 실제 결제금액 | +| total_cancelled_amount / total_cancelled_amount_formatted | integer / string | `0` / `0원` | 총 취소금액 | +| total_refunded_amount / total_refunded_amount_formatted | integer / string | `0` / `0원` | 총 환불금액 | +| total_refunded_points_amount / _formatted | integer / string | `0` / `0원` | 총 환불 포인트 | +| total_points_used_amount / _formatted | integer / string | `0` / `0원` | 총 포인트 사용액 | +| total_deposit_used_amount / _formatted | integer / string | `0` / `0원` | 총 예치금 사용액 | +| total_earned_points_amount / _formatted | integer / string | `1840` / `1,840원` | 총 적립 예정 포인트 | +| mc_subtotal_amount / mc_total_discount_amount / mc_total_shipping_amount / mc_total_amount / mc_total_points_used_amount / mc_total_deposit_used_amount | object | `{"KRW":{"amount":184000,"formatted":"184,000원"}, …}` | 각 금액의 다중 통화 표기 | +| item_count | integer | `3` | 주문 품목 수 | +| total_quantity | integer | `4` | 주문 옵션 수량 합계 (options 로드 시) | +| ordered_at / ordered_at_formatted | string | `2026-07-05T14:05:44+00:00` / `2026-07-05 23:05:44` | 주문 일시 | +| paid_at / paid_at_formatted | string \| null | `2026-07-06T14:05:44+00:00` / `2026-07-06 23:05:44` | 결제 일시 | +| confirmed_at / confirmed_at_formatted | string \| null | `null` | 구매확정 일시 | +| cancelled_at / cancelled_at_formatted | string \| null | `null` | 취소 일시 | +| orderer_name / orderer_phone / orderer_email | string | `유정우` / `010-5514-4949` / `ji792@mail.test` | 주문자 정보 (배송지에서 플래튼) | +| recipient_name / recipient_phone | string | `심채원` / `010-3955-6018` | 수령인 정보 | +| recipient_zipcode / recipient_address / recipient_detail_address | string | `38022` / `부산광역시 양천구 공항대로 9` / `101동 202호` | 수령인 주소 | +| delivery_memo / delivery_memo_label | string \| null | `parcel_box` / `택배함에 넣어주세요` | 배송 메모 및 라벨 | +| options | array | `[{"id":1,"option_status":"payment_complete", …}]` | 주문 옵션 목록 (OrderOptionResource) | +| shipping_address | object | `{"recipient_name":"심채원", …}` | 배송지 상세 (OrderAddressResource) | +| payment | object \| null | `{"payment_method":"dbank", …}` | 대표 결제 정보 (OrderPaymentResource) | +| shippings | array | `[]` | 배송 이력 (OrderShippingResource) | +| cancels | array | `[]` | 취소 이력 (OrderCancelResource) | +| abilities | object | `{"can_cancel":true}` | 비회원이 이 주문에 수행 가능한 작업 (취소 가능 상태 여부) | + +**응답 예시** (비회원 — `GuestOrderResource`) + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문 정보를 조회했습니다.", + "data": { + "order_number": "20260706-1405449337", + "order_status": "payment_complete", + "order_status_label": "결제완료", + "order_status_variant": "info", + "is_partially_cancelled": false, + "subtotal_amount": 184000, + "subtotal_amount_formatted": "184,000원", + "total_discount_amount": 0, + "total_discount_amount_formatted": "0원", + "total_shipping_amount": 0, + "total_shipping_amount_formatted": "0원", + "total_amount": 184000, + "total_amount_formatted": "184,000원", + "total_paid_amount": 184000, + "total_paid_amount_formatted": "184,000원", + "item_count": 3, + "total_quantity": 4, + "ordered_at": "2026-07-05T14:05:44+00:00", + "ordered_at_formatted": "2026-07-05 23:05:44", + "paid_at": "2026-07-06T14:05:44+00:00", + "paid_at_formatted": "2026-07-06 23:05:44", + "orderer_name": "유정우", + "orderer_phone": "010-5514-4949", + "orderer_email": "ji792@mail.test", + "recipient_name": "심채원", + "recipient_phone": "010-3955-6018", + "recipient_zipcode": "38022", + "recipient_address": "부산광역시 양천구 공항대로 9", + "recipient_detail_address": "101동 202호", + "delivery_memo": "parcel_box", + "delivery_memo_label": "택배함에 넣어주세요", + "options": [], + "shipping_address": {}, + "payment": null, + "shippings": [], + "cancels": [], + "abilities": { + "can_cancel": true + } + } +} +``` + +> 로그인 상태의 본인 주문이면 `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조로 내려갑니다 (`GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표 참조). **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 주문이 없거나 접근 권한이 없는 경우 — 회원: 본인 주문 아님(`errors.redirect_to = /mypage/orders`), 비회원: `X-Guest-Order-Token` 부재·만료·위조(`errors.redirect_to = /shop/guest/orders`). 정보 노출 방지를 위해 모든 실패를 동일 404 로 처리 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md b/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md index 43514e43..3ea7821a 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md @@ -39,17 +39,96 @@ Accept: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ `data` 는 Resource 가 아니라 `sirsoft-ecommerce.payment.get_client_config` **필터 훅의 반환 배열을 그대로** 담습니다. 따라서 키 구성은 설치된 PG 플러그인이 결정하며, 코어(모듈)는 어떤 키도 강제하지 않습니다. 아래는 번들 PG 플러그인 두 종의 실제 반환 키입니다. + +공통 (모든 PG 플러그인이 공통으로 내려주는 키) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| sdk_url | string | `https://js.tosspayments.com/v2/standard` | 프론트가 로드해야 할 PG 결제 SDK 스크립트 URL (테스트/운영 모드에 따라 달라질 수 있음) | +| callback_urls | object | `{"success":"/plugins/sirsoft-tosspayments/payment/success","fail":"/plugins/sirsoft-tosspayments/payment/fail"}` | 결제창 성공/실패/콜백 등에서 사용할 플러그인 콜백 경로 모음 (키 구성은 PG마다 다름) | + +`tosspayments` (플러그인 `sirsoft-tosspayments`) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| client_key | string | `test_ck_...` | 토스페이먼츠 SDK 초기화용 클라이언트 키 (테스트 모드면 test 키, 운영 모드면 live 키) | +| sdk_url | string | `https://js.tosspayments.com/v2/standard` | 토스페이먼츠 v2 표준 SDK URL | +| order_sheet_mode | boolean | `false` | 결제수단을 주문서(오더시트)에서 직접 선택하는 모드 여부. `false` 면 통합결제창 방식 | +| enabled_methods | array | `[]` | 오더시트 모드에서 활성화된 결제수단 목록. 결제창형(`order_sheet_mode: false`)이면 빈 배열 | +| enabled_methods[].id | string | `toss_card` | 플러그인 설정상의 결제수단 식별자 | +| enabled_methods[].method | string | `CARD` | 토스 SDK 에 전달할 결제수단 코드 | +| enabled_methods[].easy_pay_provider | string\|null | `null` | 간편결제일 때 토스 SDK 의 easyPay provider 값 (일반 결제수단이면 null) | +| enabled_methods[].core_payment_method | string | `card` | 이 결제수단에 대응하는 코어 결제수단 값 (프론트 하드코딩 제거용) | +| vbank | object | `{"valid_hours":24,"cash_receipt_type":""}` | 가상계좌 관련 설정 묶음 | +| vbank.valid_hours | integer | `24` | 가상계좌 입금 기한(시간) | +| vbank.cash_receipt_type | string | `""` | 가상계좌 현금영수증 발급 유형 (미설정이면 빈 문자열) | +| use_escrow | string | `off` | 에스크로 사용 설정값 (플러그인 설정 문자열 그대로) | +| callback_urls.success | string | `/plugins/sirsoft-tosspayments/payment/success` | 결제 성공 리다이렉트 경로 | +| callback_urls.fail | string | `/plugins/sirsoft-tosspayments/payment/fail` | 결제 실패 리다이렉트 경로 | + +`kginicis` (플러그인 `sirsoft-pay_kginicis`) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| mid | string | `INIpayTest` | 이니시스 상점 아이디(MID). 테스트/운영 및 에스크로 사용 여부에 따라 선택됨 | +| sdk_url | string | `https://stgstdpay.inicis.com/stdjs/INIStdPay.js` | 이니시스 표준결제 SDK URL (테스트: stgstdpay, 운영: stdpay) | +| callback_urls | object | — | 서명/종료보고/콜백/CBT/모바일 등 이니시스 연동 경로 모음 | +| callback_urls.signature | string | `/plugins/sirsoft-pay_kginicis/payment/signature` | PC 표준결제 서명 발급 경로 | +| callback_urls.close_report | string | `/plugins/sirsoft-pay_kginicis/payment/close-report` | 결제창 종료 보고 경로 | +| callback_urls.callback | string | `/plugins/sirsoft-pay_kginicis/payment/callback` | PC 표준결제 인증 결과 콜백 경로 | +| callback_urls.close | string | `/plugins/sirsoft-pay_kginicis/payment/close` | 결제창 닫기 처리 경로 | +| callback_urls.cbt_checkout_token | string | `/plugins/sirsoft-pay_kginicis/payment/cbt/checkout-token` | 해외결제(CBT) 체크아웃 토큰 발급 경로 | +| callback_urls.cbt_hash_data | string | `/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data` | CBT 해시 데이터 생성 경로 | +| callback_urls.cbt_callback | string | `/plugins/sirsoft-pay_kginicis/payment/cbt/callback` | CBT 결제 결과 콜백 경로 | +| callback_urls.cbt_cvs_notify | string | `/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify` | CBT 편의점 결제 입금 통보 경로 | +| callback_urls.cbt_auth_url | string | — | CBT 인증 요청 URL (테스트/운영 상수값) | +| callback_urls.mobile_signature | string | `/plugins/sirsoft-pay_kginicis/payment/mobile/signature` | 모바일 결제 서명 발급 경로 | +| callback_urls.mobile_callback | string | `/plugins/sirsoft-pay_kginicis/payment/mobile/callback` | 모바일 결제 결과 콜백 경로 | +| callback_urls.mobile_vbank_notify | string | `/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify` | 모바일 가상계좌 입금 통보 경로 | +| japan_enabled | boolean | `false` | 일본 결제(해외결제) 사용 여부 설정값 | +| japan_restrict_jpy_payment_methods | boolean | `false` | JPY 결제 시 결제수단을 일본 전용으로 제한할지 여부 | +| japan_configured | boolean | `false` | 일본 결제에 필요한 설정(MID/키 등)이 모두 채워졌는지 여부 | +| standard_configured | boolean | `true` | PC 표준결제 설정이 모두 채워졌는지 여부 | +| mobile_configured | boolean | `true` | 모바일 결제 설정이 모두 채워졌는지 여부 | +| use_escrow | boolean | `false` | 에스크로 사용 여부 설정값 | +| japan_mid | string | — | 일본 결제용 상점 아이디 (테스트면 고정 테스트 MID, 운영이면 설정된 live MID) | +| cbt_extra_data | object | — | CBT 결제 요청에 부가로 실어보낼 데이터 (플러그인 설정 기반으로 구성) | +| use_credit_point | boolean | `false` | 신용카드 포인트 결제 사용 여부 | +| easy_pay_show_brand_button | boolean | `false` | 간편결제 브랜드 버튼 노출 여부 | +| easy_pay_enabled_methods | array | `[]` | 활성화된 국내 간편결제 수단 목록 | **응답 예시** - +`tosspayments` (결제창형, 테스트 모드) 기준: + +```json +{ + "success": true, + "message": "결제 클라이언트 설정을 조회했습니다.", + "data": { + "client_key": "test_ck_xxxxxxxxxxxxxxxxxxxxxxxx", + "sdk_url": "https://js.tosspayments.com/v2/standard", + "order_sheet_mode": false, + "enabled_methods": [], + "vbank": { + "valid_hours": 24, + "cash_receipt_type": "" + }, + "use_escrow": "off", + "callback_urls": { + "success": "/plugins/sirsoft-tosspayments/payment/success", + "fail": "/plugins/sirsoft-tosspayments/payment/fail" + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해당 `provider` 에 대해 `sirsoft-ecommerce.payment.get_client_config` 필터가 빈 설정을 반환한 경우 (PG 플러그인 미설치·미활성·미지원 provider). 메시지: `결제 제공자를 찾을 수 없습니다.` | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md b/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md index e77a8ea7..862c31d8 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md @@ -42,9 +42,18 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) +_목록 응답: `data[]` 배열 항목의 필드 (페이지네이션 없음 — 현재 사용자·해당 화면의 프리셋 전체를 배열로 반환)._ - - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 프리셋 ID (기본 키) | +| target_screen | string | `products` | 대상 화면 (`products` / `orders` / `customers`) | +| name | string | `배송 대기 상품` | 프리셋 이름 (DB 컬럼 `preset_name`, 최대 100자) | +| conditions | object | `{"status": "active"}` | 저장된 검색 조건 JSON (필터 필드 → 값) | +| filters | object | `{"preset_id": 1, "filters[status]": "active"}` | 프론트엔드가 그대로 붙여 쓰는 쿼리 파라미터 형태 (`SearchPreset::toQueryParams()` 생성, 배열 값은 콤마 결합) | +| sort_order | integer | `0` | 정렬 순서 (작을수록 우선) | +| is_default | boolean | `false` | 기본 프리셋 여부 | +| created_at | string | `2026-07-14 10:20:30` | 생성 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) | **응답 예시** @@ -56,10 +65,28 @@ HTTP/1.1 200 { "success": true, "message": "검색 프리셋 목록을 조회했습니다.", - "data": [] + "data": [ + { + "id": 1, + "target_screen": "products", + "name": "배송 대기 상품", + "conditions": { + "status": "active" + }, + "filters": { + "preset_id": 1, + "filters[status]": "active" + }, + "sort_order": 0, + "is_default": false, + "created_at": "2026-07-14 10:20:30" + } + ] } ``` +> `message` 는 컨트롤러가 `sirsoft-ecommerce::messages.presets.fetch_success` 키를 넘기지만 모듈 lang 파일(`src/lang/ko/messages.php` 의 `presets` 배열)에 해당 키가 정의되어 있지 않아, 실측 응답에서 키 문자열이 그대로 노출됩니다. + **에러 응답** | 상태코드 | 의미 | 발생 조건 | @@ -108,18 +135,53 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 프리셋 1건)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 프리셋 ID | +| target_screen | string | `products` | 대상 화면 (`products` / `orders` / `customers`, 미지정 시 `products`) | +| name | string | `배송 대기 상품` | 프리셋 이름 (DB 컬럼 `preset_name`) | +| conditions | object | `{"status": "active"}` | 저장된 검색 조건 | +| filters | object | `{"preset_id": 1, "filters[status]": "active"}` | 프론트엔드용 쿼리 파라미터 형태 | +| sort_order | integer | `0` | 정렬 순서 (생성 시 기본값 `0`) | +| is_default | boolean | `false` | 기본 프리셋 여부 (생성 시 기본값 `false`) | +| created_at | string | `2026-07-14 10:20:30` | 생성 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "검색 프리셋이 저장되었습니다.", + "data": { + "id": 1, + "target_screen": "products", + "name": "배송 대기 상품", + "conditions": { + "status": "active" + }, + "filters": { + "preset_id": 1, + "filters[status]": "active" + }, + "sort_order": 0, + "is_default": false, + "created_at": "2026-07-14 10:20:30" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 동일 사용자·동일 화면에 같은 이름의 프리셋이 이미 있으면 `name` 에 "동일한 이름의 프리셋이 이미 존재합니다." | @@ -149,11 +211,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (삭제 결과 플래그만 반환하며, 삭제된 프리셋 본문은 반환하지 않습니다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 삭제 완료 여부 (컨트롤러가 `['deleted' => true]` 를 고정 반환) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "검색 프리셋이 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** @@ -161,6 +239,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 500 | UnauthorizedPresetAccessException | 다른 사용자 소유의 프리셋을 삭제하려는 경우 (`SearchPresetService::delete()` 의 소유권 검사 — "프리셋(ID: :preset_id)에 대한 접근 권한이 없습니다."). 이 예외는 전용 status 나 `render()` 를 갖지 않아 기본 예외 처리로 500 이 됩니다 (테스트 하네스 `ModuleTestCase` 에서만 403 으로 매핑) | @@ -204,11 +283,46 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 프리셋 1건)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 프리셋 ID | +| target_screen | string | `products` | 대상 화면 (수정 대상이 아님 — 생성 시 값 유지) | +| name | string | `배송 대기 상품 (수정)` | 수정된 프리셋 이름 (DB 컬럼 `preset_name`) | +| conditions | object | `{"status": "inactive"}` | 수정된 검색 조건 | +| filters | object | `{"preset_id": 1, "filters[status]": "inactive"}` | 프론트엔드용 쿼리 파라미터 형태 | +| sort_order | integer | `1` | 정렬 순서 (작을수록 우선) | +| is_default | boolean | `false` | 기본 프리셋 여부 | +| created_at | string | `2026-07-14 10:20:30` | 생성 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "검색 프리셋이 수정되었습니다.", + "data": { + "id": 1, + "target_screen": "products", + "name": "배송 대기 상품 (수정)", + "conditions": { + "status": "inactive" + }, + "filters": { + "preset_id": 1, + "filters[status]": "inactive" + }, + "sort_order": 1, + "is_default": false, + "created_at": "2026-07-14 10:20:30" + } +} +``` **에러 응답** @@ -216,7 +330,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 동일 사용자·동일 화면에 같은 이름의 다른 프리셋이 있으면 `name` 에 "동일한 이름의 프리셋이 이미 존재합니다." | +| 500 | UnauthorizedPresetAccessException | 다른 사용자 소유의 프리셋을 수정하려는 경우 (`SearchPresetService::update()` 의 소유권 검사). 전용 status/`render()` 가 없어 기본 예외 처리로 500 (테스트 하네스에서만 403 매핑) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md index e6c68fc3..620d67d5 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md @@ -206,11 +206,62 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductCommonInfoResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 공통정보의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 공통정보","en":"API Doc Sample Common Info"}` | 공통정보 이름 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 공통정보` | `name` 의 현재 로케일 해석 값 | +| content | object\|array | `[]` | 안내 본문 내용 (로케일별 값 객체, 미입력 시 빈 배열) | +| localized_content | string | `` | `content` 의 현재 로케일 해석 값 | +| content_mode | string | `text` | 내용 표시 모드 (`text` 일반 텍스트 / `html` HTML, 기본값 `text`) | +| is_default | boolean | `false` | 신규 상품에 기본으로 사용할 공통정보인지 여부 | +| is_active | boolean | `true` | 활성 여부 (false 이면 상품에 노출되지 않음) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `info-circle` | 아이콘 식별자 (목록 UI 표시용 고정값) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| products_count | integer | `0` | 이 공통정보를 사용 중인 상품 수 (생성 직후 0) | +| language_count | integer | `0` | 값이 입력된 로케일 개수 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (권한 맵 기반) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "공통정보가 생성되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 공통정보", + "en": "API Doc Sample Common Info" + }, + "localized_name": "API 문서 샘플 공통정보", + "content": [], + "localized_content": "", + "content_mode": "text", + "is_default": false, + "is_active": true, + "sort_order": 0, + "icon": "info-circle", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "products_count": 0, + "language_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -354,11 +405,64 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductCommonInfoResource` — 수정 반영 후 값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 공통정보의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 공통정보(수정)","en":"API Doc Sample Common Info (updated)"}` | 공통정보 이름 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 공통정보(수정)` | `name` 의 현재 로케일 해석 값 | +| content | object\|array | `{"ko":"배송은 결제 후 1~3일 이내 출고됩니다."}` | 안내 본문 내용 (로케일별 값 객체) | +| localized_content | string | `배송은 결제 후 1~3일 이내 출고됩니다.` | `content` 의 현재 로케일 해석 값 | +| content_mode | string | `text` | 내용 표시 모드 (`text` 일반 텍스트 / `html` HTML, 기본값 `text`) | +| is_default | boolean | `false` | 신규 상품에 기본으로 사용할 공통정보인지 여부 | +| is_active | boolean | `true` | 활성 여부 (false 이면 상품에 노출되지 않음) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `info-circle` | 아이콘 식별자 (목록 UI 표시용 고정값) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:26` | 최종 수정 일시 (이번 수정 시각) | +| products_count | integer | `0` | 이 공통정보를 사용 중인 상품 수 | +| language_count | integer | `1` | 값이 입력된 로케일 개수 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (권한 맵 기반) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "공통정보가 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 공통정보(수정)", + "en": "API Doc Sample Common Info (updated)" + }, + "localized_name": "API 문서 샘플 공통정보(수정)", + "content": { + "ko": "배송은 결제 후 1~3일 이내 출고됩니다." + }, + "localized_content": "배송은 결제 후 1~3일 이내 출고됩니다.", + "content_mode": "text", + "is_default": false, + "is_active": true, + "sort_order": 0, + "icon": "info-circle", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:26", + "products_count": 0, + "language_count": 1, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md index d9aabb33..ae536e4c 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md @@ -27,7 +27,7 @@ | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | +| hash | path | string | 예 | — | 서빙할 상품 이미지의 URL용 고유 해시 (`ecommerce_product_images.hash`) | **요청 예시** @@ -39,17 +39,41 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투(`data`)를 반환하지 않습니다. 성공 시 이미지 원본 파일의 바이너리 스트림(`StreamedResponse`)을 직접 반환합니다._ + +| 응답 헤더 | 값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `image/jpeg` (레코드의 `mime_type` 값 그대로 — `image/png` \| `image/webp` \| `image/gif` 등) | 저장된 이미지의 MIME 타입 | +| Content-Disposition | `attachment; filename="product-main.jpg"` (레코드의 `original_filename`) | 업로드 당시의 원본 파일명 | +| Cache-Control | `public, max-age=31536000` | 브라우저/CDN 1년 캐싱 (해시 기반 URL이므로 내용 변경 시 URL 자체가 바뀜) | **응답 예시** - +성공 시 JSON 이 아닌 이미지 바이너리가 스트리밍됩니다. + +```http +HTTP/1.1 200 OK +Content-Type: image/jpeg +Content-Disposition: attachment; filename="product-main.jpg" +Cache-Control: public, max-age=31536000 + + +``` + +실패(404) 시에만 JSON 봉투가 반환됩니다. + +```json +{ + "success": false, + "message": "상품 이미지를 찾을 수 없습니다." +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 레코드는 있으나 스토리지에 실제 파일이 없어 스트림 생성에 실패한 경우 (`ProductImageService::download()` null — 서버 로그에 `상품 이미지 스토리지에 없음` 기록) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md index 0a8e0046..efb8732b 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md @@ -166,11 +166,50 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductLabelResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"신상품","en":"New"}` | 라벨명 (다국어 필드 — 로케일별 값 객체) | +| color | string | `#4F46E5` | 라벨 색상 코드 (`#RRGGBB` 6자리 HEX) | +| is_active | boolean | `true` | 활성 여부 (미지정 시 모델 기본값) | +| sort_order | integer | `1` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| assignments_count | integer | `0` | 이 라벨이 부여된 상품 수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (권한 맵 기반) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "라벨이 등록되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "신상품", + "en": "New" + }, + "color": "#4F46E5", + "is_active": true, + "sort_order": 1, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "assignments_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -306,11 +345,50 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductLabelResource` — 수정 후 라벨)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"베스트","en":"Best"}` | 라벨명 (다국어 필드 — 로케일별 값 객체) | +| color | string | `#4F46E5` | 라벨 색상 코드 (`#RRGGBB` 6자리 HEX) | +| is_active | boolean | `true` | 활성 여부 | +| sort_order | integer | `1` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 15:00:27` | 최종 수정 일시 (수정 반영 후 값) | +| assignments_count | integer | `0` | 이 라벨이 부여된 상품 수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (권한 맵 기반) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "라벨이 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "베스트", + "en": "Best" + }, + "color": "#4F46E5", + "is_active": true, + "sort_order": 1, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:27", + "assignments_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md index 5f8d43c9..9b81ea14 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md @@ -272,16 +272,67 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductNoticeTemplateResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `3` | 생성된 템플릿의 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류 고시","en":"Clothing Notice"}` | 템플릿명 (로케일별 값 객체) | +| localized_name | string | `의류 고시` | `name` 의 현재 로케일 해석 값 | +| category | string\|null | `clothing` | 이 템플릿이 적용되는 품목 카테고리 식별자 (미지정 시 `null`) | +| fields | array | `[{"label":"품명","value":"샘플"}]` | 고시 항목 정의 배열 (요청의 `fields` 가 그대로 저장됨) | +| fields_count | integer | `1` | `fields` 배열의 개수 (집계값) | +| is_active | boolean | `true` | 활성 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `file-alt` | 목록 UI(SortableMenuList)용 고정 아이콘 식별자 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "상품정보제공고시가 생성되었습니다.", + "data": { + "id": 3, + "name": { + "ko": "의류 고시", + "en": "Clothing Notice" + }, + "localized_name": "의류 고시", + "category": "clothing", + "fields": [ + { + "label": "품명", + "value": "샘플" + } + ], + "fields_count": 1, + "is_active": true, + "sort_order": 0, + "icon": "file-alt", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 생성 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.create`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -418,16 +469,67 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductNoticeTemplateResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 템플릿의 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류 고시(수정)","en":"Clothing Notice (Updated)"}` | 템플릿명 (로케일별 값 객체) | +| localized_name | string | `의류 고시(수정)` | `name` 의 현재 로케일 해석 값 | +| category | string\|null | `clothing` | 이 템플릿이 적용되는 품목 카테고리 식별자 (미지정 시 `null`) | +| fields | array | `[{"label":"품명","value":"샘플"}]` | 고시 항목 정의 배열 (요청의 `fields` 로 대체됨) | +| fields_count | integer | `1` | `fields` 배열의 개수 (집계값) | +| is_active | boolean | `true` | 활성 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `file-alt` | 목록 UI(SortableMenuList)용 고정 아이콘 식별자 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 15:00:27` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품정보제공고시가 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "의류 고시(수정)", + "en": "Clothing Notice (Updated)" + }, + "localized_name": "의류 고시(수정)", + "category": "clothing", + "fields": [ + { + "label": "품명", + "value": "샘플" + } + ], + "fields_count": 1, + "is_active": true, + "sort_order": 0, + "icon": "file-alt", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:27", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 수정 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md index c2ea3655..b873fb2e 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md @@ -352,11 +352,129 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductResource`). 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 상품의 기본 키 (내부 식별자) | +| name | object | `{"ko":"샘플 상품","en":"Sample Product"}` | 상품명 (로케일별 값 객체) | +| name_localized | string | `샘플 상품` | `name` 의 현재 로케일 해석 값 | +| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | +| categories | array | `[]` | 소속 카테고리 목록 (categories 관계 로드 시) | +| category_ids | array | `[1]` | 소속 카테고리 ID 배열 (categories 관계 로드 시) | +| primary_category_id | integer | `1` | 대표 카테고리 ID (is_primary 카테고리) | +| brand_id | integer | `null` | 브랜드 식별자 | +| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화) | +| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화) | +| discount_rate | integer | `21` | 할인율(%) ((1 - 판매가/정가) × 100) | +| stock_quantity | integer | `22` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | +| safe_stock_quantity | integer | `12` | 안전재고 수량 | +| is_below_safe_stock | boolean | `false` | 재고가 안전재고 미만인지 여부 | +| is_stock_consistent | boolean | `true` | 상품 재고와 옵션 재고 합계의 일치 여부 | +| sales_status | string | `on_sale` | 판매상태 (on_sale / suspended / sold_out / coming_soon) | +| sales_status_label | string | `판매중` | `sales_status` 의 현지화 라벨 | +| display_status | string | `visible` | 전시상태 (visible / hidden) | +| display_status_label | string | `전시` | `display_status` 의 현지화 라벨 | +| tax_status | string | `taxable` | 과세여부 (taxable / tax_free) | +| tax_status_label | string | `과세` | `tax_status` 의 현지화 라벨 | +| tax_rate | string | `10.00` | 세율(%) | +| shipping_policy_id | integer | `null` | 배송정책 식별자 | +| common_info_id | integer | `null` | 공통정보 식별자 | +| description | object | `{"ko":"상품 설명","en":"Description"}` | 상세 설명 (로케일별 값 객체) | +| description_localized | string | `상품 설명` | `description` 의 현재 로케일 해석 값 | +| description_mode | string | `text` | 상세 설명 편집 모드 (text / html) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| purchase_restriction | string | `none` | 구매 대상 제한 (none / restricted) | +| allowed_roles | array | `null` | 구매 허용 역할 ID 배열 | +| barcode | string | `null` | 바코드 | +| hs_code | string | `null` | HS 코드 (수출입 관세 분류 코드) | +| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) | +| thumbnail_hash | string | `null` | 대표 이미지 해시 | +| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL | +| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) | +| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) | +| meta_keywords | array | `null` | SEO 메타 키워드 배열 | +| seo_sync_title | boolean | `true` | SEO 제목 자동 동기화 여부 | +| seo_sync_description | boolean | `true` | SEO 설명 자동 동기화 여부 | +| has_options | boolean | `false` | 옵션 사용 여부 | +| option_groups | array | `[]` | 옵션 그룹 정의 (예: `[{"name":"색상","values":["빨강","파랑"]}]`) | +| options | array | `[]` | 생성된 옵션(SKU) 목록 (`ProductOptionResource`) | +| additional_options | array | `[]` | 추가옵션 그룹 목록 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 | + +> `categories` / `shipping_policy` / `label_assignments` / `notice_items` 는 해당 관계가 eager load 된 경우에만 포함됩니다 (`whenLoaded`). **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "상품이 등록되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "샘플 상품", + "en": "Sample Product" + }, + "name_localized": "샘플 상품", + "product_code": "PROD-GJUX-1484", + "sku": "SKU-MRAD-9306", + "list_price": 112594, + "selling_price": 88949, + "discount_rate": 21, + "stock_quantity": 22, + "safe_stock_quantity": 12, + "is_below_safe_stock": false, + "is_stock_consistent": true, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "display_status": "visible", + "display_status_label": "전시", + "tax_status": "taxable", + "tax_status_label": "과세", + "tax_rate": "10.00", + "shipping_policy_id": null, + "common_info_id": null, + "description": { + "ko": "상품 설명", + "en": "Description" + }, + "description_localized": "상품 설명", + "description_mode": "text", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "purchase_restriction": "none", + "allowed_roles": null, + "barcode": null, + "hs_code": null, + "thumbnail_hash": null, + "thumbnail_url": null, + "meta_title": null, + "meta_description": null, + "meta_keywords": null, + "seo_sync_title": true, + "seo_sync_description": true, + "has_options": false, + "option_groups": [], + "options": [], + "additional_options": [], + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -409,11 +527,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductService::bulkUpdatePrice()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 가격이 변경된 상품 건수 | +| requested_count | integer | `3` | 요청한 `ids` 의 개수 (대상으로 지정한 건수) | + +> 응답 `message` 의 `:count` 는 `updated_count` 로 치환됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "3개 상품의 가격이 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_count": 3 + } +} +``` **에러 응답** @@ -464,11 +602,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductService::bulkUpdateStatus()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 상태가 변경된 상품 건수 | +| requested_count | integer | `3` | 요청한 `ids` 의 개수 (대상으로 지정한 건수) | + +> 응답 `message` 의 `:count` 는 `updated_count` 로 치환됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "3개 상품이 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_count": 3 + } +} +``` **에러 응답** @@ -519,11 +677,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductService::bulkUpdateStock()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 재고가 변경된 상품 건수 | +| requested_count | integer | `3` | 요청한 `ids` 의 개수 (대상으로 지정한 건수) | + +> 응답 `message` 의 `:count` 는 `updated_count` 로 치환됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "3개 상품의 재고가 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_count": 3 + } +} +``` **에러 응답** @@ -550,16 +728,16 @@ Content-Type: application/json | --- | --- | --- | --- | --- | --- | | ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | | bulk_changes | body | array | 아니오 | — | 상품 조건 기반 일괄 변경값 (지정 시 ids 전체에 sales_status/display_status 일괄 적용) | -| bulk_changes.sales_status | body | string | 아니오 | — | | -| bulk_changes.display_status | body | string | 아니오 | — | | -| items | body | array | 아니오 | — | 처리 대상 항목 배열 | +| bulk_changes.sales_status | body | string | 아니오 | — | `ids` 전체에 일괄 적용할 판매상태 (지정 시 items 의 개별 sales_status 보다 우선) | +| bulk_changes.display_status | body | string | 아니오 | — | `ids` 전체에 일괄 적용할 전시상태 (지정 시 items 의 개별 display_status 보다 우선) | +| items | body | array | 아니오 | — | 처리 대상 항목 배열 (각 항목: id + 개별 수정할 상품 필드 — name/list_price/selling_price/sales_status/display_status. bulk_changes 로 지정된 필드는 제외됨) | | option_bulk_changes | body | array | 아니오 | — | 옵션 조건 기반 일괄 변경값 (price_adjustment/stock_quantity 를 method+value 로 일괄 조정) | -| option_bulk_changes.price_adjustment | body | array | 아니오 | — | | -| option_bulk_changes.price_adjustment.method | body | string | 아니오 | `set`, `add`, `percent` | | -| option_bulk_changes.price_adjustment.value | body | number | 아니오 | — | | -| option_bulk_changes.stock_quantity | body | array | 아니오 | — | | -| option_bulk_changes.stock_quantity.method | body | string | 아니오 | `set`, `add`, `subtract` | | -| option_bulk_changes.stock_quantity.value | body | integer | 아니오 | min 0 | | +| option_bulk_changes.price_adjustment | body | array | 아니오 | — | 대상 상품들의 모든 옵션에 적용할 옵션 추가금 일괄 변경 블록 (method + value 쌍) | +| option_bulk_changes.price_adjustment.method | body | string | 아니오 | `set`, `add`, `percent` | 옵션 추가금 변경 방식 (set 지정값으로 설정 / add 기존값에 가산 / percent 비율 적용) | +| option_bulk_changes.price_adjustment.value | body | number | 아니오 | — | 옵션 추가금 변경 값 (method 에 따라 설정값·가산값·비율로 해석) | +| option_bulk_changes.stock_quantity | body | array | 아니오 | — | 대상 상품들의 모든 옵션에 적용할 옵션 재고 일괄 변경 블록 (method + value 쌍) | +| option_bulk_changes.stock_quantity.method | body | string | 아니오 | `set`, `add`, `subtract` | 옵션 재고 변경 방식 (set 지정값으로 설정 / add 증가 / subtract 감소) | +| option_bulk_changes.stock_quantity.value | body | integer | 아니오 | min 0 | 옵션 재고 변경 수량 (method 에 따라 설정 수량·증감 수량으로 해석) | | option_items | body | array | 아니오 | — | 옵션 개별 인라인 수정 배열 (각 항목: product_id·option_id + 수정할 옵션 필드) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.bulk_update_validation_rules`). @@ -606,11 +784,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductService::bulkUpdate()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| products_updated | integer | `3` | 반영된 상품 건수 (bulk_changes 적용 건수 + items 개별 수정 건수) | +| options_updated | integer | `5` | 반영된 옵션 건수 (`ProductOptionService::bulkUpdate()` 결과) | + +> 응답 `message` 의 `:count` 는 `products_updated + options_updated` 합산값으로 치환됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "8개 상품이 수정되었습니다.", + "data": { + "products_updated": 3, + "options_updated": 5 + } +} +``` **에러 응답** @@ -802,11 +1000,125 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품 전체). 필드 구성은 `GET /admin/products/by-code/{code}` 의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 상품의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 상품","en":"API Doc Sample Product"}` | 상품명 (로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 상품` | `name` 의 현재 로케일 해석 값 | +| product_code | string | `APIDOCSAMPLE01` | 상품코드 | +| sku | string | `SKU-DKOK-1319` | 재고관리코드(SKU) | +| categories | array | `[]` | 소속 카테고리 목록 (categories 관계 로드 시) | +| category_ids | array | `[]` | 소속 카테고리 ID 배열 | +| primary_category_id | integer | `null` | 대표 카테고리 ID | +| brand_id | integer | `null` | 브랜드 식별자 | +| list_price | integer | `703155` | 정가 (기본통화 기준) | +| selling_price | integer | `597682` | 판매가 (기본통화 기준) | +| discount_rate | integer | `15` | 할인율(%) | +| stock_quantity | integer | `509` | 재고 수량 | +| safe_stock_quantity | integer | `38` | 안전재고 수량 | +| is_below_safe_stock | boolean | `false` | 재고가 안전재고 미만인지 여부 | +| is_stock_consistent | boolean | `true` | 상품 재고와 옵션 재고 합계 일치 여부 | +| sales_status | string | `on_sale` | 판매상태 (on_sale / suspended / sold_out / coming_soon) | +| sales_status_label | string | `판매중` | `sales_status` 의 현지화 라벨 | +| display_status | string | `visible` | 전시상태 (visible / hidden) | +| display_status_label | string | `전시` | `display_status` 의 현지화 라벨 | +| tax_status | string | `taxable` | 과세여부 (taxable / tax_free) | +| tax_status_label | string | `과세` | `tax_status` 의 현지화 라벨 | +| tax_rate | string | `10.00` | 세율(%) | +| shipping_policy_id | integer | `null` | 배송정책 식별자 | +| common_info_id | integer | `null` | 공통정보 식별자 | +| description | object | `{"ko":"상품 설명","en":"Description"}` | 상세 설명 (로케일별 값 객체) | +| description_localized | string | `상품 설명` | `description` 의 현재 로케일 해석 값 | +| description_mode | string | `text` | 상세 설명 편집 모드 (text / html) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| purchase_restriction | string | `none` | 구매 대상 제한 (none / restricted) | +| allowed_roles | array | `null` | 구매 허용 역할 ID 배열 | +| barcode | string | `null` | 바코드 | +| hs_code | string | `null` | HS 코드 | +| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) | +| thumbnail_hash | string | `null` | 대표 이미지 해시 | +| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL | +| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) | +| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) | +| meta_keywords | array | `null` | SEO 메타 키워드 배열 | +| seo_sync_title | boolean | `true` | SEO 제목 자동 동기화 여부 | +| seo_sync_description | boolean | `true` | SEO 설명 자동 동기화 여부 | +| has_options | boolean | `false` | 옵션 사용 여부 | +| option_groups | array | `[]` | 옵션 그룹 정의 | +| options | array | `[]` | 옵션(SKU) 목록 (`ProductOptionResource`) | +| additional_options | array | `[]` | 추가옵션 그룹 목록 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 11:02:13` | 최종 수정 일시 (이번 수정 시각으로 갱신) | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품이 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "name_localized": "API 문서 샘플 상품", + "product_code": "APIDOCSAMPLE01", + "sku": "SKU-DKOK-1319", + "category_ids": [], + "primary_category_id": null, + "brand_id": null, + "list_price": 703155, + "selling_price": 597682, + "discount_rate": 15, + "stock_quantity": 509, + "safe_stock_quantity": 38, + "is_below_safe_stock": false, + "is_stock_consistent": true, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "display_status": "visible", + "display_status_label": "전시", + "tax_status": "taxable", + "tax_status_label": "과세", + "tax_rate": "10.00", + "shipping_policy_id": null, + "common_info_id": null, + "description_mode": "text", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "purchase_restriction": "none", + "allowed_roles": null, + "barcode": null, + "hs_code": null, + "thumbnail_hash": null, + "thumbnail_url": null, + "meta_title": null, + "meta_description": null, + "meta_keywords": null, + "seo_sync_title": true, + "seo_sync_description": true, + "has_options": false, + "option_groups": [], + "options": [], + "additional_options": [], + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 11:02:13", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -843,11 +1155,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| product_code | string | `PROD-GJUX-1484` | `ProductService::generateUniqueCode()` 가 발급한, 기존 상품과 중복되지 않는 신규 상품코드 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품코드가 생성되었습니다.", + "data": { + "product_code": "PROD-GJUX-1484" + } +} +``` **에러 응답** @@ -875,8 +1203,8 @@ Authorization: Bearer {YOUR_TOKEN} | temp_key | body | string | 아니오 | max 64 | 임시 업로드 세션 키 (같은 상품의 여러 이미지를 한 세션으로 묶음, 생략 시 서버가 UUID 자동 발급) | | collection | body | string | 아니오 | — | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | | alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | -| alt_text.ko | body | string | 아니오 | max 255 | | -| alt_text.en | body | string | 아니오 | max 255 | | +| alt_text.ko | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `ko` 로케일 값 | +| alt_text.en | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `en` 로케일 값 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-image.filter_upload_validation_rules`). @@ -919,19 +1247,57 @@ Content-Disposition: form-data; name="alt_text.en" **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. FileUploader 컴포넌트 규약에 맞춰 실제 이미지 정보는 `data.data` 로 한 단계 더 감싸져 있습니다. 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| data | object | `{ ... }` | 업로드된 이미지 정보 객체 (아래 하위 필드) | +| data.id | integer | `1` | 업로드된 이미지 레코드의 기본 키 | +| data.hash | string | `3f7a1c9e…` | 이미지 해시 (상품 생성/수정 시 `thumbnail_hash` 지정에 사용) | +| data.original_filename | string | `product-main.jpg` | 업로드한 원본 파일명 | +| data.mime_type | string | `image/jpeg` | 파일 MIME 타입 | +| data.size | integer | `204800` | 파일 크기 (바이트) | +| data.size_formatted | string | `200 KB` | 사람이 읽는 파일 크기 문자열 (B/KB/MB/GB) | +| data.download_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 이미지 다운로드/표시 URL | +| data.order | integer | `1` | 이미지 노출 순서 (`sort_order`, 미지정 시 1) | +| data.is_image | boolean | `true` | MIME 타입이 `image/` 로 시작하는지 여부 | +| data.is_thumbnail | boolean | `true` | 대표 이미지 여부 (컬렉션의 첫 이미지는 자동 지정) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "이미지가 업로드되었습니다.", + "data": { + "data": { + "id": 1, + "hash": "3f7a1c9e", + "original_filename": "product-main.jpg", + "mime_type": "image/jpeg", + "size": 204800, + "size_formatted": "200 KB", + "download_url": "/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download", + "order": 1, + "is_image": true, + "is_thumbnail": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 파일 저장 등 업로드 처리 중 오류가 발생한 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 상품 이미지 개수 상한 초과 (`ProductImageUploadLimitException`) | @@ -970,16 +1336,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 데이터를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "이미지 순서가 변경되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 순서 갱신 처리 중 오류가 발생한 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -1012,19 +1389,30 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 데이터를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "이미지가 삭제되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 삭제 처리 중 오류가 발생한 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | `id` 에 해당하는 이미지가 없는 경우 (`messages.product_images.not_found`) | @@ -1134,8 +1522,8 @@ Authorization: Bearer {YOUR_TOKEN} | temp_key | body | string | 아니오 | max 64 | 임시 업로드 세션 키 (같은 상품의 여러 이미지를 한 세션으로 묶음, 생략 시 서버가 UUID 자동 발급) | | collection | body | string | 아니오 | — | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | | alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | -| alt_text.ko | body | string | 아니오 | max 255 | | -| alt_text.en | body | string | 아니오 | max 255 | | +| alt_text.ko | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `ko` 로케일 값 | +| alt_text.en | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `en` 로케일 값 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-image.filter_upload_validation_rules`). @@ -1178,20 +1566,58 @@ Content-Disposition: form-data; name="alt_text.en" **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. FileUploader 컴포넌트 규약에 맞춰 실제 이미지 정보는 `data.data` 로 한 단계 더 감싸져 있습니다. 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| data | object | `{ ... }` | 업로드된 이미지 정보 객체 (아래 하위 필드) | +| data.id | integer | `1` | 업로드된 이미지 레코드의 기본 키 | +| data.hash | string | `3f7a1c9e…` | 이미지 해시 (상품 수정 시 `thumbnail_hash` 지정에 사용) | +| data.original_filename | string | `product-main.jpg` | 업로드한 원본 파일명 | +| data.mime_type | string | `image/jpeg` | 파일 MIME 타입 | +| data.size | integer | `204800` | 파일 크기 (바이트) | +| data.size_formatted | string | `200 KB` | 사람이 읽는 파일 크기 문자열 (B/KB/MB/GB) | +| data.download_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 이미지 다운로드/표시 URL | +| data.order | integer | `1` | 이미지 노출 순서 (`sort_order`) | +| data.is_image | boolean | `true` | MIME 타입이 `image/` 로 시작하는지 여부 | +| data.is_thumbnail | boolean | `true` | 대표 이미지 여부 (컬렉션의 첫 이미지는 자동 지정) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "이미지가 업로드되었습니다.", + "data": { + "data": { + "id": 1, + "hash": "3f7a1c9e", + "original_filename": "product-main.jpg", + "mime_type": "image/jpeg", + "size": 204800, + "size_formatted": "200 KB", + "download_url": "/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download", + "order": 1, + "is_image": true, + "is_thumbnail": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 파일 저장 등 업로드 처리 중 오류가 발생한 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 상품 이미지 개수 상한 초과 (`ProductImageUploadLimitException`) | @@ -1222,16 +1648,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 데이터를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "대표 이미지가 설정되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 대표 이미지 설정 처리 중 오류가 발생한 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | @@ -1418,11 +1855,125 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품 전체). 필드 구성은 `GET /admin/products/by-code/{code}` 의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 상품의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 상품","en":"API Doc Sample Product"}` | 상품명 (로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 상품` | `name` 의 현재 로케일 해석 값 | +| product_code | string | `APIDOCSAMPLE01` | 상품코드 | +| sku | string | `SKU-DKOK-1319` | 재고관리코드(SKU) | +| categories | array | `[]` | 소속 카테고리 목록 (categories 관계 로드 시) | +| category_ids | array | `[]` | 소속 카테고리 ID 배열 | +| primary_category_id | integer | `null` | 대표 카테고리 ID | +| brand_id | integer | `null` | 브랜드 식별자 | +| list_price | integer | `703155` | 정가 (기본통화 기준) | +| selling_price | integer | `597682` | 판매가 (기본통화 기준) | +| discount_rate | integer | `15` | 할인율(%) | +| stock_quantity | integer | `509` | 재고 수량 | +| safe_stock_quantity | integer | `38` | 안전재고 수량 | +| is_below_safe_stock | boolean | `false` | 재고가 안전재고 미만인지 여부 | +| is_stock_consistent | boolean | `true` | 상품 재고와 옵션 재고 합계 일치 여부 | +| sales_status | string | `on_sale` | 판매상태 (on_sale / suspended / sold_out / coming_soon) | +| sales_status_label | string | `판매중` | `sales_status` 의 현지화 라벨 | +| display_status | string | `visible` | 전시상태 (visible / hidden) | +| display_status_label | string | `전시` | `display_status` 의 현지화 라벨 | +| tax_status | string | `taxable` | 과세여부 (taxable / tax_free) | +| tax_status_label | string | `과세` | `tax_status` 의 현지화 라벨 | +| tax_rate | string | `10.00` | 세율(%) | +| shipping_policy_id | integer | `null` | 배송정책 식별자 | +| common_info_id | integer | `null` | 공통정보 식별자 | +| description | object | `{"ko":"상품 설명","en":"Description"}` | 상세 설명 (로케일별 값 객체) | +| description_localized | string | `상품 설명` | `description` 의 현재 로케일 해석 값 | +| description_mode | string | `text` | 상세 설명 편집 모드 (text / html) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| purchase_restriction | string | `none` | 구매 대상 제한 (none / restricted) | +| allowed_roles | array | `null` | 구매 허용 역할 ID 배열 | +| barcode | string | `null` | 바코드 | +| hs_code | string | `null` | HS 코드 | +| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) | +| thumbnail_hash | string | `null` | 대표 이미지 해시 | +| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL | +| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) | +| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) | +| meta_keywords | array | `null` | SEO 메타 키워드 배열 | +| seo_sync_title | boolean | `true` | SEO 제목 자동 동기화 여부 | +| seo_sync_description | boolean | `true` | SEO 설명 자동 동기화 여부 | +| has_options | boolean | `false` | 옵션 사용 여부 | +| option_groups | array | `[]` | 옵션 그룹 정의 | +| options | array | `[]` | 옵션(SKU) 목록 (`ProductOptionResource`) | +| additional_options | array | `[]` | 추가옵션 그룹 목록 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 11:02:13` | 최종 수정 일시 (이번 수정 시각으로 갱신) | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품이 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "name_localized": "API 문서 샘플 상품", + "product_code": "APIDOCSAMPLE01", + "sku": "SKU-DKOK-1319", + "category_ids": [], + "primary_category_id": null, + "brand_id": null, + "list_price": 703155, + "selling_price": 597682, + "discount_rate": 15, + "stock_quantity": 509, + "safe_stock_quantity": 38, + "is_below_safe_stock": false, + "is_stock_consistent": true, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "display_status": "visible", + "display_status_label": "전시", + "tax_status": "taxable", + "tax_status_label": "과세", + "tax_rate": "10.00", + "shipping_policy_id": null, + "common_info_id": null, + "description_mode": "text", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "purchase_restriction": "none", + "allowed_roles": null, + "barcode": null, + "hs_code": null, + "thumbnail_hash": null, + "thumbnail_url": null, + "meta_title": null, + "meta_description": null, + "meta_keywords": null, + "seo_sync_title": true, + "seo_sync_description": true, + "has_options": false, + "option_groups": [], + "options": [], + "additional_options": [], + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 11:02:13", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -2386,9 +2937,51 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) +_목록 응답: `data[]` 배열 항목의 필드 (`ProductListResource` — `GET /products/new` 와 동일 구성). `ids` 가 비어 있으면 `data` 는 빈 배열입니다._ - - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `322` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"eum et quia","en":"tenetur id quae"}` | 상품명 (로케일별 값 객체) | +| name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 | +| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 대표 이미지 URL | +| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화) | +| list_price_formatted | string | `112,594원` | `list_price` 의 표시용 통화 포맷 문자열 | +| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화) | +| selling_price_formatted | string | `88,949원` | `selling_price` 의 표시용 통화 포맷 문자열 | +| discount_rate | integer | `21` | 할인율(%) ((1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":112594,"formatted":"112,594원","is_default":true,"editable":true}}` | 통화별 정가 맵 | +| multi_currency_selling_price | object | `{"KRW":{"price":88949,"formatted":"88,949원","is_default":true,"editable":true}}` | 통화별 판매가 맵 | +| stock_quantity | integer | `22` | 재고 수량 | +| safe_stock_quantity | integer | `12` | 안전재고 수량 | +| is_below_safe_stock | boolean | `false` | 재고가 안전재고 미만인지 여부 | +| option_stock_sum | integer | `51` | 활성 옵션의 재고 합계 (options 관계 로드 시) | +| sales_status | string | `on_sale` | 판매상태 (on_sale / suspended / sold_out / coming_soon) | +| sales_status_label | string | `판매중` | `sales_status` 의 현지화 라벨 | +| sales_status_variant | string | `success` | `sales_status` 의 UI 배지 변형 키 | +| display_status | string | `visible` | 전시상태 (visible / hidden) | +| display_status_label | string | `전시` | `display_status` 의 현지화 라벨 | +| display_status_variant | string | `success` | `display_status` 의 UI 배지 변형 키 | +| categories | array | `[]` | 소속 카테고리 목록 (categories 관계 로드 시) | +| primary_category | string | `스마트폰` | 대표 카테고리명 (categories 관계 로드 시) | +| categories_with_path | array | `[]` | 소속 카테고리 + 경로(breadcrumb) 목록 | +| brand_name | string | `ASUS` | 브랜드명 (brand 관계 로드 시) | +| shipping_policy_id | integer | `31` | 배송정책 식별자 | +| shipping_policy_name | string | `국내 무료배송` | 배송정책명 (shippingPolicy 관계 로드 시) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| has_options | boolean | `false` | 옵션 사용 여부 | +| options_count | integer | `1` | 활성 옵션 개수 (options 관계 로드 시) | +| options | array | `[]` | 활성 옵션(SKU) 목록 (`ProductOptionResource`) | +| labels | array | `[]` | 노출 중인 상품 라벨 목록 (activeLabelAssignments 관계 로드 시) | +| review_count | integer | `0` | 리뷰 개수 | +| rating_avg | number | `0` | 평균 별점 (소수 1자리 반올림) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 | +| abilities | object | `{"can_update":false,"can_delete":false}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 | **응답 예시** @@ -2854,11 +3447,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 상품 문의 글의 기본 키 (게시판 모듈에 생성된 게시글 ID) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "문의가 등록되었습니다.", + "data": { + "id": 1 + } +} +``` **에러 응답** @@ -2866,7 +3475,7 @@ Content-Type: application/json | --- | --- | --- | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 도메인 규칙 위반(비밀글 비허용 등, `RuntimeException` → `messages.inquiries.create_failed`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md index b414359d..9ae14fea 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md @@ -267,11 +267,150 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 쿠폰 = `CouponResource`, 상세 조회(show)와 동일한 필드 집합)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 쿠폰의 기본 키 | +| name | object | `{"ko":"신규가입 쿠폰","en":"Signup Coupon"}` | 쿠폰명 (로케일별 값 객체) | +| localized_name | string | `신규가입 쿠폰` | `name` 의 현재 로케일 해석 값 | +| description | object\|null | `null` | 쿠폰 설명 (로케일별 값 객체) | +| localized_description | string\|null | `null` | `description` 의 현재 로케일 해석 값 | +| target_type | string | `order_amount` | 적용대상: product_amount(상품금액), order_amount(주문금액), shipping_fee(배송비) | +| target_type_label | string | `주문금액` | `target_type` 의 다국어 라벨 | +| target_type_badge_color | string | `blue` | `target_type` 배지 색상 (상품금액=teal, 주문금액=blue, 배송비=orange) | +| discount_type | string | `fixed` | 혜택유형: fixed(정액), rate(정률 %) | +| discount_type_label | string | `정액할인` | `discount_type` 의 다국어 라벨 | +| discount_value | number | `1000` | 혜택값 (정액=할인 금액, 정률=할인율 %) | +| discount_max_amount | number\|null | `null` | 최대 할인액 (정률 시 상한, 미설정 null) | +| min_order_amount | number | `0` | 쿠폰 적용 최소 주문금액 (0=제한 없음) | +| benefit_formatted | string | `1,000원 할인` | 혜택 표시용 포맷 문자열 | +| multi_currency_discount_value | object\|null | `{"KRW":{"price":1000,"formatted":"1,000원","is_default":true,"editable":true}}` | 정액 할인 금액의 통화별 환산 맵 (정률은 null) | +| multi_currency_min_order_amount | object\|null | `null` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | +| multi_currency_discount_max_amount | object\|null | `null` | 최대 할인액의 통화별 환산 맵 (미설정 시 null) | +| issue_method | string | `download` | 발급방법: direct(직접발급), download(다운로드), auto(자동발급) | +| issue_method_label | string | `다운로드` | `issue_method` 의 다국어 라벨 | +| issue_method_badge_color | string | `teal` | `issue_method` 배지 색상 (직접발급=gray, 다운로드=teal, 자동발급=blue) | +| issue_condition | string | `manual` | 발급조건: manual(수동), signup(회원가입), first_purchase(첫구매), birthday(생일) | +| issue_condition_label | string | `수동발급` | `issue_condition` 의 다국어 라벨 | +| issue_condition_badge_color | string | `orange` | `issue_condition` 배지 색상 | +| issue_status | string | `issuing` | 발급상태: issuing(발급중), stopped(발급중단) | +| issue_status_label | string | `발급중` | `issue_status` 의 다국어 라벨 | +| issue_status_badge_color | string | `blue` | `issue_status` 배지 색상 (발급중=blue, 발급중단=orange) | +| total_quantity | integer\|null | `null` | 총 발급 수량 (null=무제한) | +| issued_count | integer | `0` | 현재까지 발급된 수량 (생성 직후 0) | +| per_user_limit | integer | `1` | 회원 1인당 발급 제한 수량 (0=무제한) | +| issue_count_formatted | string | `0/무제한` | 발급 수량 표시용 포맷 문자열 | +| valid_type | string | `period` | 유효기간 유형: period(기간 지정), days_from_issue(발급일로부터 N일) | +| valid_days | integer\|null | `null` | 발급일로부터 유효 일수 (valid_type=days_from_issue) | +| valid_from | string\|null | `2026-07-01` | 유효기간 시작일 | +| valid_to | string\|null | `2026-07-31` | 유효기간 종료일 | +| valid_period_formatted | string | `-` | 유효기간 표시용 포맷 문자열 | +| issue_from | string\|null | `null` | 발급기간 시작 일시 (datetime-local 호환) | +| issue_to | string\|null | `null` | 발급기간 종료 일시 (datetime-local 호환) | +| issue_period_formatted | string | `상시발급` | 발급기간 표시용 포맷 문자열 | +| is_combinable | boolean | `false` | 다른 쿠폰과 중복 사용 가능 여부 | +| target_scope | string | `all` | 적용 범위: all(전체 상품), products(특정 상품), categories(특정 카테고리) | +| target_scope_label | string | `전체상품` | `target_scope` 의 다국어 라벨 | +| is_issuable | boolean | `true` | 현재 발급 가능 상태인지 여부 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| created_by | string\|null | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 등록자(생성한 관리자) 식별자 | +| created_by_name | string | `관리자` | 등록자 이름 (미설정 시 `-`) | +| created_by_email | string\|null | `admin@example.com` | 등록자 이메일 (creator 관계 파생) | +| creator | object\|null | `{"uuid":"a1e0a91a-…","name":"관리자"}` | 생성자 정보 객체 | +| included_products | array | `[]` | 적용 대상 포함 상품 목록 (target_scope=products) | +| excluded_products | array | `[]` | 적용 제외 상품 목록 | +| included_categories | array | `[]` | 적용 대상 포함 카테고리 목록 (target_scope=categories) | +| excluded_categories | array | `[]` | 적용 제외 카테고리 목록 | +| issues_count | integer | `0` | 발급 내역 건수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "쿠폰이 등록되었습니다.", + "data": { + "id": 2, + "name": { + "ko": "신규가입 쿠폰", + "en": "Signup Coupon" + }, + "localized_name": "신규가입 쿠폰", + "description": null, + "localized_description": null, + "target_type": "order_amount", + "target_type_label": "주문금액", + "target_type_badge_color": "blue", + "discount_type": "fixed", + "discount_type_label": "정액할인", + "discount_value": 1000, + "discount_max_amount": null, + "min_order_amount": 0, + "benefit_formatted": "1,000원 할인", + "multi_currency_discount_value": { + "KRW": { + "price": 1000, + "formatted": "1,000원", + "is_default": true, + "editable": true + } + }, + "multi_currency_min_order_amount": null, + "multi_currency_discount_max_amount": null, + "issue_method": "download", + "issue_method_label": "다운로드", + "issue_method_badge_color": "teal", + "issue_condition": "manual", + "issue_condition_label": "수동발급", + "issue_condition_badge_color": "orange", + "issue_status": "issuing", + "issue_status_label": "발급중", + "issue_status_badge_color": "blue", + "total_quantity": null, + "issued_count": 0, + "per_user_limit": 1, + "issue_count_formatted": "0/무제한", + "valid_type": "period", + "valid_days": null, + "valid_from": "2026-07-01", + "valid_to": "2026-07-31", + "valid_period_formatted": "2026-07-01 ~ 2026-07-31", + "issue_from": null, + "issue_to": null, + "issue_period_formatted": "상시발급", + "is_combinable": false, + "target_scope": "all", + "target_scope_label": "전체상품", + "is_issuable": true, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "created_by": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "created_by_name": "관리자", + "created_by_email": "admin@example.com", + "creator": { + "uuid": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "name": "관리자" + }, + "included_products": [], + "excluded_products": [], + "included_categories": [], + "excluded_categories": [], + "issues_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -318,11 +457,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 발급상태가 실제로 변경된 쿠폰 건수 (`CouponService::bulkUpdateIssueStatus()` 반환값) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "3개 쿠폰의 발급상태가 변경되었습니다.", + "data": { + "updated_count": 3 + } +} +``` **에러 응답** @@ -502,16 +657,156 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 쿠폰 = `CouponResource`, 상세 조회(show)와 동일한 필드 집합)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 쿠폰의 기본 키 | +| name | object | `{"ko":"API 문서 샘플 쿠폰(수정)","en":"API Doc Sample Coupon (edited)"}` | 쿠폰명 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 쿠폰(수정)` | `name` 의 현재 로케일 해석 값 | +| description | object\|null | `null` | 쿠폰 설명 (로케일별 값 객체) | +| localized_description | string\|null | `null` | `description` 의 현재 로케일 해석 값 | +| target_type | string | `order_amount` | 적용대상: product_amount, order_amount, shipping_fee | +| target_type_label | string | `주문금액` | `target_type` 의 다국어 라벨 | +| target_type_badge_color | string | `blue` | `target_type` 배지 색상 | +| discount_type | string | `fixed` | 혜택유형: fixed(정액), rate(정률 %) | +| discount_type_label | string | `정액할인` | `discount_type` 의 다국어 라벨 | +| discount_value | number | `2000` | 혜택값 (정액=할인 금액, 정률=할인율 %) | +| discount_max_amount | number\|null | `null` | 최대 할인액 (정률 시 상한) | +| min_order_amount | number | `0` | 쿠폰 적용 최소 주문금액 (0=제한 없음) | +| benefit_formatted | string | `2,000원 할인` | 혜택 표시용 포맷 문자열 | +| multi_currency_discount_value | object\|null | `{"KRW":{"price":2000,"formatted":"2,000원","is_default":true,"editable":true}}` | 정액 할인 금액의 통화별 환산 맵 (정률은 null) | +| multi_currency_min_order_amount | object\|null | `null` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | +| multi_currency_discount_max_amount | object\|null | `null` | 최대 할인액의 통화별 환산 맵 (미설정 시 null) | +| issue_method | string | `download` | 발급방법: direct, download, auto | +| issue_method_label | string | `다운로드` | `issue_method` 의 다국어 라벨 | +| issue_method_badge_color | string | `teal` | `issue_method` 배지 색상 | +| issue_condition | string | `manual` | 발급조건: manual, signup, first_purchase, birthday | +| issue_condition_label | string | `수동발급` | `issue_condition` 의 다국어 라벨 | +| issue_condition_badge_color | string | `orange` | `issue_condition` 배지 색상 | +| issue_status | string | `issuing` | 발급상태: issuing(발급중), stopped(발급중단) | +| issue_status_label | string | `발급중` | `issue_status` 의 다국어 라벨 | +| issue_status_badge_color | string | `blue` | `issue_status` 배지 색상 | +| total_quantity | integer\|null | `null` | 총 발급 수량 (null=무제한) | +| issued_count | integer | `0` | 현재까지 발급된 수량 | +| per_user_limit | integer | `1` | 회원 1인당 발급 제한 수량 (0=무제한) | +| issue_count_formatted | string | `0/무제한` | 발급 수량 표시용 포맷 문자열 | +| valid_type | string | `period` | 유효기간 유형: period, days_from_issue | +| valid_days | integer\|null | `null` | 발급일로부터 유효 일수 | +| valid_from | string\|null | `2026-07-01` | 유효기간 시작일 | +| valid_to | string\|null | `2026-07-31` | 유효기간 종료일 | +| valid_period_formatted | string | `2026-07-01 ~ 2026-07-31` | 유효기간 표시용 포맷 문자열 | +| issue_from | string\|null | `null` | 발급기간 시작 일시 | +| issue_to | string\|null | `null` | 발급기간 종료 일시 | +| issue_period_formatted | string | `상시발급` | 발급기간 표시용 포맷 문자열 | +| is_combinable | boolean | `false` | 다른 쿠폰과 중복 사용 가능 여부 | +| target_scope | string | `all` | 적용 범위: all, products, categories | +| target_scope_label | string | `전체상품` | `target_scope` 의 다국어 라벨 | +| is_issuable | boolean | `true` | 현재 발급 가능 상태인지 여부 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 11:02:13` | 최종 수정 일시 (수정 반영) | +| created_by | string\|null | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 등록자 식별자 | +| created_by_name | string | `관리자` | 등록자 이름 (미설정 시 `-`) | +| created_by_email | string\|null | `admin@example.com` | 등록자 이메일 | +| creator | object\|null | `{"uuid":"a1e0a91a-…","name":"관리자"}` | 생성자 정보 객체 | +| included_products | array | `[]` | 적용 대상 포함 상품 목록 (수정 시 동기화 결과) | +| excluded_products | array | `[]` | 적용 제외 상품 목록 | +| included_categories | array | `[]` | 적용 대상 포함 카테고리 목록 | +| excluded_categories | array | `[]` | 적용 제외 카테고리 목록 | +| issues_count | integer | `0` | 발급 내역 건수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰이 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 쿠폰(수정)", + "en": "API Doc Sample Coupon (edited)" + }, + "localized_name": "API 문서 샘플 쿠폰(수정)", + "description": null, + "localized_description": null, + "target_type": "order_amount", + "target_type_label": "주문금액", + "target_type_badge_color": "blue", + "discount_type": "fixed", + "discount_type_label": "정액할인", + "discount_value": 2000, + "discount_max_amount": null, + "min_order_amount": 0, + "benefit_formatted": "2,000원 할인", + "multi_currency_discount_value": { + "KRW": { + "price": 2000, + "formatted": "2,000원", + "is_default": true, + "editable": true + } + }, + "multi_currency_min_order_amount": null, + "multi_currency_discount_max_amount": null, + "issue_method": "download", + "issue_method_label": "다운로드", + "issue_method_badge_color": "teal", + "issue_condition": "manual", + "issue_condition_label": "수동발급", + "issue_condition_badge_color": "orange", + "issue_status": "issuing", + "issue_status_label": "발급중", + "issue_status_badge_color": "blue", + "total_quantity": null, + "issued_count": 0, + "per_user_limit": 1, + "issue_count_formatted": "0/무제한", + "valid_type": "period", + "valid_days": null, + "valid_from": "2026-07-01", + "valid_to": "2026-07-31", + "valid_period_formatted": "2026-07-01 ~ 2026-07-31", + "issue_from": null, + "issue_to": null, + "issue_period_formatted": "상시발급", + "is_combinable": false, + "target_scope": "all", + "target_scope_label": "전체상품", + "is_issuable": true, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 11:02:13", + "created_by": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "created_by_name": "관리자", + "created_by_email": "admin@example.com", + "creator": { + "uuid": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "name": "관리자" + }, + "included_products": [], + "excluded_products": [], + "included_categories": [], + "excluded_categories": [], + "issues_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 수정 처리 중 예외 발생 시 (`exceptions.operation_failed`: "작업 처리 중 오류가 발생했습니다.") — 대상 쿠폰 미존재도 이 경로로 응답 | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | @@ -553,16 +848,44 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CouponService::issueDirectly()` 반환 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| issued | integer | `2` | 실제로 발급에 성공한 회원 수 | +| skipped | array | `[{"user_id":15,"reason":"1인당 발급 가능 수량을 초과했습니다."}]` | 발급이 건너뛰어진 회원 목록 (전체 롤백 없이 회원 단위로 누적) | +| skipped[].user_id | integer | `15` | 건너뛴 회원의 내부 회원 ID | +| skipped[].reason | string | `1인당 발급 가능 수량을 초과했습니다.` | 건너뛴 사유 (발급 검증 예외 메시지 원문) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "2명에게 발급했습니다. (1명은 발급 조건 미충족으로 제외)", + "data": { + "issued": 2, + "skipped": [ + { + "user_id": 15, + "reason": "1인당 발급 가능 수량을 초과했습니다." + } + ] + } +} +``` + +> 건너뛴 회원이 없으면 메시지는 `messages.coupons.direct_issued`(`":issued명에게 쿠폰을 발급했습니다."`)이고 `skipped` 는 `[]` 입니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 쿠폰 미존재 또는 쿠폰 자체가 발급 불가 상태(발급중단/기간 외/수량 소진 등)여서 전원 차단된 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | @@ -601,7 +924,30 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_목록 응답: `data.data[]` 배열 항목의 필드 (`CouponIssueResource`) + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 발급 내역 ID (기본 키) | +| coupon_id | integer | `1` | 발급된 쿠폰(정의) ID | +| user_id | string\|null | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 발급 대상 회원의 UUID (내부 정수 ID 대신 UUID 노출) | +| coupon_code | string\|null | `WELCOME2026` | 쿠폰 코드 (다운로드 쿠폰 시 발급되는 코드, 없으면 null) | +| status | string | `available` | 발급 내역 상태: available(사용가능), used(사용완료), expired(만료), cancelled(취소) | +| status_label | string | `사용가능` | `status` 의 다국어 라벨 (사용가능/사용완료/기간만료/취소됨) | +| status_badge_color | string | `blue` | `status` 배지 색상 (available=blue, used=green, expired=gray, cancelled=red) | +| issued_at | string\|null | `2026-07-08 10:50:11` | 발급 일시 (사용자 타임존 포맷) | +| expired_at | string\|null | `2026-07-31 23:59:59` | 만료 일시 (사용자 타임존 포맷, 없으면 null) | +| used_at | string\|null | `null` | 사용 일시 (미사용 시 null) | +| order_id | integer\|null | `null` | 쿠폰이 사용된 주문 ID (미사용 시 null) | +| order_number | string\|null | `20260708-0001` | 사용된 주문의 주문번호 (order 관계 로드 시) | +| discount_amount | number\|null | `null` | 실제 적용된 할인 금액 (기본 통화 자릿수로 반올림, 미사용 시 null) | +| is_used | boolean | `false` | 사용 완료 여부 (`status === 'used'`) | +| is_expired | boolean | `false` | 만료 여부 | +| is_usable | boolean | `true` | 현재 사용 가능 여부 | +| is_cancellable | boolean | `true` | 관리자 취소 가능 여부 (available 이면서 미만료일 때만 true) | +| user_name | string | `홍길동` | 발급 대상 회원 이름 (user 관계 로드 시) | + +`data.pagination` 은 `current_page`, `last_page`, `per_page`, `total`, `from`, `to`, `has_more_pages` 를 포함합니다. **응답 예시** @@ -645,16 +991,65 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (취소 처리된 발급 내역 = `CouponIssueResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 취소된 발급 내역 ID | +| coupon_id | integer | `1` | 발급된 쿠폰(정의) ID | +| user_id | string\|null | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 발급 대상 회원의 UUID | +| coupon_code | string\|null | `WELCOME2026` | 쿠폰 코드 (다운로드 쿠폰 시) | +| status | string | `cancelled` | 취소 처리 후 상태 (항상 `cancelled`) | +| status_label | string | `취소됨` | `status` 의 다국어 라벨 | +| status_badge_color | string | `red` | `status` 배지 색상 (cancelled=red) | +| issued_at | string\|null | `2026-07-08 10:50:11` | 발급 일시 | +| expired_at | string\|null | `2026-07-31 23:59:59` | 만료 일시 | +| used_at | string\|null | `null` | 사용 일시 (미사용 건만 취소 가능하므로 항상 null) | +| order_id | integer\|null | `null` | 사용된 주문 ID (미사용이므로 null) | +| discount_amount | number\|null | `null` | 실제 적용된 할인 금액 (미사용이므로 null) | +| is_used | boolean | `false` | 사용 완료 여부 | +| is_expired | boolean | `false` | 만료 여부 | +| is_usable | boolean | `false` | 현재 사용 가능 여부 (취소되었으므로 false) | +| is_cancellable | boolean | `false` | 추가 취소 가능 여부 (이미 취소되어 false) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰 발급이 취소되었습니다.", + "data": { + "id": 1, + "coupon_id": 1, + "user_id": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "coupon_code": "WELCOME2026", + "status": "cancelled", + "status_label": "취소됨", + "status_badge_color": "red", + "issued_at": "2026-07-08 10:50:11", + "expired_at": "2026-07-31 23:59:59", + "used_at": null, + "order_id": null, + "discount_amount": null, + "is_used": false, + "is_expired": false, + "is_usable": false, + "is_cancellable": false + } +} +``` + +> `order_number` / `user_name` 은 관계가 로드된 경우에만 포함됩니다 (취소 응답에서는 관계를 별도 로드하지 않으므로 생략될 수 있음). **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 발급 내역 미존재(쿠폰-발급 조합 불일치 포함) 또는 미사용(available) 상태가 아니어서 취소 불가한 경우. `error.detail` 에 사유 원문("쿠폰 발급 내역을 찾을 수 없습니다." / "미사용 상태의 발급 건만 취소할 수 있습니다.")이 담김 | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md index b185aa33..33091217 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md @@ -39,17 +39,41 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투(`data`)를 반환하지 않습니다. 성공 시 이미지 바이너리 스트림(`StreamedResponse`)을 그대로 반환합니다 (`ProductReviewImageService::download()` → `StorageInterface::response()`)._ + +성공 응답의 헤더는 다음과 같습니다. + +| 헤더 | 값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `image/jpeg` | 업로드 시 저장된 이미지의 MIME 타입 (`product_review_images.mime_type`) | +| Cache-Control | `public, max-age=31536000` | 공개 이미지 장기 캐싱 (1년) | +| Content-Disposition | `attachment; filename="review.jpg"` | 업로드 시의 원본 파일명 (`original_filename`) | **응답 예시** - +```http +HTTP/1.1 200 OK +Content-Type: image/jpeg +Cache-Control: public, max-age=31536000 +Content-Disposition: attachment; filename="review.jpg" + +<이미지 바이너리 스트림> +``` + +이미지를 찾지 못한 경우에만 JSON 봉투가 반환됩니다. + +```json +{ + "success": false, + "message": "이미지를 찾을 수 없습니다." +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해시에 해당하는 리뷰 이미지가 없거나(`findByHash()` → null), 레코드는 있으나 스토리지에 실제 파일이 없는 경우 (`messages.reviews.image_not_found`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md b/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md index 9aa0f10e..c5ba3335 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md @@ -189,11 +189,38 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. `action` 값에 따라 반환 키가 달라집니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `3` | 삭제된 리뷰 건수 (`action=delete` 일 때만 반환) | +| updated_count | integer | `3` | 상태가 변경된 리뷰 건수 (`action=change_status` 일 때만 반환) | **응답 예시** - +`action=delete` (message: `messages.reviews.bulk_deleted`) + +```json +{ + "success": true, + "message": "선택한 리뷰가 삭제되었습니다.", + "data": { + "deleted_count": 3 + } +} +``` + +`action=change_status` (message: `messages.reviews.bulk_updated`) + +```json +{ + "success": true, + "message": "선택한 리뷰의 상태가 변경되었습니다.", + "data": { + "updated_count": 3 + } +} +``` **에러 응답** @@ -201,7 +228,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`ids` 누락/빈 배열, `ids.*` 가 존재하지 않는 리뷰 ID, `action` 이 `delete`/`change_status` 외의 값, `action=change_status` 인데 `status` 누락, `status` 가 `visible`/`hidden` 외의 값) | +| 500 | Internal Server Error | 일괄 처리 중 예외 발생 (`messages.reviews.bulk_failed` — "일괄 처리에 실패했습니다.") | @@ -502,11 +530,78 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (상태가 변경된 리뷰 리소스). 컨트롤러가 관계를 추가로 로드하지 않으므로 `product`·`images`·`image_count`·`orderOption`·`reply_admin_uuid`·`reply_admin` 등 관계 의존 키는 응답에 포함되지 않습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 리뷰 ID (기본 키) | +| product_id | integer | `1` | 리뷰 대상 상품 ID | +| order_option_id | integer | `1` | 리뷰가 연결된 주문 옵션 ID | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 작성자 UUID | +| user | object | `{"uuid":"a234c2b1-...","name":"API 문서 샘플 사용자","email":"apidoc-sample-user@example.com"}` | 작성자 정보 (uuid·name·email, `user` 관계 로드 시) | +| option_snapshot | object\|null | `null` | 주문 시점 옵션 스냅샷 (옵션명 보존용, 주문 옵션에서 복사) | +| option_snapshot_label | string | `` | `option_snapshot.option_name` 의 현재 로케일 표시 문자열 | +| rating | integer | `5` | 별점 (1~5) | +| content | string | `Alias quas iusto dolorem eum eveniet …` | 리뷰 내용 | +| content_mode | string | `text` | 콘텐츠 모드: text / html | +| status | string | `hidden` | 변경된 리뷰 상태: `visible`(전시중) / `hidden`(숨김) | +| status_label | string | `숨김` | 상태의 사람이 읽는 라벨 (ReviewStatus::label()) | +| status_badge_color | string | `gray` | 상태 뱃지 색상 (visible=blue / hidden=gray) | +| has_reply | boolean | `false` | 판매자 답변 존재 여부 (`reply_content` 유무) | +| has_reply_label | string | `미답변` | `has_reply` 의 사람이 읽는 라벨 (답변완료 / 미답변) | +| has_reply_badge_color | string | `gray` | 답변 여부 뱃지 색상 (답변완료=green / 미답변=gray) | +| reply_content | string\|null | `null` | 판매자 답변 내용 (없으면 null) | +| reply_content_mode | string | `text` | 답변 콘텐츠 모드: text / html | +| replied_at | string\|null | `null` | 답변 최초 작성 일시 | +| reply_updated_at | string\|null | `null` | 답변 최종 수정 일시 | +| created_at | string | `2026-07-08 10:44:49` | 리뷰 생성 일시 | +| updated_at | string | `2026-07-08 15:10:07` | 리뷰 최종 수정 일시 (상태 변경으로 갱신됨) | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리뷰에 수행 가능한 작업 불리언 맵 (can_update, can_delete) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "리뷰가 수정되었습니다.", + "data": { + "id": 1, + "product_id": 1, + "order_option_id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "option_snapshot": null, + "option_snapshot_label": "", + "rating": 5, + "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut.", + "content_mode": "text", + "status": "hidden", + "status_label": "숨김", + "status_badge_color": "gray", + "has_reply": false, + "has_reply_label": "미답변", + "has_reply_badge_color": "gray", + "reply_content": null, + "reply_content_mode": "text", + "replied_at": null, + "reply_updated_at": null, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:10:07", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -515,7 +610,8 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | `status` 누락 또는 `visible`/`hidden` 외의 값 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 상태 변경 중 예외 발생 (`messages.reviews.update_failed` — "리뷰 수정에 실패했습니다.") | @@ -560,11 +656,72 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 리뷰 리소스, HTTP 201). 서비스가 생성 직후의 모델을 그대로 반환하므로 `product`·`images`·`image_count`·`orderOption`·`reply_admin_uuid`·`reply_admin` 등 관계 의존 키는 응답에 포함되지 않습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 리뷰 ID (기본 키) | +| product_id | integer | `1` | 리뷰 대상 상품 ID (요청값 그대로) | +| order_option_id | integer | `1` | 리뷰가 연결된 주문 옵션 ID (요청값 그대로) | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 작성자 UUID (로그인 사용자) | +| option_snapshot | object\|null | `null` | 주문 시점 옵션 스냅샷 (주문 옵션의 `option_snapshot` 을 복사) | +| option_snapshot_label | string | `` | `option_snapshot.option_name` 의 현재 로케일 표시 문자열 | +| rating | integer | `5` | 별점 (1~5, 요청값 그대로) | +| content | string | `예시 내용입니다.` | 리뷰 내용 (10~2000자, 요청값 그대로) | +| content_mode | string | `text` | 콘텐츠 모드: text / html (미지정 시 text) | +| status | string | `visible` | 생성 시 상태 — 항상 `visible`(전시중) | +| status_label | string | `전시중` | 상태의 사람이 읽는 라벨 (ReviewStatus::label()) | +| status_badge_color | string | `blue` | 상태 뱃지 색상 (visible=blue / hidden=gray) | +| has_reply | boolean | `false` | 판매자 답변 존재 여부 (생성 직후는 항상 false) | +| has_reply_label | string | `미답변` | `has_reply` 의 사람이 읽는 라벨 (답변완료 / 미답변) | +| has_reply_badge_color | string | `gray` | 답변 여부 뱃지 색상 (답변완료=green / 미답변=gray) | +| reply_content | null | `null` | 판매자 답변 내용 (생성 직후는 null) | +| reply_content_mode | string | `text` | 답변 콘텐츠 모드: text / html | +| replied_at | null | `null` | 답변 최초 작성 일시 (생성 직후는 null) | +| reply_updated_at | null | `null` | 답변 최종 수정 일시 (생성 직후는 null) | +| created_at | string | `2026-07-08 15:20:11` | 리뷰 생성 일시 | +| updated_at | string | `2026-07-08 15:20:11` | 리뷰 최종 수정 일시 | +| abilities | object | `{"can_update":false,"can_delete":false}` | 현재 사용자가 이 리뷰에 수행 가능한 작업 불리언 맵 (관리자 권한 `sirsoft-ecommerce.reviews.update`/`.delete` 기준이므로 일반 회원은 false) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "리뷰가 작성되었습니다.", + "data": { + "id": 1, + "product_id": 1, + "order_option_id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "option_snapshot": null, + "option_snapshot_label": "", + "rating": 5, + "content": "예시 내용입니다. 배송도 빠르고 품질도 만족스럽습니다.", + "content_mode": "text", + "status": "visible", + "status_label": "전시중", + "status_badge_color": "blue", + "has_reply": false, + "has_reply_label": "미답변", + "has_reply_badge_color": "gray", + "reply_content": null, + "reply_content_mode": "text", + "replied_at": null, + "reply_updated_at": null, + "created_at": "2026-07-08 15:20:11", + "updated_at": "2026-07-08 15:20:11", + "abilities": { + "can_update": false, + "can_delete": false + } + } +} +``` **에러 응답** @@ -572,7 +729,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 작성 자격 미충족 시 `ReviewNotWritableException`(RuntimeException) 이 발생해 사유 코드가 담긴 메시지로 응답 (`order_option_not_found` / `not_own_order` / `not_confirmed` / `deadline_passed` / `already_written`) | +| 500 | Internal Server Error | 리뷰 생성 중 예외 발생 (`messages.reviews.create_failed` — "리뷰 작성에 실패했습니다.") | @@ -694,20 +852,65 @@ Content-Type: application/octet-stream **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (업로드된 리뷰 이미지 리소스, HTTP 201)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 리뷰 이미지 ID (기본 키) | +| review_id | integer | `1` | 이미지가 속한 리뷰 ID | +| hash | string | `9f3a1c7b20de` | URL 용 고유 해시 (12자, 생성 시 자동 부여) | +| original_filename | string | `example.jpg` | 업로드된 원본 파일명 | +| download_url | string | `/api/modules/sirsoft-ecommerce/review-image/9f3a1c7b20de` | 해시 기반 이미지 서빙 URL (`review-image/{hash}`) | +| mime_type | string | `image/jpeg` | MIME 타입 (예: image/jpeg, image/webp) | +| file_size | integer | `204800` | 파일 크기 (바이트) | +| width | integer\|null | `1200` | 이미지 너비 (px, 이미지가 아니거나 판별 실패 시 null) | +| height | integer\|null | `800` | 이미지 높이 (px, 이미지가 아니거나 판별 실패 시 null) | +| alt_text | object\|null | `null` | 대체 텍스트 (다국어 JSON, 업로드 시점에는 설정하지 않으므로 null) | +| is_thumbnail | boolean | `true` | 대표 이미지 여부 (해당 리뷰의 첫 이미지면 true) | +| sort_order | integer | `1` | 정렬 순서 (기존 최대값 + 1) | +| created_at | string | `2026-07-08 15:30:02` | 업로드 일시 | +| abilities | object | `{"can_delete":false}` | 현재 사용자가 이 이미지에 수행 가능한 작업 불리언 맵 (관리자 권한 `sirsoft-ecommerce.reviews.delete` 기준이므로 일반 회원은 false) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "리뷰 이미지가 업로드되었습니다.", + "data": { + "id": 1, + "review_id": 1, + "hash": "9f3a1c7b20de", + "original_filename": "example.jpg", + "download_url": "/api/modules/sirsoft-ecommerce/review-image/9f3a1c7b20de", + "mime_type": "image/jpeg", + "file_size": 204800, + "width": 1200, + "height": 800, + "alt_text": null, + "is_thumbnail": true, + "sort_order": 1, + "created_at": "2026-07-08 15:30:02", + "abilities": { + "can_delete": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없거나, 대상 리뷰가 로그인 사용자의 리뷰가 아닌 경우 (`messages.reviews.forbidden` — "권한이 없습니다.") | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | `image` 파일 누락·허용 형식/용량 위반 (`error.errors` 에 필드별 메시지) 또는 리뷰당 최대 첨부 개수 초과 시 `ReviewImageUploadLimitException`(RuntimeException) — 최대 개수는 리뷰 설정 `review_settings.max_images`(기본 5) 기준 | +| 500 | Internal Server Error | 업로드 처리 중 예외 발생 (`messages.reviews.image_upload_failed` — "리뷰 이미지 업로드에 실패했습니다.") | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md index 6c7f8c72..829e0abc 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md @@ -422,90 +422,90 @@ HTTP/1.1 200 | --- | --- | --- | --- | --- | --- | | _tab | body | string | 아니오 | `basic_info`, `language_currency`, `seo`, `order_settings`, `claim`, `shipping`, `review_settings`, `notification_definitions`, `notifications`, `inquiry`, `mileage` | 저장할 설정 탭(카테고리) 지정 (탭별 부분 저장 식별용) | | notifications | body | array | 아니오 | — | 알림 채널 설정 배열 (채널 ID·활성 여부·정렬 순서) | -| notifications.channels | body | array | 아니오 | — | | +| notifications.channels | body | array | 아니오 | — | 알림 채널 목록. 항목별 `id`(문자열, max 50, 필수), `is_active`(boolean, 필수), `sort_order`(integer, min 0) | | basic_info | body | array | 아니오 | — | 쇼핑몰 기본 정보 섹션 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처 등) | -| basic_info.shop_name | body | string | 아니오 | max 255 | basic info.shop 이름 (식별자) | -| basic_info.route_path | body | string | 아니오 | max 100 | | -| basic_info.no_route | body | boolean | 아니오 | — | | -| basic_info.company_name | body | string | 아니오 | max 255 | basic info.company 이름 (식별자) | -| basic_info.business_number_1 | body | string | 아니오 | max 3 | | -| basic_info.business_number_2 | body | string | 아니오 | max 2 | | -| basic_info.business_number_3 | body | string | 아니오 | max 5 | | -| basic_info.ceo_name | body | string | 아니오 | max 100 | basic info.ceo 이름 (식별자) | -| basic_info.business_type | body | string | 아니오 | max 100 | | -| basic_info.business_category | body | string | 아니오 | max 255 | | -| basic_info.zipcode | body | string | 아니오 | max 10 | | -| basic_info.base_address | body | string | 아니오 | max 500 | | -| basic_info.detail_address | body | string | 아니오 | max 255 | | -| basic_info.phone_1 | body | string | 아니오 | max 4 | | -| basic_info.phone_2 | body | string | 아니오 | max 4 | | -| basic_info.phone_3 | body | string | 아니오 | max 4 | | -| basic_info.fax_1 | body | string | 아니오 | max 4 | | -| basic_info.fax_2 | body | string | 아니오 | max 4 | | -| basic_info.fax_3 | body | string | 아니오 | max 4 | | -| basic_info.email_id | body | string | 아니오 | max 100 | basic info.email 식별자 | -| basic_info.email_domain | body | string | 아니오 | max 100 | | -| basic_info.privacy_officer | body | string | 아니오 | max 100 | | -| basic_info.privacy_officer_email | body | email | 아니오 | max 255 | | -| basic_info.mail_order_number | body | string | 아니오 | max 100 | | -| basic_info.telecom_number | body | string | 아니오 | max 100 | | +| basic_info.shop_name | body | string | 아니오 | max 255 | 쇼핑몰 이름 (`basic_info` 전달 시 필수) | +| basic_info.route_path | body | string | 아니오 | max 100 | 쇼핑몰 라우트 경로 (예: `shop`). `no_route: true` 이면 검증에서 제외 | +| basic_info.no_route | body | boolean | 아니오 | — | 라우트 경로를 사용하지 않음 (쇼핑몰만 단독 운영하는 경우) | +| basic_info.company_name | body | string | 아니오 | max 255 | 상호(회사명) | +| basic_info.business_number_1 | body | string | 아니오 | max 3 | 사업자등록번호 앞 3자리 | +| basic_info.business_number_2 | body | string | 아니오 | max 2 | 사업자등록번호 가운데 2자리 | +| basic_info.business_number_3 | body | string | 아니오 | max 5 | 사업자등록번호 뒤 5자리 | +| basic_info.ceo_name | body | string | 아니오 | max 100 | 대표자 이름 | +| basic_info.business_type | body | string | 아니오 | max 100 | 업태 (예: 도매, 소매) | +| basic_info.business_category | body | string | 아니오 | max 255 | 종목 (예: 전자상거래) | +| basic_info.zipcode | body | string | 아니오 | max 10 | 사업장 우편번호 | +| basic_info.base_address | body | string | 아니오 | max 500 | 사업장 기본주소 | +| basic_info.detail_address | body | string | 아니오 | max 255 | 사업장 상세주소 | +| basic_info.phone_1 | body | string | 아니오 | max 4 | 대표 전화번호 첫 번째 마디 | +| basic_info.phone_2 | body | string | 아니오 | max 4 | 대표 전화번호 두 번째 마디 | +| basic_info.phone_3 | body | string | 아니오 | max 4 | 대표 전화번호 세 번째 마디 | +| basic_info.fax_1 | body | string | 아니오 | max 4 | 대표 팩스번호 첫 번째 마디 | +| basic_info.fax_2 | body | string | 아니오 | max 4 | 대표 팩스번호 두 번째 마디 | +| basic_info.fax_3 | body | string | 아니오 | max 4 | 대표 팩스번호 세 번째 마디 | +| basic_info.email_id | body | string | 아니오 | max 100 | 대표 E-mail 의 아이디 부분 (`@` 앞) | +| basic_info.email_domain | body | string | 아니오 | max 100 | 대표 E-mail 의 도메인 부분 (`@` 뒤) | +| basic_info.privacy_officer | body | string | 아니오 | max 100 | 개인정보 보호책임자 이름 | +| basic_info.privacy_officer_email | body | email | 아니오 | max 255 | 개인정보 보호책임자 E-mail | +| basic_info.mail_order_number | body | string | 아니오 | max 100 | 통신판매업 신고번호 | +| basic_info.telecom_number | body | string | 아니오 | max 100 | 부가통신 사업자번호 | | language_currency | body | array | 아니오 | — | 통화 설정 섹션 (기본 통화·통화 목록: 코드·다국어명·환율·반올림 규칙·통화별 로케일) | -| language_currency.default_currency | body | string | 아니오 | max 10 | | -| language_currency.currencies | body | array | 아니오 | — | | +| language_currency.default_currency | body | string | 아니오 | max 10 | 쇼핑몰 기본(base) 통화 코드. 상품/주문이 1건이라도 생성된 뒤에는 변경 불가 | +| language_currency.currencies | body | array | 아니오 | — | 등록 통화 목록. 항목별 `code`(ISO 4217 3자리 대문자, 필수)·`name`(다국어 배열, 필수)·`symbol`·`exchange_rate`·`base_unit`·`rounding_unit`·`rounding_method`(`floor`\|`round`\|`ceil`)·`decimal_places`·`is_default`·`locales` | | seo | body | array | 아니오 | — | SEO 메타 설정 섹션 (페이지 유형별 메타 타이틀/설명·SEO 활성 토글) | -| seo.meta_category_title | body | string | 아니오 | max 500 | | -| seo.meta_category_description | body | string | 아니오 | max 1000 | | -| seo.meta_search_title | body | string | 아니오 | max 500 | | -| seo.meta_search_description | body | string | 아니오 | max 1000 | | -| seo.meta_product_title | body | string | 아니오 | max 500 | | -| seo.meta_product_description | body | string | 아니오 | max 1000 | | -| seo.meta_shop_index_title | body | string | 아니오 | max 500 | | -| seo.meta_shop_index_description | body | string | 아니오 | max 1000 | | -| seo.seo_category | body | boolean | 아니오 | — | | -| seo.seo_search_result | body | boolean | 아니오 | — | | -| seo.seo_product_detail | body | boolean | 아니오 | — | | -| seo.seo_shop_index | body | boolean | 아니오 | — | | +| seo.meta_category_title | body | string | 아니오 | max 500 | 카테고리 페이지 메타 Title (`{commerce_name}`·`{category_name}` 등 변수 사용 가능) | +| seo.meta_category_description | body | string | 아니오 | max 1000 | 카테고리 페이지 메타 Description | +| seo.meta_search_title | body | string | 아니오 | max 500 | 검색 결과 페이지 메타 Title (`{keyword_name}` 변수 사용 가능) | +| seo.meta_search_description | body | string | 아니오 | max 1000 | 검색 결과 페이지 메타 Description | +| seo.meta_product_title | body | string | 아니오 | max 500 | 상품 상세 페이지 메타 Title (`{product_name}` 변수 사용 가능) | +| seo.meta_product_description | body | string | 아니오 | max 1000 | 상품 상세 페이지 메타 Description | +| seo.meta_shop_index_title | body | string | 아니오 | max 500 | 쇼핑몰 메인 페이지 메타 Title | +| seo.meta_shop_index_description | body | string | 아니오 | max 1000 | 쇼핑몰 메인 페이지 메타 Description | +| seo.seo_category | body | boolean | 아니오 | — | 카테고리 페이지에 검색엔진 친화적 페이지 제공 여부 | +| seo.seo_search_result | body | boolean | 아니오 | — | 검색결과 페이지에 검색엔진 친화적 페이지 제공 여부 | +| seo.seo_product_detail | body | boolean | 아니오 | — | 상품 상세페이지에 검색엔진 친화적 페이지 제공 여부 | +| seo.seo_shop_index | body | boolean | 아니오 | — | 쇼핑몰 메인 페이지에 검색엔진 친화적 페이지 제공 여부 | | inquiry | body | array | 아니오 | — | 문의 연동 설정 섹션 (문의 게시판 slug) | -| inquiry.board_slug | body | string | 아니오 | max 255 | | +| inquiry.board_slug | body | string | 아니오 | max 255 | 상품 1:1 문의를 연동할 게시판 slug (게시판 모듈 활성화 시에만 선택 가능) | | order_settings | body | array | 아니오 | — | 주문/결제 설정 섹션 (기본 PG·결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) | -| order_settings.default_pg_provider | body | string | 아니오 | max 50 | | -| order_settings.cash_receipt_provider | body | string | 아니오 | max 50 | | -| order_settings.cash_receipt_self_issue | body | boolean | 아니오 | — | | -| order_settings.shipping_fee_tax_policy | body | string | 아니오 | `proportional`, `taxable`, `follow_main_item` | | -| order_settings.payment_methods | body | array | 아니오 | — | | -| order_settings.banks | body | array | 아니오 | — | | -| order_settings.bank_accounts | body | array | 아니오 | — | | -| order_settings.auto_cancel_expired | body | boolean | 아니오 | — | | -| order_settings.auto_cancel_days | body | integer | 아니오 | min 0, max 30 | | -| order_settings.cart_expiry_days | body | integer | 아니오 | min 1, max 365 | | -| order_settings.stock_restore_on_cancel | body | boolean | 아니오 | — | | -| order_settings.confirmable_statuses | body | array | 아니오 | — | | +| order_settings.default_pg_provider | body | string | 아니오 | max 50 | 결제 처리에 사용할 기본 PG사 ID (결제수단별 `pg_provider` 미지정 시 적용) | +| order_settings.cash_receipt_provider | body | string | 아니오 | max 50 | 현금영수증 발급 프로바이더 ID. 결제 PG 와 독립 선택하며 빈 문자열이면 미사용 | +| order_settings.cash_receipt_self_issue | body | boolean | 아니오 | — | 현금영수증 자진발급 사용 (구매자가 신청하지 않은 무통장 입금 건도 자동 발급) | +| order_settings.shipping_fee_tax_policy | body | string | 아니오 | `proportional`, `taxable`, `follow_main_item` | 현금영수증의 배송비 과세 방식 (안분 / 전액 과세 / 주된 재화 기준) | +| order_settings.payment_methods | body | array | 아니오 | — | 결제수단 목록. 항목별 `id`(필수)·`pg_provider`·`sort_order`(min 1)·`is_active`·`min_order_amount`(min 0)·`stock_deduction_timing`(`order_placed`\|`payment_complete`\|`none`)·`mileage_deduction_timing`(`order_placed`\|`payment_complete`). 최소 1개는 활성이어야 하며, PG 가 필요한 결제수단을 활성화하려면 PG사 지정 필수 | +| order_settings.banks | body | array | 아니오 | — | 무통장입금용 은행 목록. 항목별 `code`(max 10, 필수)·`name`(다국어 배열, 현재 로케일 필수) | +| order_settings.bank_accounts | body | array | 아니오 | — | 무통장 입금 계좌 목록. 항목별 `bank_code`·`account_number`·`account_holder`(모두 필수)·`is_active`·`is_default`. 계좌가 있으면 최소 1건은 사용+기본 상태여야 함 | +| order_settings.auto_cancel_expired | body | boolean | 아니오 | — | 입금대기 상태 주문의 자동취소 사용 여부 | +| order_settings.auto_cancel_days | body | integer | 아니오 | min 0, max 30 | 자동취소 기한(일). 주문일 포함 이 일수 경과 시 입금대기 주문을 자동 취소 | +| order_settings.cart_expiry_days | body | integer | 아니오 | min 1, max 365 | 장바구니 보관기간(일). 경과 시 담긴 상품 자동 삭제 | +| order_settings.stock_restore_on_cancel | body | boolean | 아니오 | — | 주문 취소 시 차감된 재고 자동 복구 여부 (반품/교환에도 적용) | +| order_settings.confirmable_statuses | body | array | 아니오 | — | 사용자가 구매확정할 수 있는 주문 옵션 상태 목록 (`payment_complete`, `shipping_hold`, `preparing`, `shipping_ready`, `shipping`, `delivered` 중 선택) | | claim | body | array | 아니오 | — | 클레임 설정 섹션 (환불 사유 목록, DB 동기화 대상으로 분리 저장) | -| claim.refund_reasons | body | array | 아니오 | — | | +| claim.refund_reasons | body | array | 아니오 | — | 환불 사유 목록. 항목별 `id`·`code`(소문자·언더스코어, 필수)·`name`(다국어 배열, 현재 로케일 필수)·`fault_type`(`customer`\|`seller`\|`carrier`, 필수)·`is_user_selectable`·`is_active`·`sort_order`. 코드 중복 불가 | | review_settings | body | array | 아니오 | — | 리뷰 정책 섹션 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) | -| review_settings.write_deadline_days | body | integer | 아니오 | min 1, max 365 | | -| review_settings.max_images | body | integer | 아니오 | min 0, max 20 | | -| review_settings.max_image_size_mb | body | integer | 아니오 | min 1, max 50 | | +| review_settings.write_deadline_days | body | integer | 아니오 | min 1, max 365 | 구매 확정 후 리뷰를 작성할 수 있는 기간(일) | +| review_settings.max_images | body | integer | 아니오 | min 0, max 20 | 리뷰 1건당 첨부 가능한 이미지 수 (0 = 이미지 첨부 불가) | +| review_settings.max_image_size_mb | body | integer | 아니오 | min 1, max 50 | 리뷰 이미지 파일 1개의 최대 용량(MB) | | mileage | body | array | 아니오 | — | 마일리지 설정 섹션 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림) | -| mileage.enabled | body | boolean | 아니오 | — | | -| mileage.default_earn_rate | body | number | 아니오 | min 0, max 100 | | -| mileage.earn_trigger | body | string | 아니오 | `delivered`, `confirmed` | | -| mileage.earn_delay_days | body | integer | 아니오 | min 0, max 365 | | -| mileage.currency_rules | body | array | 아니오 | — | | -| mileage.expiry_enabled | body | boolean | 아니오 | — | | -| mileage.expiry_days | body | integer | 아니오 | min 1, max 3650 | | -| mileage.expiry_notification_enabled | body | boolean | 아니오 | — | | -| mileage.expiry_notification_days_before | body | integer | 아니오 | min 1, max 365 | | +| mileage.enabled | body | boolean | 아니오 | — | 마일리지 적립·사용 기능 사용 여부 (`mileage` 전달 시 필수) | +| mileage.default_earn_rate | body | number | 아니오 | min 0, max 100 | 기본 적립률(%). 상품 등록 시 기본값으로 사용되며, `enabled: true` 인 경우 0 초과여야 함 | +| mileage.earn_trigger | body | string | 아니오 | `delivered`, `confirmed` | 마일리지 적립 시점 (배송완료 / 구매확정) | +| mileage.earn_delay_days | body | integer | 아니오 | min 0, max 365 | 적립 시점 이후 실제 적립까지의 지연일 | +| mileage.currency_rules | body | array | 아니오 | — | 통화별 사용 규칙. 항목별 `currency_code`(ISO 4217, 필수)·`point_value`(1점당 금액, min 0.001)·`min_use_amount`·`use_unit`(min 1)·`max_use_type`(`percent`\|`fixed`)·`max_use_percent`·`max_use_value`. 첫 행은 기본 통화여야 하고, 등록 통화만 허용하며 중복 불가 | +| mileage.expiry_enabled | body | boolean | 아니오 | — | 마일리지 유효기간 사용 여부 (경과 시 미사용 마일리지 자동 소멸) | +| mileage.expiry_days | body | integer | 아니오 | min 1, max 3650 | 마일리지 유효기간(일) | +| mileage.expiry_notification_enabled | body | boolean | 아니오 | — | 소멸 예정 알림 발송 여부 | +| mileage.expiry_notification_days_before | body | integer | 아니오 | min 1, max 365 | 유효기간 만료 며칠 전에 소멸 예정 알림을 발송할지 | | shipping | body | array | 아니오 | — | 배송 설정 섹션 (기본 국가·배송 가능 국가·무료배송·배송사(carriers)·배송유형(types) — carriers/types는 DB 동기화 대상으로 분리 저장) | -| shipping.default_country | body | string | 아니오 | max 10 | | -| shipping.available_countries | body | array | 아니오 | — | | -| shipping.international_shipping_enabled | body | boolean | 아니오 | — | | -| shipping.free_shipping_threshold | body | integer | 아니오 | min 0 | | -| shipping.free_shipping_enabled | body | boolean | 아니오 | — | | -| shipping.address_validation_enabled | body | boolean | 아니오 | — | | -| shipping.address_api_provider | body | string | 아니오 | max 50 | | -| shipping.carriers | body | array | 아니오 | — | | -| shipping.types | body | array | 아니오 | — | | +| shipping.default_country | body | string | 아니오 | max 10 | 기본 배송 국가 코드. 새 배송정책 등록 시 기본으로 추가되며, `available_countries` 에 존재해야 함 | +| shipping.available_countries | body | array | 아니오 | — | 배송 가능 국가 목록. 항목별 `code`(max 10, 필수)·`name`(다국어 배열, 필수)·`is_active`. 코드 중복 불가 | +| shipping.international_shipping_enabled | body | boolean | 아니오 | — | 해외배송 기능 사용 여부 | +| shipping.free_shipping_threshold | body | integer | 아니오 | min 0 | 무료배송 기준 금액 (이 금액 이상 주문 시 무료배송) | +| shipping.free_shipping_enabled | body | boolean | 아니오 | — | 무료배송 기준금액 적용 여부 | +| shipping.address_validation_enabled | body | boolean | 아니오 | — | 주소 검증(주소찾기 API) 사용 여부 | +| shipping.address_api_provider | body | string | 아니오 | max 50 | 주소찾기 API 제공자 식별자 (기본값 `kakao`) | +| shipping.carriers | body | array | 아니오 | — | 배송사 목록(DB 동기화 대상). 항목별 `id`·`code`(소문자/숫자/하이픈, 필수)·`name`(다국어 배열, 현재 로케일 필수)·`type`(`domestic`\|`international`, 필수)·`tracking_url`(`{tracking_number}` 치환)·`is_active`·`sort_order`. 코드 중복 불가 | +| shipping.types | body | array | 아니오 | — | 배송유형 목록(DB 동기화 대상). 항목별 `id`·`code`(소문자/숫자/하이픈, 필수)·`name`(다국어 배열, 현재 로케일 필수)·`category`(`domestic`\|`international`\|`other`, 필수)·`is_active`·`sort_order`. 코드 중복 불가 | **요청 예시** @@ -1331,11 +1331,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| cleared | boolean | `true` | 이커머스 설정 캐시 + SEO 렌더 캐시 초기화 성공 여부 (성공 시 항상 `true`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.cache_clear_success", + "data": { + "cleared": true + } +} +``` **에러 응답** @@ -1577,7 +1593,9 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 설정 조회 중 예외 발생 (`messages.settings.fetch_failed`) | @@ -1686,7 +1704,9 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 설정 조회 중 예외 발생 (`messages.settings.fetch_failed`) | @@ -1741,7 +1761,9 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 설정 조회 중 예외 발생 (`messages.settings.fetch_failed`) | @@ -1817,7 +1839,9 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 설정 조회 중 예외 발생 (`messages.settings.fetch_failed`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md index 2f9a62c7..85b0b7b8 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md @@ -188,11 +188,58 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ShippingCarrierResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `13` | 생성된 배송사의 기본 키 (내부 식별자) | +| code | string | `apidoc` | 배송사 고유 코드 (cj, fedex 등 시스템 식별용) | +| name | object | `{"ko":"API 문서 샘플 배송사","en":"API Doc Sample Carrier"}` | 다국어 배송사명 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 배송사` | `name` 의 현재 로케일 해석 값 (표시용 문자열) | +| type | string | `domestic` | 배송사 유형 (`domestic` 국내 / `international` 해외) | +| type_label | string | `국내` | `type` 값의 사람이 읽는 라벨 (`국내` / `국제`) | +| tracking_url | string\|null | `https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}` | 배송 추적 URL 템플릿 (`{tracking_number}` 치환자를 운송장 번호로 대체) | +| is_active | boolean | `true` | 활성 여부 (true 활성 / false 비활성) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| creator | object | `{"id":1,"name":"관리자"}` | 생성자 정보 (creator 관계 로드 시에만 포함) | +| updater | object | `{"id":1,"name":"관리자"}` | 최종 수정자 정보 (updater 관계 로드 시에만 포함) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (모두 `sirsoft-ecommerce.settings.update` 권한 기준) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "배송사가 등록되었습니다.", + "data": { + "id": 13, + "code": "apidoc", + "name": { + "ko": "API 문서 샘플 배송사", + "en": "API Doc Sample Carrier" + }, + "localized_name": "API 문서 샘플 배송사", + "type": "domestic", + "type_label": "국내", + "tracking_url": null, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -477,11 +524,58 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ShippingCarrierResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `13` | 수정된 배송사의 기본 키 (내부 식별자) | +| code | string | `apidoc` | 배송사 고유 코드 (cj, fedex 등 시스템 식별용) | +| name | object | `{"ko":"API 문서 샘플 배송사(수정)","en":"API Doc Sample Carrier"}` | 다국어 배송사명 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 배송사(수정)` | `name` 의 현재 로케일 해석 값 (표시용 문자열) | +| type | string | `domestic` | 배송사 유형 (`domestic` 국내 / `international` 해외) | +| type_label | string | `국내` | `type` 값의 사람이 읽는 라벨 (`국내` / `국제`) | +| tracking_url | string\|null | `https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}` | 배송 추적 URL 템플릿 (`{tracking_number}` 치환자를 운송장 번호로 대체) | +| is_active | boolean | `true` | 활성 여부 (true 활성 / false 비활성) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 15:12:07` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| creator | object | `{"id":1,"name":"관리자"}` | 생성자 정보 (creator 관계 로드 시에만 포함) | +| updater | object | `{"id":1,"name":"관리자"}` | 최종 수정자 정보 (updater 관계 로드 시에만 포함) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (모두 `sirsoft-ecommerce.settings.update` 권한 기준) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송사가 수정되었습니다.", + "data": { + "id": 13, + "code": "apidoc", + "name": { + "ko": "API 문서 샘플 배송사(수정)", + "en": "API Doc Sample Carrier" + }, + "localized_name": "API 문서 샘플 배송사(수정)", + "type": "domestic", + "type_label": "국내", + "tracking_url": null, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:12:07", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md index 0b0c7d05..849ff4d1 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md @@ -223,11 +223,55 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ShippingPolicyResource`). 생성 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"기본 배송정책","en":"Default Shipping Policy"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `기본 배송정책` | `name` 의 현재 로케일 해석 값 | +| country_settings | array | `[]` | 국가별 배송 설정 목록 (`countrySettings` 관계가 로드된 경우에만 포함) | +| fee_summary | string | `KR: 배송비: 3,000원` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (활성 설정 없으면 빈 문자열) | +| countries_display | string | `🇰🇷` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개, 초과분은 `+N` 축약) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `false` | default 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "배송정책이 등록되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "기본 배송정책", + "en": "Default Shipping Policy" + }, + "name_localized": "기본 배송정책", + "fee_summary": "KR: 배송비: 3,000원", + "countries_display": "🇰🇷", + "is_active": true, + "is_default": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -236,6 +280,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 생성 처리 중 예외 발생 (`exceptions.operation_failed`) | @@ -421,16 +466,35 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `2` | 실제로 삭제된 배송정책 건수 (`ShippingPolicyService::bulkDelete()` 반환값) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 배송정책이 삭제되었습니다.", + "data": { + "deleted_count": 2 + } +} +``` + +> 컨트롤러가 `messageParams` 를 넘기지 않으므로 `message` 의 `:count` 자리표시자는 치환되지 않은 채로 내려옵니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 일괄 삭제 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -474,16 +538,35 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `2` | 활성 상태가 실제로 변경된 배송정책 건수 (`ShippingPolicyService::bulkToggleActive()` 반환값) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 배송정책의 사용여부가 변경되었습니다.", + "data": { + "updated_count": 2 + } +} +``` + +> 컨트롤러가 `messageParams` 를 넘기지 않으므로 `message` 의 `:count` 자리표시자는 치환되지 않은 채로 내려옵니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 일괄 변경 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -506,17 +589,17 @@ Content-Type: application/json | endpoint | body | string | 예 | max 500 | 테스트로 호출할 외부 배송비 계산 API 엔드포인트 URL. 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)와 userinfo(`https://a@b/`) 위장 주소는 422 로 거부됩니다 — 이 주소는 쇼핑몰 서버가 대신 호출하므로 내부망 접근을 막기 위함(SSRF). 사내 배송비 계산 서버를 쓰려면 코어 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요 | | request_fields | body | array | 아니오 | — | 요청에 실어 보낼 필드명 목록 (후보 SSoT ShippingApiRequestField 5종) | | config | body | array | 아니오 | — | API 호출 고급 설정 (HTTP 메서드·인증방식·필드 매핑·응답 형식/경로 등) | -| config.http_method | body | string | 아니오 | — | | -| config.auth_type | body | string | 아니오 | — | | -| config.auth_token | body | string | 아니오 | max 1000 | | -| config.auth_header_name | body | string | 아니오 | max 100 | config.auth header 이름 (식별자) | -| config.response_type | body | string | 아니오 | — | | -| config.response_path | body | string | 아니오 | max 200 | | -| config.field_map | body | array | 아니오 | — | | +| config.http_method | body | string | 아니오 | — | 외부 API 호출에 사용할 HTTP 메서드 (GET=query string bracket 표기, POST=JSON body. 미지정 시 POST) | +| config.auth_type | body | string | 아니오 | — | 인증 헤더 부착 방식 (none=인증 없음, bearer=`Authorization: Bearer {token}`, custom_header=커스텀 헤더명 + 값) | +| config.auth_token | body | string | 아니오 | max 1000 | 인증에 사용할 토큰/키 값 (`auth_type` 이 bearer/custom_header 일 때 헤더 값으로 전송) | +| config.auth_header_name | body | string | 아니오 | max 100 | `auth_type` 이 `custom_header` 일 때 토큰을 실어 보낼 헤더 이름 (예: `X-API-Key`. HTTP 헤더 토큰 문자만 허용) | +| config.response_type | body | string | 아니오 | — | 외부 API 응답에서 배송비를 추출하는 방식 (json=`response_path` 점표기 경로로 중첩 값 추출, text=본문에서 숫자/소수점만 추출) | +| config.response_path | body | string | 아니오 | max 200 | JSON 응답에서 배송비 값이 위치한 점표기 경로 (예: `data.shipping_fee`) | +| config.field_map | body | array | 아니오 | — | 요청 필드명 재매핑 표 (내부 필드명 → 외부 API 가 요구하는 파라미터명. 값은 영숫자/`_`/`.`/`-` 만 허용) | | sample | body | array | 아니오 | — | 테스트 계산에 사용할 샘플 주문 데이터 (무게/금액/수량 등) | -| sample.group_total | body | number | 아니오 | min 0 | | -| sample.total_quantity | body | integer | 아니오 | min 0 | | -| sample.country_code | body | string | 아니오 | max 10 | | +| sample.group_total | body | number | 아니오 | min 0 | 테스트용 배송 그룹 합계 금액 (미지정 시 `10000`) | +| sample.total_quantity | body | integer | 아니오 | min 0 | 테스트용 배송 그룹 총 수량 (미지정 시 `1`) | +| sample.country_code | body | string | 아니오 | max 10 | 국가 코드 (ISO 3166-1 alpha-2. 미지정 시 `KR`) | **요청 예시** @@ -555,11 +638,55 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`OrderCalculationService::testApiCall()` 반환 배열). 외부 API 호출 실패도 HTTP 200 으로 내려오며 `ok=false` 로 구분합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| ok | boolean | `true` | 테스트 호출 성공 여부 (외부 API 가 2xx 응답을 준 경우 true. 무응답/요청 실패 시 false) | +| http_ok | boolean | `true` | 외부 API 응답이 2xx 인지 여부 (응답을 받은 경우에만 포함) | +| reason | string | `no_response`, `request_failed` | 실패 사유 코드 (`ok=false` 인 경우에만 포함. no_response=응답 없음, request_failed=연결 실패/타임아웃) | +| error | string | `cURL error 28: Operation timed out` | 예외 메시지 (`reason=request_failed` 인 경우에만 포함) | +| request | object | `{"method":"POST","endpoint":"https://…","data":{…},"body":"{…}"}` | 실제로 보낸 요청 미리보기 (성공/실패 무관하게 항상 포함) | +| request.method | string | `POST` | 실제 사용된 HTTP 메서드 (`config.http_method` 대문자, 미지정 시 `POST`) | +| request.endpoint | string | `https://api.example.com/shipping/quote` | 호출한 외부 API URL | +| request.data | object | `{"country_code":"KR","group_total":10000,"total_quantity":1}` | `request_fields` + `field_map` 을 적용해 구성된 요청 파라미터 | +| request.body | string | `{\n "country_code": "KR"\n}` | `request.data` 를 pretty-print JSON 문자열로 직렬화한 요청 본문 미리보기 | +| response | object | `{"status":200,"body":"{\"fee\":3000}"}` | 외부 API 응답 (응답을 받은 경우에만 포함) | +| response.status | integer | `200` | 외부 API 응답 HTTP 상태코드 | +| response.body | string | `{"fee":3000}` | 외부 API 응답 본문 (과대 응답 방어를 위해 앞 4096자로 잘림) | +| extracted_fee | integer\|null | `3000` | 응답에서 추출된 배송비 (`response_type`/`response_path` 로 추출 실패 시 `null`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "계산 API 테스트 호출을 완료했습니다.", + "data": { + "ok": true, + "http_ok": true, + "request": { + "method": "POST", + "endpoint": "https://api.example.com/shipping/quote", + "data": { + "country_code": "KR", + "group_total": 10000, + "total_quantity": 1 + }, + "body": "{\n \"country_code\": \"KR\",\n \"group_total\": 10000,\n \"total_quantity\": 1\n}" + }, + "response": { + "status": 200, + "body": "{\"fee\":3000}" + }, + "extracted_fee": 3000 + } +} +``` **에러 응답** @@ -597,7 +724,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `ResponseHelper::moduleSuccess()` 에 데이터를 넘기지 않아 `data` 는 `null`)._ **응답 예시** @@ -607,6 +734,7 @@ Authorization: Bearer {YOUR_TOKEN} | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 삭제 처리 중 예외 발생 (사용 중인 정책 등 — `exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | @@ -701,11 +829,55 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ShippingPolicyResource` — show 와 동일 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"기본 배송정책","en":"Default Shipping Policy"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `기본 배송정책` | `name` 의 현재 로케일 해석 값 | +| country_settings | array | `[]` | 국가별 배송 설정 목록 (`countrySettings` 관계가 로드된 경우에만 포함) | +| fee_summary | string | `KR: 배송비: 3,000원` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (활성 설정 없으면 빈 문자열) | +| countries_display | string | `🇰🇷` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개, 초과분은 `+N` 축약) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `false` | default 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:35` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송정책이 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "기본 배송정책", + "en": "Default Shipping Policy" + }, + "name_localized": "기본 배송정책", + "fee_summary": "KR: 배송비: 3,000원", + "countries_display": "🇰🇷", + "is_active": true, + "is_default": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:35", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -715,6 +887,7 @@ Content-Type: application/json | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 수정 처리 중 예외 발생 (`exceptions.operation_failed`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/users.md b/modules/_bundled/sirsoft-ecommerce/docs/api/users.md index c64c2bbd..0a198729 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/users.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/users.md @@ -46,11 +46,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| preferred_currency | string | `KRW` | 변경 후 저장된 회원 선호 결제 통화 코드 (ISO 4217, 등록 통화 중 하나) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "결제 통화가 변경되었습니다.", + "data": { + "preferred_currency": "KRW" + } +} +``` **에러 응답** @@ -59,7 +75,8 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-currency.manage`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `currency` 미전달 또는 등록 통화(is_default \| exchange_rate>0)가 아닌 코드 전달 시 | +| 500 | Internal Server Error | 통화 저장 처리 중 예외 발생 시 (`결제 통화 변경에 실패했습니다.`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md index 5f9ad1ff..bfd0fd17 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md @@ -45,10 +45,65 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `2` | 기본 키 (내부 식별자) | -| product_id | integer | `1732` | product 식별자 (연관 리소스 참조) | -| created_at | string | `2026-07-31T12:45:03.000000Z` | 생성 일시 | -| product | object | `{"id":1732,"name":{"ko":"면 손수건 3매입 #1","en":"Cotton Handk…` | | +| id | integer | `1` | 찜(위시리스트) 레코드 식별자 (DELETE `/wishlist/{id}` 의 `id`) | +| product_id | integer | `10` | 찜한 상품의 식별자 | +| created_at | string | `2026-07-14T09:12:33.000000Z` | 찜한 일시 (목록은 최신순 정렬) | +| product | object | `{ "id": 10, ... }` | 찜한 상품 정보 (`ProductListResource` 형식, 아래 하위 필드) | + +`product` 하위 필드 (찜 목록에서는 상품의 브랜드·카테고리·라벨 관계만 로드되므로 옵션/배송정책 관련 필드는 응답에 포함되지 않습니다): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `10` | 상품 식별자 | +| name | object | `{"ko":"상품명","en":"Product"}` | 상품명 다국어 원본 | +| name_localized | string | `상품명` | 현재 로케일로 해석된 상품명 | +| product_code | string | `P0000001` | 상품 코드 | +| sku | string \| null | `SKU-001` | 재고 관리 코드 | +| thumbnail_url | string \| null | `/storage/products/thumb.jpg` | 대표 썸네일 이미지 URL | +| list_price | number | `30000` | 정가 (기본 통화 기준 반올림) | +| list_price_formatted | string | `30,000원` | 정가 표시용 포맷 문자열 | +| selling_price | number | `24000` | 판매가 (기본 통화 기준 반올림) | +| selling_price_formatted | string | `24,000원` | 판매가 표시용 포맷 문자열 | +| discount_rate | integer | `20` | 정가 대비 할인율(%) | +| multi_currency_list_price | object | `{"KRW":30000,"USD":22.5}` | 통화별 정가 | +| multi_currency_selling_price | object | `{"KRW":24000,"USD":18}` | 통화별 판매가 | +| stock_quantity | integer | `100` | 재고 수량 | +| safe_stock_quantity | integer | `10` | 안전 재고 수량 | +| is_below_safe_stock | boolean | `false` | 재고가 안전 재고 미만인지 여부 | +| sales_status | string | `on_sale` | 판매 상태 값 (`on_sale`/`sold_out`/`stop_sale` 등) | +| sales_status_label | string | `판매중` | 판매 상태 라벨 (다국어) | +| sales_status_variant | string | `success` | 판매 상태 배지 색상 변형 | +| display_status | string | `visible` | 진열 상태 값 | +| display_status_label | string | `진열` | 진열 상태 라벨 (다국어) | +| display_status_variant | string | `success` | 진열 상태 배지 색상 변형 | +| categories | array | `[{"id":3,"name":"의류","is_primary":true}]` | 소속 카테고리 목록 (id/name/is_primary) | +| primary_category | string \| null | `의류` | 대표 카테고리명 | +| categories_with_path | array | `[{"id":3,"path":[...],"path_string":"패션 > 의류","is_primary":true}]` | 카테고리 경로(브레드크럼) 포함 목록 | +| brand_name | string \| null | `나이키` | 브랜드명 (현재 로케일) | +| shipping_policy_id | integer \| null | `1` | 배송 정책 식별자 | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer \| null | `10` | 최대 구매 수량 (없으면 `null`) | +| has_options | boolean | `true` | 옵션 보유 여부 | +| options | array | `[]` | 옵션 목록 (찜 목록에서는 옵션 관계 미로드로 빈 배열) | +| labels | array | `[{"name":"NEW","color":"#ff0000"}]` | 활성 라벨 목록 (정렬순) | +| review_count | integer | `0` | 리뷰 수 (리뷰 집계 미로드 시 `0`) | +| rating_avg | number | `0.0` | 평균 평점 (리뷰 집계 미로드 시 `0.0`) | +| created_at | string | `2026-07-01 10:00:00` | 상품 등록 일시 (사용자 타임존 기준) | +| updated_at | string | `2026-07-10 15:30:00` | 상품 수정 일시 (사용자 타임존 기준) | +| is_owner | boolean | `false` | 요청자가 상품 등록자(`created_by`)인지 여부 | +| abilities | object | `{"can_update":false,"can_delete":false}` | 상품 수정/삭제 권한 (권한 없으면 키 자체가 생략될 수 있음) | + +`data.pagination` 필드: + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| current_page | integer | `1` | 현재 페이지 번호 | +| last_page | integer | `1` | 마지막 페이지 번호 | +| per_page | integer | `20` | 페이지당 항목 수 (기본 20, 최대 100) | +| total | integer | `0` | 전체 찜 항목 수 | +| from | integer \| null | `null` | 현재 페이지 첫 항목의 순번 (없으면 `null`) | +| to | integer \| null | `null` | 현재 페이지 마지막 항목의 순번 (없으면 `null`) | +| has_more_pages | boolean | `false` | 다음 페이지 존재 여부 | **응답 예시** @@ -198,11 +253,21 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품이 찜 목록에서 제거되었습니다.", + "data": null +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php index 39bbab64..f418aeea 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php @@ -22,12 +22,6 @@ return [ 'status_changed' => 'Category status has been changed.', 'order_updated' => 'Category order has been changed.', ], - 'category_images' => [ - 'uploaded' => 'Category image has been uploaded.', - 'not_found' => 'Category image not found.', - 'deleted' => 'Category image has been deleted.', - 'reordered' => 'Category image order has been changed.', - ], 'settings' => [ 'fetch_success' => 'Settings retrieved successfully.', 'fetch_failed' => 'Failed to retrieve settings.', @@ -90,6 +84,12 @@ return [ 'thumbnail_set' => 'Thumbnail has been set.', 'not_found' => 'Product image not found.', ], + 'category_images' => [ + 'uploaded' => 'Image has been uploaded.', + 'deleted' => 'Image has been deleted.', + 'reordered' => 'Image order has been changed.', + 'not_found' => 'Category image not found.', + ], 'brands' => [ 'list_retrieved' => 'Brand list retrieved successfully.', 'retrieved' => 'Brand information retrieved successfully.', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php index e789ca36..ecd2ba67 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php @@ -22,12 +22,6 @@ return [ 'status_changed' => '카테고리 상태가 변경되었습니다.', 'order_updated' => '카테고리 순서가 변경되었습니다.', ], - 'category_images' => [ - 'uploaded' => '카테고리 이미지가 업로드되었습니다.', - 'not_found' => '카테고리 이미지를 찾을 수 없습니다.', - 'deleted' => '카테고리 이미지가 삭제되었습니다.', - 'reordered' => '카테고리 이미지 순서가 변경되었습니다.', - ], 'settings' => [ 'fetch_success' => '설정을 조회했습니다.', 'fetch_failed' => '설정 조회에 실패했습니다.', @@ -90,6 +84,12 @@ return [ 'thumbnail_set' => '대표 이미지가 설정되었습니다.', 'not_found' => '상품 이미지를 찾을 수 없습니다.', ], + 'category_images' => [ + 'uploaded' => '이미지가 업로드되었습니다.', + 'deleted' => '이미지가 삭제되었습니다.', + 'reordered' => '이미지 순서가 변경되었습니다.', + 'not_found' => '카테고리 이미지를 찾을 수 없습니다.', + ], 'brands' => [ 'list_retrieved' => '브랜드 목록을 조회했습니다.', 'retrieved' => '브랜드 정보를 조회했습니다.', diff --git a/modules/_bundled/sirsoft-page/docs/api/attachments.md b/modules/_bundled/sirsoft-page/docs/api/attachments.md index 810bd664..683e0cd7 100644 --- a/modules/_bundled/sirsoft-page/docs/api/attachments.md +++ b/modules/_bundled/sirsoft-page/docs/api/attachments.md @@ -63,11 +63,45 @@ Content-Disposition: form-data; name="temp_key" **응답 필드** (`data` 내부) - +_단건 응답: FileUploader 컴포넌트 규약에 맞춰 `data.data` 객체에 업로드된 첨부 1건(`PageAttachmentResource`)이 담깁니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| data.id | integer | `1` | 첨부파일 ID (기본 키) | +| data.hash | string | `"a1b2c3d4e5f6"` | URL용 고유 해시 (12자, 생성 시 자동 부여). 공개 다운로드/미리보기 라우트의 식별자 | +| data.original_filename | string | `"example.pdf"` | 업로드된 원본 파일명 | +| data.mime_type | string | `"application/pdf"` | MIME 타입 (예: `image/jpeg`, `application/pdf`) | +| data.size | integer | `102400` | 파일 크기 (바이트) | +| data.collection | string | `"attachments"` | 첨부 컬렉션 그룹명 (미지정 시 `attachments`) | +| data.order | integer | `0` | 정렬 순서 (0 이상) | +| data.is_image | boolean | `false` | 이미지 파일 여부 (`mime_type` 이 `image/` 로 시작하는지) | +| data.download_url | string | `"/api/modules/sirsoft-page/pages/attachment/a1b2c3d4e5f6"` | 공개 hash 다운로드 URL | +| data.preview_url | string\|null | `null` | 공개 hash 이미지 미리보기 URL (이미지가 아니면 `null`) | +| data.created_at | string\|null | `"2026-07-14 10:30:00"` | 업로드 일시 (사용자 타임존 기준 포맷) | **응답 예시** - +```json +{ + "success": true, + "message": "첨부파일이 업로드되었습니다.", + "data": { + "data": { + "id": 1, + "hash": "a1b2c3d4e5f6", + "original_filename": "example.pdf", + "mime_type": "application/pdf", + "size": 102400, + "collection": "attachments", + "order": 0, + "is_image": false, + "download_url": "/api/modules/sirsoft-page/pages/attachment/a1b2c3d4e5f6", + "preview_url": null, + "created_at": "2026-07-14 10:30:00" + } + } +} +``` **에러 응답** @@ -75,7 +109,8 @@ Content-Disposition: form-data; name="temp_key" | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-page.pages.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 파일 누락/파일 아님/10MB 초과/허용되지 않은 확장자(`jpg,jpeg,png,gif,webp,pdf,zip,doc,docx,xls,xlsx,ppt,pptx,hwp,txt` 외) | +| 500 | Internal Server Error | 저장 중 예외 발생 시 `첨부파일 업로드에 실패했습니다.` (`error` 에 예외 메시지) | @@ -114,11 +149,17 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만, `data` 는 `null`)._ **응답 예시** - +```json +{ + "success": true, + "message": "첨부파일 순서가 변경되었습니다.", + "data": null +} +``` **에러 응답** @@ -126,7 +167,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `order` 누락/배열 아님/빈 배열, 각 원소의 `id`(정수) 또는 `order`(0 이상 정수) 누락 | +| 500 | Internal Server Error | 순서 갱신 중 예외 발생 시 `첨부파일 순서 변경에 실패했습니다.` (`error` 에 예외 메시지) | @@ -156,11 +198,17 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만, `data` 는 `null`)._ **응답 예시** - +```json +{ + "success": true, + "message": "첨부파일이 삭제되었습니다.", + "data": null +} +``` **에러 응답** @@ -168,7 +216,8 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | `{id}` 에 해당하는 첨부파일이 없는 경우 (`첨부파일을 찾을 수 없습니다.`) | +| 500 | Internal Server Error | 삭제 실패 또는 삭제 중 예외 발생 시 `첨부파일 삭제에 실패했습니다.` | diff --git a/modules/_bundled/sirsoft-page/docs/api/pages.md b/modules/_bundled/sirsoft-page/docs/api/pages.md index c9736baf..b82bacc2 100644 --- a/modules/_bundled/sirsoft-page/docs/api/pages.md +++ b/modules/_bundled/sirsoft-page/docs/api/pages.md @@ -158,9 +158,9 @@ HTTP/1.1 200 | content_mode | body | string | 아니오 | `html`, `text` | 본문 편집 모드. `html` 은 리치 에디터 HTML, `text` 는 평문으로 저장·렌더링됩니다 (미지정 시 `html`) | | published | body | boolean | 아니오 | — | 발행 여부 (발행된 항목만 필터) | | seo_meta | body | array | 아니오 | — | SEO 메타 정보 맵. 하위 키 `title`(max 255)·`description`(max 500)·`keywords`(max 500)를 담습니다 | -| seo_meta.title | body | string | 아니오 | max 255 | | -| seo_meta.description | body | string | 아니오 | max 500 | | -| seo_meta.keywords | body | string | 아니오 | max 500 | | +| seo_meta.title | body | string | 아니오 | max 255 | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목) | +| seo_meta.description | body | string | 아니오 | max 500 | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) | +| seo_meta.keywords | body | string | 아니오 | max 500 | SEO 메타 키워드 (검색엔진 노출 키워드, 쉼표 구분) | | temp_key | body | string | 아니오 | max 64 | 저장 전 첨부 업로드 시 발급받은 임시 키. 생성된 페이지에 임시 첨부를 귀속시키는 데 사용합니다 | **요청 예시** @@ -192,11 +192,77 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`PageResource`, HTTP 201)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `11` | 기본 키 (내부 식별자) | +| slug | string | `example-key` | URL 슬러그 (고유, 소문자·숫자·하이픈) | +| title | object | `{"ko":"예시 제목","en":"Example Title"}` | 페이지 제목 (다국어 JSON) | +| content | object | `{"ko":"

예시 내용입니다.

"}` | 페이지 본문 (다국어 JSON, 미입력 시 null) | +| content_mode | string | `html` | 본문 형식 (`html`, `text` — 미지정 시 `html`) | +| published | boolean | `true` | 발행 여부 (true: 발행, false: 미발행) | +| published_at | string\|null | `2026-07-08 10:51:59` | 발행 일시 (미발행 시 null) | +| seo_meta | object\|null | `{"title":"...","description":"...","keywords":"..."}` | SEO 메타 정보 (title, description, keywords) | +| current_version | integer | `1` | 현재 버전 번호 | +| creator | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"관리자"}` | 생성자 정보 (uuid/name — creator 관계 eager load) | +| updater | object\|null | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"관리자"}` | 최종 수정자 정보 (uuid/name — updater 관계 eager load) | +| attachments | array | `[]` | 페이지에 귀속된 첨부파일 목록 (`temp_key` 로 귀속된 임시 첨부 포함) | +| created_at | string | `2026-07-08 10:51:59` | 생성 일시 | +| updated_at | string | `2026-07-08 10:51:59` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자(`created_by`)인지 여부 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "페이지가 생성되었습니다.", + "data": { + "id": 11, + "slug": "example-key", + "title": { + "ko": "예시 제목", + "en": "Example Title" + }, + "content": { + "ko": "

예시 내용입니다.

", + "en": "

Example body.

" + }, + "content_mode": "html", + "published": true, + "published_at": "2026-07-08 10:51:59", + "seo_meta": { + "title": "예시 제목", + "description": "예시 내용입니다.", + "keywords": "예시,키워드" + }, + "current_version": 1, + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "updater": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "attachments": [], + "created_at": "2026-07-08 10:51:59", + "updated_at": "2026-07-08 10:51:59", + "is_owner": true, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -204,7 +270,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-page.pages.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 대표 케이스: `slug` 중복/형식 위반(소문자·숫자·하이픈만), `title` 누락 또는 기본 로케일 값 누락, `content_mode` 가 html/text 이외 | +| 500 | Internal Server Error | 생성 처리 중 예외 (`페이지 생성에 실패했습니다.`) | @@ -243,11 +310,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| count | integer | `3` | 실제로 발행 상태가 변경된 페이지 건수 (메시지의 `:count` 에도 동일 값이 치환됨) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "3개 페이지의 발행 상태가 변경되었습니다.", + "data": { + "count": 3 + } +} +``` **에러 응답** @@ -255,7 +338,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 대표 케이스: `ids` 누락/빈 배열, `ids.*` 가 존재하지 않는 페이지 ID, `published` 누락 또는 boolean 아님 | +| 500 | Internal Server Error | 일괄 처리 중 예외 (`일괄 발행 상태 변경에 실패했습니다.`) | @@ -292,11 +376,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| exists | boolean | `false` | 해당 슬러그가 이미 사용 중인지 여부 (true: 중복 — 사용 불가, false: 사용 가능). `exclude_id` 를 넘기면 그 페이지는 중복 검사에서 제외 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "슬러그 중복 확인이 완료되었습니다.", + "data": { + "exists": false + } +} +``` **에러 응답** @@ -304,7 +404,7 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-page.pages.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 대표 케이스: `slug` 누락, `exclude_id` 가 정수가 아님 | @@ -334,7 +434,7 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** @@ -502,9 +602,9 @@ HTTP/1.1 200 | content_mode | body | string | 아니오 | `html`, `text` | 본문 편집 모드. `html` 은 리치 에디터 HTML, `text` 는 평문으로 저장·렌더링됩니다 (미지정 시 `html`) | | published | body | boolean | 아니오 | — | 발행 여부 (발행된 항목만 필터) | | seo_meta | body | array | 아니오 | — | SEO 메타 정보 맵. 하위 키 `title`(max 255)·`description`(max 500)·`keywords`(max 500)를 담습니다 | -| seo_meta.title | body | string | 아니오 | max 255 | | -| seo_meta.description | body | string | 아니오 | max 500 | | -| seo_meta.keywords | body | string | 아니오 | max 500 | | +| seo_meta.title | body | string | 아니오 | max 255 | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목) | +| seo_meta.description | body | string | 아니오 | max 500 | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) | +| seo_meta.keywords | body | string | 아니오 | max 500 | SEO 메타 키워드 (검색엔진 노출 키워드, 쉼표 구분) | | temp_key | body | string | 아니오 | max 64 | 저장 전 첨부 업로드 시 발급받은 임시 키. 새로 업로드한 첨부를 이 페이지에 귀속시키는 데 사용합니다 | **요청 예시** @@ -536,20 +636,87 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`PageResource` — `show` 와 동일 shape)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `10` | 기본 키 (내부 식별자) | +| slug | string | `apidoc-sample-page` | URL 슬러그 (고유) | +| title | object | `{"ko":"API 문서 샘플 페이지","en":"API Doc Sample Page"}` | 페이지 제목 (다국어 JSON) | +| content | object | `{"ko":"

수정된 본문.

"}` | 페이지 본문 (다국어 JSON) | +| content_mode | string | `html` | 본문 형식 (`html`, `text`) | +| published | boolean | `true` | 발행 여부 (true: 발행, false: 미발행) | +| published_at | string\|null | `2026-07-08 10:51:59` | 발행 일시 (미발행 시 null) | +| seo_meta | object\|null | `{"title":"...","description":"...","keywords":"..."}` | SEO 메타 정보 (title, description, keywords) | +| current_version | integer | `3` | 현재 버전 번호 (수정 시 이전 상태가 버전 이력으로 적재되며 증가) | +| creator | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"관리자"}` | 생성자 정보 (uuid/name — creator 관계 eager load) | +| updater | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"관리자"}` | 최종 수정자 정보 (uuid/name — updater 관계 eager load) | +| attachments | array | `[{"id":4,"hash":"ninrdtsmhhta","original_filename":"nesciunt.jpg", ...}]` | 페이지에 귀속된 첨부파일 목록 (`PageAttachmentResource` 배열) | +| created_at | string | `2026-07-08 10:51:59` | 생성 일시 | +| updated_at | string | `2026-07-08 15:03:15` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자(`created_by`)인지 여부 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "페이지가 수정되었습니다.", + "data": { + "id": 10, + "slug": "apidoc-sample-page", + "title": { + "ko": "API 문서 샘플 페이지", + "en": "API Doc Sample Page" + }, + "content": { + "ko": "

수정된 본문.

", + "en": "

Revised body.

" + }, + "content_mode": "html", + "published": true, + "published_at": "2026-07-08 10:51:59", + "seo_meta": { + "title": "Dolorum et aut officia ipsam doloribus inventore.", + "description": "Sint ipsa impedit enim natus tempore quasi dignissimos praesentium numquam rerum tempore nam.", + "keywords": "illo,pariatur,quis,necessitatibus,aperiam" + }, + "current_version": 3, + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "updater": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "attachments": [], + "created_at": "2026-07-08 10:51:59", + "updated_at": "2026-07-08 15:03:15", + "is_owner": true, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없거나, 스코프 밖 리소스인 경우 (`auth.scope_denied`) | +| 404 | Not Found | `{page}` ID 에 해당하는 페이지가 없는 경우 (`페이지를 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 대표 케이스: `title` 누락, `slug` 중복(자기 자신은 제외)/형식 위반, `content_mode` 가 html/text 이외 | +| 500 | Internal Server Error | 수정 처리 중 예외 (`페이지 수정에 실패했습니다.`) | @@ -789,19 +956,63 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`PageVersionResource` — `versions.index` 항목과 동일 shape)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `14` | 버전 레코드의 기본 키 (내부 식별자) | +| page_id | integer | `10` | 이 버전이 속한 페이지의 ID | +| version | integer | `2` | 버전 순번 (1부터 증가, 페이지 수정 시마다 채번) | +| title | object | `{"ko":"API 문서 샘플 페이지 (v2)","en":"API Doc Sample Page (v2)"}` | 그 시점의 페이지 제목 스냅샷 (다국어 JSON) | +| content | object | `{"ko":"

수정 버전 본문.

"}` | 그 시점의 페이지 본문 스냅샷 (다국어 JSON) | +| content_mode | string | `html` | 그 시점의 본문 형식 (`html`, `text` — 미지정 시 `html`) | +| seo_meta | object\|null | `null` | 그 시점의 SEO 메타 스냅샷 (title, description, keywords) | +| changes_summary | string\|null | `본문 보강` | 이 버전에서 무엇이 바뀌었는지 요약한 변경 설명 (없으면 null) | +| creator | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"관리자"}` | 이 버전을 만든 사용자 정보 (uuid/name — creator 관계 eager load) | +| created_at | string | `2026-07-08 10:51:59` | 버전 생성 일시 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "페이지 정보를 조회했습니다.", + "data": { + "id": 14, + "page_id": 10, + "version": 2, + "title": { + "ko": "API 문서 샘플 페이지 (v2)", + "en": "API Doc Sample Page (v2)" + }, + "content": { + "ko": "

수정 버전 본문.

", + "en": "

Revised version body.

" + }, + "content_mode": "html", + "seo_meta": null, + "changes_summary": "본문 보강", + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "created_at": "2026-07-08 10:51:59" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.read`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.read`)이 없거나, 스코프 밖 리소스인 경우 (`auth.scope_denied`) | +| 404 | Not Found | `{page}` 페이지 또는 `{versionId}` 버전이 없는 경우 (`페이지를 찾을 수 없습니다.`) | +| 500 | Internal Server Error | 조회 처리 중 예외 (`페이지 정보 조회에 실패했습니다.`) | @@ -832,19 +1043,82 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (복원된 페이지의 `PageResource` — `show` 와 동일 shape)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `10` | 기본 키 (내부 식별자) | +| slug | string | `apidoc-sample-page` | URL 슬러그 (고유) | +| title | object | `{"ko":"API 문서 샘플 페이지 (v1)","en":"API Doc Sample Page (v1)"}` | 복원된 페이지 제목 (다국어 JSON — 대상 버전의 스냅샷) | +| content | object | `{"ko":"

초기 버전 본문.

"}` | 복원된 페이지 본문 (다국어 JSON — 대상 버전의 스냅샷) | +| content_mode | string | `html` | 본문 형식 (`html`, `text`) | +| published | boolean | `true` | 발행 여부 (true: 발행, false: 미발행) | +| published_at | string\|null | `2026-07-08 10:51:59` | 발행 일시 (미발행 시 null) | +| seo_meta | object\|null | `null` | SEO 메타 정보 (title, description, keywords — 대상 버전의 스냅샷) | +| current_version | integer | `3` | 현재 버전 번호 (복원 자체도 새 버전으로 적재되어 증가) | +| creator | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"관리자"}` | 생성자 정보 (uuid/name — creator 관계 eager load) | +| updater | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"관리자"}` | 최종 수정자(복원 수행자) 정보 (uuid/name — updater 관계 eager load) | +| attachments | array | `[]` | 페이지에 귀속된 첨부파일 목록 (`PageAttachmentResource` 배열) | +| created_at | string | `2026-07-08 10:51:59` | 생성 일시 | +| updated_at | string | `2026-07-08 15:10:02` | 최종 수정 일시 (복원 시각) | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자(`created_by`)인지 여부 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "이전 버전으로 복원되었습니다.", + "data": { + "id": 10, + "slug": "apidoc-sample-page", + "title": { + "ko": "API 문서 샘플 페이지 (v1)", + "en": "API Doc Sample Page (v1)" + }, + "content": { + "ko": "

초기 버전 본문.

", + "en": "

Initial version body.

" + }, + "content_mode": "html", + "published": true, + "published_at": "2026-07-08 10:51:59", + "seo_meta": null, + "current_version": 3, + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "updater": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "attachments": [], + "created_at": "2026-07-08 10:51:59", + "updated_at": "2026-07-08 15:10:02", + "is_owner": true, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없거나, 스코프 밖 리소스인 경우 (`auth.scope_denied`) | +| 404 | Not Found | `{page}` 페이지 또는 `{versionId}` 버전이 없는 경우 (`페이지를 찾을 수 없습니다.`) | +| 500 | Internal Server Error | 복원 처리 중 예외 — 지정된 버전이 해당 페이지에 속하지 않는 경우 포함 (`버전 복원에 실패했습니다.`) | @@ -874,17 +1148,33 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다. 성공 시 원본 파일 바이트를 그대로 내려주는 파일 스트리밍 응답(`StreamedResponse`, `Content-Disposition: attachment`)이며, 실패 시에만 JSON 오류 봉투를 반환합니다._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: image/jpeg +Content-Disposition: attachment; filename="nesciunt.jpg" + +(파일 바이너리 스트림) +``` + +실패 시(첨부 미존재 · 미발행 페이지의 첨부를 권한 없이 요청 · 스토리지에 파일 없음): + +```json +{ + "success": false, + "message": "첨부파일을 찾을 수 없습니다.", + "errors": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해시에 해당하는 첨부가 없거나(`첨부파일을 찾을 수 없습니다.`), 미발행 페이지의 첨부를 `sirsoft-page.pages.read` 권한 없이 요청한 경우(존재를 숨기기 위해 동일하게 404), 또는 DB 레코드는 있으나 스토리지에 실제 파일이 없는 경우(`첨부파일이 스토리지에 존재하지 않습니다.`) | @@ -914,17 +1204,33 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다. 성공 시 이미지 바이트를 인라인으로 내려주는 스트리밍 응답(`StreamedResponse`, `Content-Disposition: inline`)이며, 실패 시에만 JSON 오류 봉투를 반환합니다._ **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: image/jpeg +Content-Disposition: inline + +(이미지 바이너리 스트림) +``` + +실패 시(첨부 미존재 · 이미지가 아닌 첨부 · 스토리지에 파일 없음): + +```json +{ + "success": false, + "message": "첨부파일이 스토리지에 존재하지 않습니다.", + "errors": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해시에 해당하는 첨부가 없는 경우(`첨부파일을 찾을 수 없습니다.`), 첨부가 이미지가 아니거나 스토리지에 실제 파일이 없는 경우(`첨부파일이 스토리지에 존재하지 않습니다.`). 미리보기는 발행/미발행과 무관하게 공개 서빙되므로 권한에 의한 404 는 없습니다 | diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/api/images.md b/plugins/_bundled/sirsoft-ckeditor5/docs/api/images.md index 6fa3d5c9..487f0d50 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/docs/api/images.md +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/api/images.md @@ -39,17 +39,44 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 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`)을 파일명으로 사용 | **응답 예시** - +성공 시 (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" + + +``` + +실패 시 (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`) | diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md b/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md index e2e7d869..519eb643 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md @@ -48,18 +48,34 @@ Content-Type: application/octet-stream **응답 필드** (`data` 내부) - +_이 엔드포인트는 표준 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 가 이 값을 그대로 `` 에 삽입한다. | + +실패 시에는 최상위 `error.message` 만 반환합니다 (아래 에러 응답 표 참조). + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| error.message | string | `이미지 파일만 업로드할 수 있습니다.` | 검증/권한/서버 오류 사유 (다국어 처리된 단일 메시지). CKEditor 가 에디터 UI 의 알림 문구로 표시한다. | **응답 예시** - +```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":"이미지 업로드에 실패했습니다."}}` | diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index e9049595..a3c8eb0f 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -57,6 +57,10 @@ ## [1.0.0] - 2026-07-01 +### Fixed + +- 동의 저장·철회·정책 버전 발행 등을 처리한 뒤 표시되는 안내 문구가 번역된 문장 대신 내부 식별자가 그대로 보이던 문제를 수정했습니다. 이제 사용 중인 언어에 맞는 안내 문구가 표시됩니다. + ### Changed - 동의 기록 관리 / 마이페이지 개인정보 탭의 콘텐츠 카드 / 세로 정렬 컨테이너 외형을 sirsoft-admin_basic 표준 시맨틱 (.admin-card / .row-stack 등) 과 정합 — 다른 화면과 같은 결로 통일. diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md b/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md index e70d3b62..fd86691f 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md @@ -65,7 +65,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": { "data": [], "pagination": { diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md index 31d75332..cdafaa39 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md @@ -53,15 +53,38 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `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 입니다 | **응답 예시** - +```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 + } + } +} +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Validation Error | `consents` 누락/비배열/빈 배열, 값이 boolean 이 아님, `source` 가 `banner\|preference_center\|register\|mypage` 외의 값, 카탈로그에 없는 consent_key 전달(`유효하지 않은 동의 항목입니다.`), 필수 카테고리를 `false` 로 전달(`필수 항목은 철회할 수 없습니다.`) | @@ -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 **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 응답 없음 — 공개 조회 엔드포인트입니다. 요청 파라미터가 없어 검증 실패(422)가 발생하지 않고, 컨트롤러/Service 가 도메인 예외를 던지지 않습니다. 미인증 호출도 게스트로 정상 처리되어 401 도 발생하지 않습니다._ @@ -149,17 +172,30 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| consent_key | string | `cookie_analytics` | 이번 호출로 동의(`true`)가 부여된 동의 항목 키. 요청 본문의 `consent_key` 를 그대로 반향하며, 프론트가 해당 행만 갱신하는 데 사용합니다 | **응답 예시** - +```json +{ + "success": true, + "message": "동의가 갱신되었습니다.", + "data": { + "consent_key": "cookie_analytics" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Validation Error | `consent_key` 누락/문자열 아님/50자 초과, 또는 쿠키 카테고리 카탈로그에 없는 키(`유효하지 않은 동의 항목입니다.`) | @@ -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` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| consent_key | string | `cookie_marketing` | 이번 호출로 철회(`false`)된 동의 항목 키. 요청 본문의 `consent_key` 를 그대로 반향하며, 프론트가 해당 행만 갱신하는 데 사용합니다 | **응답 예시** - +```json +{ + "success": true, + "message": "동의가 철회되었습니다.", + "data": { + "consent_key": "cookie_marketing" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Validation Error | `consent_key` 누락/문자열 아님/50자 초과, 카탈로그에 없는 키(`유효하지 않은 동의 항목입니다.`), 또는 필수(strictly necessary) 카테고리 철회 시도(`필수 항목은 철회할 수 없습니다.`) | diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/policy-versions.md b/plugins/_bundled/sirsoft-gdpr/docs/api/policy-versions.md index 31a9fb60..0c1c3c04 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/policy-versions.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/policy-versions.md @@ -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, diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md b/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md index 6aaf45e1..79f0d783 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md @@ -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` 내부) - +_단건 응답: `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 +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 **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 없음 (공개 조회 — 요청 파라미터가 없고 컨트롤러/서비스가 도메인 예외를 던지지 않습니다. 설정 미저장 시에도 기본값으로 200 응답합니다)._ diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminConsentLogController.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminConsentLogController.php index 34a12c5f..e0490f22 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminConsentLogController.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminConsentLogController.php @@ -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(), diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminPolicyVersionController.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminPolicyVersionController.php index f8f6d7f2..fe0fd28b 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminPolicyVersionController.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminPolicyVersionController.php @@ -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), ]); } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminSettingsController.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminSettingsController.php index dd371c6b..19f7e078 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminSettingsController.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminSettingsController.php @@ -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, ]); } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php index d7243f56..9b1c187f 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php @@ -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, diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprSettingsController.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprSettingsController.php index 00901f6b..58c0f6f6 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprSettingsController.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprSettingsController.php @@ -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); } /** diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/User/GdprConsentController.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/User/GdprConsentController.php index a469a75f..85dabda6 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/User/GdprConsentController.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/User/GdprConsentController.php @@ -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), ]); } diff --git a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md index 8ff7ed36..02ea3f1e 100644 --- a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md @@ -12,6 +12,10 @@ ## [1.0.0] - 2026-07-01 +### Fixed + +- 마케팅 설정을 저장한 뒤 표시되는 안내 문구가 번역된 문장 대신 내부 식별자가 그대로 보이던 문제를 수정했습니다. 이제 사용 중인 언어에 맞는 안내 문구가 표시됩니다. + ### Added - 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다. diff --git a/plugins/_bundled/sirsoft-marketing/docs/api/channels.md b/plugins/_bundled/sirsoft-marketing/docs/api/channels.md index 247c6e6f..c5ca259e 100644 --- a/plugins/_bundled/sirsoft-marketing/docs/api/channels.md +++ b/plugins/_bundled/sirsoft-marketing/docs/api/channels.md @@ -38,17 +38,49 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `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` | 시스템 채널 여부 (삭제·플래그 변경 불가) | **응답 예시** - +```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명 있어 삭제할 수 없습니다." | @@ -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": "채널이 저장되었습니다.", diff --git a/plugins/_bundled/sirsoft-marketing/docs/api/settings.md b/plugins/_bundled/sirsoft-marketing/docs/api/settings.md index 924a164c..311791ec 100644 --- a/plugins/_bundled/sirsoft-marketing/docs/api/settings.md +++ b/plugins/_bundled/sirsoft-marketing/docs/api/settings.md @@ -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 **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 없음 (공개 조회 — 인증·검증·도메인 예외 경로가 없다. 요청 파라미터가 없어 422 가 발생하지 않고, `PublicBaseController` 라 401/403 도 없으며, 설정 미존재 시에도 기본값으로 200 을 반환한다)._ diff --git a/plugins/_bundled/sirsoft-marketing/src/Http/Controllers/MarketingSettingsController.php b/plugins/_bundled/sirsoft-marketing/src/Http/Controllers/MarketingSettingsController.php index 09613554..00d758fc 100644 --- a/plugins/_bundled/sirsoft-marketing/src/Http/Controllers/MarketingSettingsController.php +++ b/plugins/_bundled/sirsoft-marketing/src/Http/Controllers/MarketingSettingsController.php @@ -84,6 +84,6 @@ class MarketingSettingsController extends PublicBaseController ]; }, $channels); - return ResponseHelper::success('messages.success', $data); + return ResponseHelper::success('common.success', $data); } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index 960e770b..e18a9b3c 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -22,7 +22,7 @@ ### Fixed -- 일본 편의점결제(CVS) 관련 안내 메시지가 번역되지 않고 내부 코드 그대로 표시되던 문제를 수정했습니다. 주문을 찾을 수 없거나 입금 통보 테스트·입금기한 만료 처리를 할 때 정상적인 한국어(영어) 안내가 표시됩니다. +- 안내 메시지가 번역되지 않고 내부 코드 그대로 표시되던 문제를 수정했습니다. 일본 편의점결제(CVS) 입금 통보 테스트·입금기한 만료 처리뿐 아니라 에스크로 배송 등록·구매확인 거부, 거래 조회, 정산 마감 등 이 플러그인의 모든 처리 결과에서 사용 중인 언어에 맞는 안내가 표시됩니다. - 일본(CBT) 설정을 운영 모드로 전환할 때 상점 정보나 서명키가 비어 있어도 저장되던 문제를 수정했습니다. 필수값이 비었거나 샘플값이 그대로면 저장이 중단되고 안내 메시지가 표시됩니다. - 이 플러그인이 설치된 사이트에서 관리자 주문 목록의 '일부취소' 표시와 적립예정 포인트·마일리지 사용 금액, 주문상품의 추가옵션 요약이 보이지 않던 문제를 수정했습니다. 결제 정보 칸에 테스트결제 표시를 덧붙이는 과정에서 목록 표 구성 전체를 옛 버전으로 되돌려 놓고 있었습니다. - 관리자 결제수단 설정에서 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제를 수정했습니다. 해당 결제수단의 PG사 목록이 항상 비어 있어 기본 PG사만 사용할 수 있었고, 결제수단별로 다른 PG사를 지정하는 설정이 화면에서 불가능했습니다. diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md index a1d78284..69b9118d 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md @@ -136,7 +136,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "messages.success", + "message": "성공적으로 처리되었습니다.", "data": { "product_id": 4, "product_code": "CBT-TEST-20260708063238", diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md index c0533627..19fe4554 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md @@ -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` 내부) - +_단건 응답: `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`. | **응답 예시** - +```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 편의점결제 주문만 만료 처리할 수 있습니다.") | @@ -367,11 +438,96 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `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 | `` | 만료 처리 사유 | **응답 예시** - +```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`) | @@ -410,7 +567,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `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` 내부) - +_단건 응답: `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` | 조정 레코드 갱신 시각. | **응답 예시** - +```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` 로 되돌아가고 오류가 기록된다. | @@ -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` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| result_code | string | `00` | KG 이니시스 배송등록 응답 코드. `00` 만 성공으로 처리된다(그 외는 502). | +| result_msg | string | `정상처리` | KG 이니시스 응답 메시지. 응답에 메시지가 없으면 `OK`. | +| invoice | string | `1234567890123` | 등록된 운송장번호(요청값 그대로). | +| ex_name | string | `CJ대한통운` | 등록된 택배사명(요청 `ex_code` 를 공식 코드표로 변환한 이름). | **응답 예시** - +```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`) | @@ -642,11 +900,29 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| result_code | string | `00` | KG 이니시스 구매거절확인(INIAPI `type=Dncf`) 응답 코드. `00` 만 성공으로 처리된다(그 외는 502). | +| result_msg | string | `정상처리` | KG 이니시스 응답 메시지. 응답에 메시지가 없으면 `OK`. | **응답 예시** - +```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`) | @@ -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` 내부) - +_이 엔드포인트는 `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`. | **응답 예시** diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md index 4f80ce74..951648f0 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md @@ -37,11 +37,24 @@ Accept: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| checkout_token | string | `eyJvaWQiOiJPUkQyMDI2MDcxNDAwMSIsInByaWNlIjoxMDAwMH0.5f1c…` | 후속 hash-data 요청에 그대로 실어 보내는 단기 체크아웃 토큰. 주문번호·금액·구매자(이메일/전화)·요청 IP·User-Agent 를 HMAC-SHA256 으로 봉인한 서명값이다. | **응답 예시** - +```json +{ + "success": true, + "data": { + "checkout_token": "eyJvaWQiOiJPUkQyMDI2MDcxNDAwMSIsInByaWNlIjoxMDAwMH0.5f1c…" + } +} +``` + +_이 엔드포인트는 `ResponseHelper` 봉투를 쓰지 않고 `response()->json()` 으로 `success`·`data` 만 반환합니다 (`message` 없음)._ **에러 응답** @@ -79,11 +92,24 @@ Accept: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| hash_data | string | `a1b2c3d4e5f6…` | 일본 CBT 결제창이 KG 이니시스에 전송할 위변조 방지 해시(P_HASHDATA). 일본 가맹점 MID·타임스탬프·금액·주문번호로 생성된다. | **응답 예시** - +```json +{ + "success": true, + "data": { + "hash_data": "a1b2c3d4e5f6…" + } +} +``` + +_이 엔드포인트는 `ResponseHelper` 봉투를 쓰지 않고 `response()->json()` 으로 `success`·`data` 만 반환합니다 (`message` 없음)._ **에러 응답** @@ -138,17 +164,33 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `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`). | **응답 예시** - +```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회를 초과해 요청한 경우 | @@ -192,17 +234,34 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| chkfake | string | `9f86d081884c7d65…` | 모바일 결제창이 KG 이니시스에 전송할 위변조 방지 해시(P_CHKFAKE). 주문번호·금액·타임스탬프로 생성된다. | +| mobile_payment_url | string | `https://mobile.inicis.com/smart/payment/` | 모바일 결제창 진입 URL. 테스트/운영 모드 설정에 따라 달라진다. | **응답 예시** - +```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회를 초과해 요청한 경우 | @@ -246,17 +305,36 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| signature | string | `4d2f8a9c1b7e…` | PC 표준결제창에 전달할 위변조 방지 서명. 주문번호·금액·타임스탬프로 생성된다. | +| verification | string | `7c1a3e5b9d0f…` | 표준결제창 검증값. signature 와 함께 결제창 호출 파라미터로 전달된다. | +| mKey | string | `e3b0c44298fc1c14…` | 가맹점 키 해시(mKey). KG 이니시스 표준결제창이 가맹점 식별에 사용한다. | **응답 예시** - +```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회를 초과해 요청한 경우 | @@ -310,11 +388,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 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 +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 - + - **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.cbt.callback` - **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\CbtCallbackController@handle` - **인증/권한**: 공개 (인증 불필요) @@ -363,11 +457,27 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 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 +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 - + - **라우트명**: `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 | | -| resultCode | body | string | 아니오 | max 100 | 결제 인증 결과 코드 (`0000` = 성공). `2001`/`0021`/`0022`/빈값은 사용자 취소로 간주해 에러 없이 체크아웃으로 복귀한다. | -| resultMsg | body | string | 아니오 | max 500 | 결제 인증 결과 메시지. 실패 시 실패 URL 의 `message` 파라미터로 전달되며 '취소'/'사용자' 포함 여부로 사용자 취소를 판별한다. | -| orderID | body | string | 아니오 | max 100 | | -| orderId | body | string | 아니오 | max 100 | | -| oid | body | string | 아니오 | max 100 | 결제 대상 주문의 주문번호. 서버가 주문을 조회해 결제 가능 상태·통화·금액을 검증하고 이 값을 서명 생성 입력으로 사용한다. | -| mid | body | string | 아니오 | max 30 | | -| paymethod | body | string | 아니오 | max 50 | | -| selectedPaymentMethod | body | string | 아니오 | max 50 | | +| 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` 내부) - +_이 엔드포인트는 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 +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` 내부) - +_이 엔드포인트는 JSON 을 반환하지 않습니다 (`data` 없음). 응답은 `Content-Type: text/plain` 의 평문 본문 한 줄이며, HTTP 상태코드는 항상 200 입니다._ + +| 본문 | 의미 | 발생 조건 | +| --- | --- | --- | +| `OK` | 통보 수신 확인 | 검증 통과 후 입금 처리 완료, 또는 처리 불필요(비-성공 `status` 통보 / 이미 결제완료된 중복 통보)로 무시 | +| `FAIL` | 통보 거부 | 주문·결제행 미존재, TID/MID/SID 불일치, 통화가 `JPY` 아님, 금액 불일치, 결제 상태가 입금대기 아님, 결제수단이 CVS 아님 | + +`FAIL` 을 반환하면 KG 이니시스가 동일 통보를 재시도합니다. **응답 예시** - +```http +HTTP/1.1 200 OK +Content-Type: text/plain + +OK +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 403 | Forbidden | KG 이니시스 공식 발송 IP 화이트리스트에 없는 IP 에서 요청한 경우 (로컬·테스트 환경 제외) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -550,15 +689,30 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 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 +HTTP/1.1 200 OK +Content-Type: text/html; charset=UTF-8 +Cache-Control: no-store, no-cache, must-revalidate, max-age=0 + + + + + + KG 이니시스 결제창 닫기 + + + + + +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 응답이 정의되어 있지 않습니다. 인증·요청 파라미터 없이 정적 HTML(200)만 반환하므로 도메인 특이 에러가 발생하지 않습니다._ @@ -589,15 +743,20 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 을 반환하지 않습니다 (`data` 없음). 응답은 `Content-Type: text/html; charset=UTF-8` 의 HTML(HTTP 200)이며, 본문은 팝업을 닫는 스크립트(`window.close()`) 한 줄뿐입니다. 결제나 구매결정 상태를 변경하지 않습니다._ **응답 예시** - +```http +HTTP/1.1 200 OK +Content-Type: text/html; charset=UTF-8 + + +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 응답이 정의되어 있지 않습니다. 인증·요청 파라미터 없이 팝업 닫기 스크립트(HTML 200)만 반환하므로 도메인 특이 에러가 발생하지 않습니다._ @@ -626,15 +785,25 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 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 +HTTP/1.1 302 Found +Location: https://example.com/mypage/orders/ORD20260714001 +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 응답이 정의되어 있지 않습니다. 거래번호가 비었거나 매칭되는 에스크로 결제가 없어도 예외를 던지지 않고 주문 목록(`/mypage/orders`)으로 리다이렉트합니다._ @@ -665,15 +834,22 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 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 +HTTP/1.1 200 OK +Content-Type: text/html; charset=UTF-8 + + +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 응답이 정의되어 있지 않습니다. 거래번호가 비었거나 매칭되는 에스크로 결제가 없어도 기록만 생략하고 동일한 HTML(200)을 반환합니다._ @@ -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` 내부) - +_이 엔드포인트는 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) | **응답 예시** - +```http +HTTP/1.1 200 OK +Content-Type: text/html; charset=UTF-8 + + + + + + 에스크로 구매결정 + + + + +

에스크로 구매결정 창을 준비 중입니다...

+
+ + + + + + + + + +
+ + +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 401 | Unauthorized | 비로그인 상태로 접근한 경우 (라우트에 `auth` 미들웨어 적용 — 요약의 "공개" 표기는 자동 생성 오차) | +| 404 | Not Found | `orderNumber` 에 해당하는 주문이 접속 사용자 본인 소유가 아니거나, 해당 주문에 거래번호가 있는 KG 이니시스 에스크로 결제(`pg_provider=kginicis`, `is_escrow=true`)가 없는 경우 | @@ -728,7 +937,7 @@ Accept: application/json ### GET /plugins/sirsoft-pay_kginicis/payment/mobile/callback - + - **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.mobile.callback` - **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\MobileCallbackController@handle` - **인증/권한**: 공개 (인증 불필요) @@ -755,11 +964,27 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 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 +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 - + - **라우트명**: `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 | 예 | — | | -| P_RMESG1 | body | string | 아니오 | — | | -| P_TID | body | string | 아니오 | — | | -| P_REQ_URL | body | string | 아니오 | — | | -| P_AMT | body | string | 아니오 | — | | -| P_OID | body | string | 아니오 | — | | +| 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` 내부) - +_이 엔드포인트는 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 +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` 내부) - +_이 엔드포인트는 JSON 을 반환하지 않습니다 (`data` 없음). 응답은 `Content-Type: text/plain` 의 평문 본문 한 줄이며, HTTP 상태코드는 항상 200 입니다._ + +| 본문 | 의미 | 발생 조건 | +| --- | --- | --- | +| `OK` | 통보 수신 확인 | 검증 통과 후 입금 처리 완료, 또는 처리 불필요(`P_STATUS` 가 `02` 가 아니거나 `P_TYPE` 이 `VBANK` 가 아닌 통보 / 이미 결제완료된 중복 통보)로 무시 | +| `FAIL` | 통보 거부 | 주문 미존재, 거래번호(TID)·MID·가상계좌번호 불일치, 금액이 결제 통화 기준 청구액과 불일치, 결제가 가상계좌·입금대기 상태가 아님, 처리 중 예외 발생 | + +`FAIL` 을 반환하면 KG 이니시스가 동일 통보를 재시도합니다. **응답 예시** - +```http +HTTP/1.1 200 OK +Content-Type: text/plain + +OK +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 403 | Forbidden | KG 이니시스 공식 발송 IP 화이트리스트에 없는 IP 에서 요청한 경우 (로컬·테스트 환경 제외) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -1007,16 +1261,29 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 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 +HTTP/1.1 200 OK +Content-Type: text/plain + +OK +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 403 | Forbidden | KG 이니시스 공식 발송 IP 화이트리스트에 없는 IP 에서 요청한 경우 (로컬·테스트 환경 제외) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md index e24a8b39..6a6d9fde 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md @@ -38,11 +38,115 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `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 +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 오류·타임아웃 등) 시 | diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtCvsOperationsController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtCvsOperationsController.php index e80218ef..648a2483 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtCvsOperationsController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtCvsOperationsController.php @@ -35,7 +35,7 @@ class AdminCbtCvsOperationsController extends AdminBaseController ); } - return ResponseHelper::success('messages.success', $summary); + return ResponseHelper::success('common.success', $summary); } /** diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtReconciliationController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtReconciliationController.php index ef4ae4c8..241a31fd 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtReconciliationController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtReconciliationController.php @@ -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)); } /** diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtTestProductController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtTestProductController.php index 9a521c7e..c3096ec4 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtTestProductController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtTestProductController.php @@ -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()]); } } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php index 6ccf5296..4cc89ca4 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php @@ -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()], ]); } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php index defd4990..0b53e30f 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php @@ -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()], ]); } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php index 69a5acfd..31c238c9 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php @@ -45,7 +45,7 @@ class AdminOrderListController extends AdminBaseController } } - return ResponseHelper::success('messages.success', $map); + return ResponseHelper::success('common.success', $map); } /** diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminSettingsStatusController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminSettingsStatusController.php index a9cd5f4b..51aa9f67 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminSettingsStatusController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminSettingsStatusController.php @@ -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), ]); } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php index 78deed44..a45e372a 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php @@ -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); } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php index b43391c0..1be0cd63 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php @@ -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', ]); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php index 5cffc760..18b02039 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php @@ -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()], ]); } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php index a9bcc21e..b63d60ea 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php @@ -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); } } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminSettingsStatusController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminSettingsStatusController.php index 5bf03d56..5dc793f2 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminSettingsStatusController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminSettingsStatusController.php @@ -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), ]); } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php index 8fa43674..e41dfd5e 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php @@ -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, diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php index c7f7eb33..e8200ae0 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php @@ -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', ]); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentRetryController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentRetryController.php index a5e115d4..68ac34fc 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentRetryController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentRetryController.php @@ -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.'], ]); } diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php index 258c1b31..bb42dc48 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php @@ -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); } diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php index 841bc581..66aadda9 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php @@ -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); } } diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminSettingsStatusController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminSettingsStatusController.php index 2e854622..453403bd 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminSettingsStatusController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminSettingsStatusController.php @@ -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), ]); } diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php index 04631f52..09f099da 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php @@ -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); } } } diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminVbankRefundController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminVbankRefundController.php index 6a1809ae..830f43ee 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminVbankRefundController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminVbankRefundController.php @@ -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}"), ]); } diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php index b63757b3..ddbd0dc7 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php @@ -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', ]); } diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md b/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md index b405edfe..dc539b60 100644 --- a/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md +++ b/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md @@ -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 | | -| data.status | body | string | 예 | max 40 | | -| data.paymentKey | body | string | 아니오 | max 255 | | +| 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 | 토스 결제 키. 수신만 하며 이 엔드포인트에서 상태 전이에 사용하지 않는다. | **요청 예시** diff --git a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md index 7a130a03..706a9b61 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md @@ -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 diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/api/identity.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/api/identity.md index 73bd5d3c..4ed88018 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/docs/api/identity.md +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/api/identity.md @@ -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 } ``` diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/api/plugin.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/api/plugin.md index e7831c4d..ba1036d7 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/docs/api/plugin.md +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/api/plugin.md @@ -37,19 +37,55 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 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 +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 +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +이 엔드포인트는 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`) | -**설명** +**설명** + +이니시스 본인인증 매뉴얼 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` 내부) - +_이 엔드포인트는 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 +HTTP/1.1 200 OK +Content-Type: text/html; charset=UTF-8 +``` + +```html + + + + + + 본인인증 결과 + + + + + +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 응답 없음 — 인증/검증이 없고 query 파라미터가 모두 선택이므로, 어떤 요청에도 `200 OK` + bridge HTML 을 반환합니다. 본인인증 실패는 HTTP 에러가 아니라 `identity_error` query 로 전달되어 페이지 스크립트가 처리합니다._ -**설명** +**설명** + +이니시스 본인인증 매뉴얼 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 목적지입니다. diff --git a/plugins/_bundled/sirsoft-verification_kginicis/src/Http/Controllers/MyInicisIdentityShowController.php b/plugins/_bundled/sirsoft-verification_kginicis/src/Http/Controllers/MyInicisIdentityShowController.php index 873b2bfb..5db3de4e 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/src/Http/Controllers/MyInicisIdentityShowController.php +++ b/plugins/_bundled/sirsoft-verification_kginicis/src/Http/Controllers/MyInicisIdentityShowController.php @@ -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(), ); } diff --git a/tests/Feature/Api/Identity/IdentityChallengeShowTest.php b/tests/Feature/Api/Identity/IdentityChallengeShowTest.php index 9f7b45c0..21e46160 100644 --- a/tests/Feature/Api/Identity/IdentityChallengeShowTest.php +++ b/tests/Feature/Api/Identity/IdentityChallengeShowTest.php @@ -36,6 +36,10 @@ class IdentityChallengeShowTest extends TestCase $response->assertStatus(404); } + /** + * @scenario source=identity-challenge-status-field-exposure axis=field:id,field:status,field:public_payload + * @effects public_safe_fields_present_in_response + */ public function test_show_returns_public_status_fields(): void { // start a challenge @@ -55,6 +59,10 @@ class IdentityChallengeShowTest extends TestCase ->assertJsonPath('data.id', $id); } + /** + * @scenario source=identity-challenge-status-field-exposure axis=field:attempts,field:max_attempts,field:target_hash + * @effects attempts_absent_from_response, max_attempts_absent_from_response, target_hash_absent_from_response, verification_token_absent_from_response, metadata_absent_from_response + */ public function test_show_does_not_expose_sensitive_fields(): void { $request = $this->postJson('/api/identity/challenges', [ @@ -65,8 +73,10 @@ class IdentityChallengeShowTest extends TestCase $response = $this->getJson("/api/identity/challenges/{$id}"); - // 시도 횟수·코드 본체·target_hash·verification_token 등 노출 금지 + // 시도 횟수(attempts / max_attempts)·코드 본체·target_hash·verification_token 등 노출 금지. + // 이 엔드포인트는 권한 가드 없는 공개 폴링용이므로 남은 시도 횟수를 추론할 단서를 응답에 담지 않는다. $response->assertJsonMissingPath('data.attempts') + ->assertJsonMissingPath('data.max_attempts') ->assertJsonMissingPath('data.target_hash') ->assertJsonMissingPath('data.verification_token') ->assertJsonMissingPath('data.metadata'); diff --git a/tests/Feature/Console/ApiDocgenCommandTest.php b/tests/Feature/Console/ApiDocgenCommandTest.php index 3ca6d539..4a6b1002 100644 --- a/tests/Feature/Console/ApiDocgenCommandTest.php +++ b/tests/Feature/Console/ApiDocgenCommandTest.php @@ -219,6 +219,54 @@ class ApiDocgenCommandTest extends TestCase } } + /** + * (e-2) --check 는 실측 제외를 drift 로 보지 않습니다. + * + * 회귀: `--check` 가 `skipped_reason` 이 있는 엔드포인트(쓰기 메서드·부수효과·미치환 path)를 + * 전부 drift 로 집계해 **항상 FAILURE** 를 반환했다. 실측 제외는 설계상 정상 동작이며 + * (부수효과 쓰기는 의도적으로 호출하지 않는다), 그 자리는 사람이 코드 근거로 채운다. + * 실측 제외를 결함으로 세면 문서가 완전해도 통과할 수 없어 기준이 무의미해진다. + * + * drift 판정 대상은 (1) 문서 파일 부재 (2) 미채움 마커 잔존 두 가지다. + */ + #[Test] + public function check는_실측제외를_drift로_보지_않는다(): void + { + // 문서가 완비된 확장(미채움 0건)에서 --check 는 성공해야 한다. + // ckeditor5 는 엔드포인트 2건 모두 실측 제외(unresolved-path-param / side-effectful-write)지만 + // 사람이 응답 필드/예시를 채워 두었으므로 문서 자체는 완전하다. + $this->artisan('api:docgen', ['--scope' => 'plugin:sirsoft-ckeditor5', '--check' => true]) + ->assertSuccessful(); + } + + /** + * (e-3) --check 는 미채움 마커가 남아 있으면 drift 로 검출합니다. + */ + #[Test] + public function check는_미채움_마커_잔존을_drift로_검출한다(): void + { + $target = base_path('plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md'); + + if (! File::exists($target)) { + $this->markTestSkipped('대상 문서 없음'); + } + + $backup = File::get($target); + + try { + // 사람이 채운 응답 필드 자리를 미채움 마커로 되돌린다. + File::put( + $target, + $backup."\n\n" + ); + + $this->artisan('api:docgen', ['--scope' => 'plugin:sirsoft-ckeditor5', '--check' => true]) + ->assertFailed(); + } finally { + File::put($target, $backup); + } + } + /** * README 목차 항목/확장 목록 샘플을 반환합니다. * diff --git a/tests/Unit/Helpers/ResponseHelperTest.php b/tests/Unit/Helpers/ResponseHelperTest.php index ff1f9bd7..93fa3342 100644 --- a/tests/Unit/Helpers/ResponseHelperTest.php +++ b/tests/Unit/Helpers/ResponseHelperTest.php @@ -248,8 +248,8 @@ class ResponseHelperTest extends TestCase /** * 깊은 호출 스택의 예외를 생성합니다. * - * @param int $depth 호출 깊이 - * @return \RuntimeException + * @param int $depth 호출 깊이 + * @return \RuntimeException 생성된 예외 */ private function createDeepException(int $depth): \RuntimeException { @@ -294,4 +294,74 @@ class ResponseHelperTest extends TestCase $this->assertSame('欢迎', $data['message']); } + + /** + * 회귀: 기본 메시지 키가 실제로 번역되어야 한다. + * + * `messages.success` 는 `lang/{ko,en}/messages.php` 가 존재하지 않아 번역되지 않았고, + * `__()` 가 미해석 키 문자열을 그대로 반환해 API 응답에 `"message": "messages.success"` 가 + * 그대로 나갔다 (코어 7곳 + gdpr/marketing/pay_kginicis/verification_kginicis 등 43개 호출). + * 정상 키는 `common.success` 다 (ko: "성공적으로 처리되었습니다." / en: "Successfully processed."). + */ + public function test_default_success_message_key_is_translated(): void + { + foreach (['ko', 'en'] as $locale) { + App::setLocale($locale); + + $data = ResponseHelper::success()->getData(true); + + $this->assertNotSame( + 'messages.success', + $data['message'], + "[{$locale}] 기본 메시지 키가 번역되지 않고 키 문자열 그대로 응답에 나갔다" + ); + + // 미해석 lang 키는 `도메인.키` 형태 그대로 반환된다 (공백 없는 dot notation). + // 번역문은 문장이므로 공백을 포함한다. + $this->assertDoesNotMatchRegularExpression( + '/^[a-z_]+(\.[a-z_]+)+$/', + $data['message'], + "[{$locale}] 번역문이 아니라 미해석 lang 키로 보인다: {$data['message']}" + ); + } + } + + /** + * 회귀: 코드베이스에 미해석 lang 키(`messages.success`)를 넘기는 호출이 없어야 한다. + * + * ResponseHelper 기본값만 고치면 명시적으로 그 키를 넘기던 호출부는 여전히 미해석 키를 낸다. + */ + public function test_no_caller_passes_unresolvable_messages_success_key(): void + { + $roots = array_filter([ + base_path('app'), + ...glob(base_path('modules/_bundled/*/src')), + ...glob(base_path('plugins/_bundled/*/src')), + ], 'is_dir'); + + $offenders = []; + + foreach ($roots as $root) { + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root)); + + foreach ($files as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $content = (string) file_get_contents($file->getPathname()); + + if (str_contains($content, "'messages.success'")) { + $offenders[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file->getPathname()); + } + } + } + + $this->assertSame( + [], + $offenders, + "번역되지 않는 'messages.success' 키를 넘기는 호출이 남아 있다 (정상 키: 'common.success'):\n - " + .implode("\n - ", $offenders) + ); + } } diff --git a/tests/Unit/Support/ApiDoc/ApiDocPipelineTest.php b/tests/Unit/Support/ApiDoc/ApiDocPipelineTest.php index 5d67291c..47c9676e 100644 --- a/tests/Unit/Support/ApiDoc/ApiDocPipelineTest.php +++ b/tests/Unit/Support/ApiDoc/ApiDocPipelineTest.php @@ -1763,7 +1763,7 @@ MD; // 사람이 도메인 특이 에러 표를 직접 채운다. $humanTable = "| 상태코드 | 의미 | 발생 조건 |\n" ."| --- | --- | --- |\n" - ."| 429 | Too Many Requests | 동일 IP 에서 분당 10회를 초과해 요청한 경우 |"; + .'| 429 | Too Many Requests | 동일 IP 에서 분당 10회를 초과해 요청한 경우 |'; $withTable = str_replace( '_대표 에러 없음 (공개 조회). _', $humanTable, @@ -1806,4 +1806,243 @@ MD; $this->assertStringContainsString('/api/admin/brands/{brand}', $block); $this->assertStringNotContainsString('/api/admin/brands/1', $block); } + + #[Test] + public function 같은_라우트명의_ge_t_pos_t_는_서로_다른_생성블록_키를_갖는다(): void + { + // 회귀: Route::match(['get','post'], ...)->name('x') 는 하나의 라우트명으로 두 메서드를 + // 등록한다(KG이니시스 CBT/모바일 콜백). 생성 블록 키가 라우트명뿐이면 한 문서에 같은 + // `@generated:start:x` 가 두 번 나오고, strpos 기반 블록 조회가 언제나 첫 블록만 잡아 + // 두 번째(POST) 블록의 사람 서술이 재생성마다 유실된다. + // (실측: pay_kginicis payment.md 의 body 파라미터 용도 12건) + $scaffolder = new ApiDocScaffolder; + + $base = [ + 'uri' => '/plugins/sirsoft-pay_kginicis/payment/cbt/callback', + 'name' => 'web.plugins.sirsoft-pay_kginicis.payment.cbt.callback', + 'controller' => 'C', 'controller_method' => 'handle', 'permission' => null, + 'middleware' => ['web'], 'path_params' => [], + ]; + $request = ['request_class' => null, 'params' => [], 'hook_filters' => []]; + $schema = ['envelope' => ['data'], 'shape' => 'object', 'pagination' => false, + 'fields' => [['name' => 'status', 'type' => 'string', 'sample' => 'ok']]]; + $probe = ['status' => 200, 'skipped_reason' => null]; + + // 라우트명이 문서 안에서 중복이면 커맨드가 methodScopedKey=true 로 호출한다. + $get = $scaffolder->endpointSection(['method' => 'GET'] + $base, $request, $schema, $probe, [], true); + $post = $scaffolder->endpointSection(['method' => 'POST'] + $base, $request, $schema, $probe, [], true); + + $extractKey = static fn (string $s): string => (string) preg_replace( + '/^.*.*$/s', '$1', explode("\n", $s)[1] + ); + + $this->assertNotSame( + $extractKey($get), + $extractKey($post), + '같은 라우트명이라도 메서드가 다르면 생성 블록 키가 달라야 한다' + ); + + // 스캐폴더의 키 생성기는 커맨드가 쓰는 섹션 키와 동일해야 한다 (mergeDocument 대조 키). + $this->assertSame($extractKey($get), $scaffolder->generatedKey(['method' => 'GET'] + $base, true)); + $this->assertSame($extractKey($post), $scaffolder->generatedKey(['method' => 'POST'] + $base, true)); + + // 중복이 아닌 라우트는 키를 그대로 둔다 (전 문서 키 변경 시 사람 서술 유실 방지). + $this->assertSame($base['name'], $scaffolder->generatedKey(['method' => 'GET'] + $base)); + } + + #[Test] + public function 같은_라우트명_두_메서드의_사람_서술이_각각_보존된다(): void + { + // 위 키 고유화가 실제 보존으로 이어지는지 — GET/POST 두 블록의 파라미터 용도가 + // 서로 섞이거나 유실되지 않고 각자 자리에 되살아나야 한다. + $scaffolder = new ApiDocScaffolder; + + $base = [ + 'uri' => '/plugins/sirsoft-pay_kginicis/payment/cbt/callback', + 'name' => 'web.plugins.sirsoft-pay_kginicis.payment.cbt.callback', + 'controller' => 'C', 'controller_method' => 'handle', 'permission' => null, + 'middleware' => ['web'], 'path_params' => [], + ]; + $schema = ['envelope' => ['data'], 'shape' => 'object', 'pagination' => false, + 'fields' => [['name' => 'status', 'type' => 'string', 'sample' => 'ok']]]; + $probe = ['status' => 200, 'skipped_reason' => null]; + + // GET 은 query, POST 는 body 로 같은 이름의 파라미터를 받는다 (실제 콜백 계약). + $getRequest = ['request_class' => null, 'hook_filters' => [], 'params' => [ + ['name' => 'sid', 'location' => 'query', 'type' => 'string', 'required' => false, 'allowed' => '—'], + ]]; + $postRequest = ['request_class' => null, 'hook_filters' => [], 'params' => [ + ['name' => 'sid', 'location' => 'body', 'type' => 'string', 'required' => false, 'allowed' => '—'], + ]]; + + $getSection = $scaffolder->endpointSection(['method' => 'GET'] + $base, $getRequest, $schema, $probe, [], true); + $postSection = $scaffolder->endpointSection(['method' => 'POST'] + $base, $postRequest, $schema, $probe, [], true); + + // 사람이 두 블록의 용도를 각각 채운 기존 문서 + $existing = "# 결제\n\n" + .str_replace('', 'GET 콜백의 세션 ID', $getSection)."\n" + .str_replace('', 'POST 콜백의 세션 ID', $postSection)."\n"; + + $merged = $scaffolder->mergeDocument( + $existing, + '# 결제', + [$getSection, $postSection], + [ + $scaffolder->generatedKey(['method' => 'GET'] + $base, true), + $scaffolder->generatedKey(['method' => 'POST'] + $base, true), + ], + ); + + $this->assertStringContainsString('GET 콜백의 세션 ID', $merged); + $this->assertStringContainsString('POST 콜백의 세션 ID', $merged, 'POST 블록의 사람 서술이 유실되면 안 된다'); + $this->assertStringNotContainsString('', $merged); + } + + #[Test] + public function 사람이_응답_필드_라벨을_고쳐도_채운_본문을_보존한다(): void + { + // 회귀: 봉투(`success`/`message`/`data`)를 쓰지 않고 JSON 을 root 에 그대로 내리는 + // 엔드포인트(LayoutPreviewController@serve 등)에서는 `**응답 필드** (`data` 내부)` 라벨이 + // 사실과 다르다. 사람이 이를 `**응답 필드**` 로 정정하면, 라벨 정확 일치를 요구하던 + // 보존 로직이 기존 본문을 찾지 못해 사람이 채운 내용이 통째로 마커로 퇴행했다. + // (실측: docs/backend/api/layouts.md 의 미리보기 서빙 응답 필드 표) + $scaffolder = new ApiDocScaffolder; + + $route = [ + 'method' => 'GET', 'uri' => '/api/layouts/{name}', 'name' => 'api.layouts.preview', + 'controller' => 'C', 'controller_method' => 'serve', 'permission' => null, + 'middleware' => ['api', 'optional.sanctum'], 'path_params' => ['name'], + ]; + $request = ['request_class' => null, 'params' => [], 'hook_filters' => []]; + $schema = ['envelope' => [], 'shape' => 'object', 'fields' => [], 'pagination' => false]; + + // 실측 불가(unresolved-path-param) → 응답 필드 자리에 마커만 생성된다. + $section = $scaffolder->endpointSection($route, $request, $schema, ['status' => null, 'skipped_reason' => 'unresolved-path-param']); + $this->assertStringContainsString('실측 응답에 필드 없음', $section, '마커 생성 전제 확인'); + + // 사람이 라벨을 정정하고(`(data 내부)` 제거) 본문을 서술로 채운 기존 문서. + // 표가 아니라 서술이므로 applyPreservedTableCells 의 행 키 대조로는 지켜지지 않는다 — + // 오직 라벨로 본문 구간을 찾는 applyPreservedResponseBodies 만이 이를 되살릴 수 있고, + // 라벨 정확 일치만 보면 사람이 고친 라벨 때문에 구간을 못 찾아 마커로 퇴행한다. + $filled = str_replace('**응답 필드** (`data` 내부)', '**응답 필드**', $section); + $filled = preg_replace( + '/_단건 응답[^\n]*_\n\n/', + '_이 엔드포인트는 봉투를 사용하지 않고 레이아웃 JSON 을 root 에 그대로 반환합니다._', + $filled + ); + $this->assertStringNotContainsString('실측 응답에 필드 없음', $filled, '테스트 픽스처가 마커를 실제로 대체했는지 확인'); + + $existing = "# 레이아웃\n\n".$filled; + + $merged = $scaffolder->mergeDocument($existing, '# 레이아웃', [$section], ['api.layouts.preview']); + + $this->assertStringContainsString('봉투를 사용하지 않고', $merged, '라벨을 고친 문서의 사람 본문이 보존되어야 한다'); + $this->assertStringNotContainsString('실측 응답에 필드 없음', $merged, '사람 본문이 마커로 퇴행하면 안 된다'); + } + + #[Test] + public function 에러_응답_자리의_사람_서술은_대표_에러_없음_초안에_덮이지_않는다(): void + { + // 회귀: 자동 추론이 에러 행을 하나도 만들지 못하는 공개 조회 엔드포인트는 + // `_대표 에러 없음 (공개 조회). _` 초안을 낸다. 사람이 그 자리를 + // "이 엔드포인트는 도메인 에러를 반환하지 않습니다 (근거...)" 같은 서술로 채우면, + // 표 행이 없으므로 errorTableRows() 가 빈 배열을 반환해 보존이 건너뛰어지고 + // 재생성 때마다 TODO 초안으로 퇴행한다. (실측: locales.md / modules.md / plugins.md) + $scaffolder = new ApiDocScaffolder; + + $route = [ + 'method' => 'GET', 'uri' => '/api/locales', 'name' => 'api.locales.index', + 'controller' => 'C', 'controller_method' => 'index', 'permission' => null, + 'middleware' => ['api'], 'path_params' => [], + ]; + $request = ['request_class' => null, 'params' => [], 'hook_filters' => []]; + $schema = ['envelope' => ['data'], 'shape' => 'collection', 'pagination' => false, + 'fields' => [['name' => 'code', 'type' => 'string', 'sample' => 'ko']]]; + + $section = $scaffolder->endpointSection($route, $request, $schema, ['status' => 200, 'skipped_reason' => null]); + $this->assertStringContainsString('대표 에러 없음', $section, '자동 초안 전제 확인'); + + $prose = '_이 엔드포인트는 도메인 에러를 반환하지 않습니다. 공개 조회이므로 인증 실패(401)가 없습니다._'; + $filled = preg_replace('/_대표 에러 없음[^\n]*_/', $prose, $section); + $existing = "# 로케일\n\n".$filled; + + $merged = $scaffolder->mergeDocument($existing, '# 로케일', [$section], ['api.locales.index']); + + $this->assertStringContainsString('도메인 에러를 반환하지 않습니다', $merged, '사람이 쓴 에러 서술이 보존되어야 한다'); + $this->assertStringNotContainsString('대표 에러 없음', $merged, '사람 서술이 자동 초안으로 퇴행하면 안 된다'); + } + + #[Test] + public function crl_f_문서에서도_사람이_채운_표_셀을_보존한다(): void + { + // 회귀: Windows 체크아웃(core.autocrlf=true)에서 문서가 CRLF 로 저장되면 + // parseTableRow 의 `str_ends_with($line, ' |')` 가드가 줄 끝 \r 때문에 전부 실패해 + // 표 셀 보존이 통째로 무력화된다 → 사람이 채운 설명이 재생성 때마다 TODO 로 퇴행. + // (실측: docs/backend/api/notifications.md 의 응답 필드 설명 10건 유실) + $scaffolder = new ApiDocScaffolder; + + $route = [ + 'method' => 'GET', 'uri' => '/api/admin/notifications', 'name' => 'api.admin.notifications.index', + 'controller' => 'C', 'controller_method' => 'index', 'permission' => null, + 'middleware' => ['api', 'auth:sanctum'], 'path_params' => [], + ]; + $request = ['request_class' => null, 'params' => [], 'hook_filters' => []]; + $schema = [ + 'envelope' => ['data'], 'shape' => 'collection', 'pagination' => false, + 'fields' => [['name' => 'type', 'type' => 'string', 'sample' => 'new_order_admin']], + ]; + + $section = $scaffolder->endpointSection($route, $request, $schema, ['status' => 200, 'skipped_reason' => null]); + + // 사람이 설명을 채운 기존 문서를 CRLF 로 구성한다. + $filled = str_replace( + '', + '알림 유형 식별자 (알림 정의의 type)', + $section + ); + $existingCrlf = str_replace("\n", "\r\n", "# 알림\n\n".$filled); + + $merged = $scaffolder->mergeDocument( + $existingCrlf, + '# 알림', + [$section], + ['api.admin.notifications.index'], + ); + + $this->assertStringContainsString('알림 유형 식별자', $merged, 'CRLF 문서의 사람 서술이 보존되어야 한다'); + $this->assertStringNotContainsString('', $merged, '보존된 셀이 TODO 로 퇴행하면 안 된다'); + } + + #[Test] + public function 에러표의_권한_셀은_파이프를_이스케이프한다(): void + { + // 회귀: permission 이 여러 권한의 OR 조합(`core.modules.read | core.menus.read`)일 때 + // 파이프를 이스케이프하지 않으면 셀이 갈라져 3열 표가 4열로 깨진다. + // (docs/backend/api/modules.md 의 403 행 24건이 이 상태로 생성돼 있었다) + $scaffolder = new ApiDocScaffolder; + + $route = [ + 'method' => 'GET', + 'uri' => '/api/admin/modules', + 'name' => 'api.admin.modules.index', + 'controller' => 'C', + 'controller_method' => 'index', + 'permission' => 'core.modules.read | core.menus.read', + 'middleware' => ['api', 'auth:sanctum', 'permission:admin,core.modules.read'], + 'path_params' => [], + ]; + $request = ['request_class' => null, 'params' => [], 'hook_filters' => []]; + $schema = ['envelope' => ['data'], 'shape' => 'collection', 'fields' => [['name' => 'id', 'type' => 'integer', 'sample' => '1']], 'pagination' => false]; + + $section = $scaffolder->endpointSection($route, $request, $schema, ['status' => 200, 'skipped_reason' => null]); + + // 403 행을 찾아 열 수를 센다 (이스케이프된 \| 는 셀 구분자가 아니다) + $line = collect(explode("\n", $section))->first(fn (string $l): bool => str_starts_with($l, '| 403 |')); + + $this->assertNotNull($line, '403 에러 행이 생성되어야 한다'); + $this->assertStringContainsString('\\|', $line, '권한 셀의 파이프가 이스케이프되어야 한다'); + + $cells = preg_split('/(?assertCount(3, $cells, "403 행은 3열(상태코드/의미/발생 조건)이어야 한다: {$line}"); + } } diff --git a/tests/Unit/Support/ApiDoc/ParameterDescriberTest.php b/tests/Unit/Support/ApiDoc/ParameterDescriberTest.php index b5b36f12..56fa8856 100644 --- a/tests/Unit/Support/ApiDoc/ParameterDescriberTest.php +++ b/tests/Unit/Support/ApiDoc/ParameterDescriberTest.php @@ -262,4 +262,72 @@ class ParameterDescriberTest extends TestCase $this->assertStringContainsString('SEO', $describer->describe('meta_description', 'body', 'string')); $this->assertStringContainsString('대체 텍스트', $describer->describe('alt_text', 'body', 'array')); } + + #[Test] + public function 중첩_파라미터를_leaf_세그먼트로_설명한다(): void + { + $describer = new ParameterDescriber; + + // 중첩 환경설정 키(general.*, mail.*, upload.*)는 leaf 가 의미를 결정한다. + // 부모 그룹명은 라벨일 뿐이므로 leaf 로 폴백해 공통 사전을 태운다. + $this->assertStringContainsString('호스트', $describer->describe('mail.host', 'body', 'string')); + $this->assertStringContainsString('포트', $describer->describe('mail.port', 'body', 'integer')); + $this->assertStringContainsString('타임존', $describer->describe('general.timezone', 'body', 'string')); + + // 회귀: 전체명 접미 매칭이 부모 세그먼트를 설명문에 흘리던 문제 + // (general.site_name => "general.site 이름 (식별자)") + $siteName = $describer->describe('general.site_name', 'body', 'string'); + $this->assertStringNotContainsString('general.', $siteName); + $this->assertStringContainsString('이름', $siteName); + } + + #[Test] + public function 로케일_접미_파라미터를_부모_설명_기준으로_설명한다(): void + { + $describer = new ParameterDescriber; + + // 다국어 필드(name.ko / alt_text.en)는 부모 필드의 로케일별 값이다. + $ko = $describer->describe('alt_text.ko', 'body', 'string'); + $this->assertStringContainsString('대체 텍스트', $ko); + $this->assertStringContainsString('ko', $ko); + + // 부모가 도메인 특이(사전 미등재)면 로케일 접미도 TODO 로 남는다. + $this->assertNull($describer->describe('refund_priority.ko', 'body', 'string')); + } + + #[Test] + public function 배열_원소_인덱스_파라미터는_상위_경로로_설명한다(): void + { + $describer = new ParameterDescriber; + + // items.*.name / ids.0 처럼 인덱스가 leaf 인 경우 상위 경로로 폴백한다. + $this->assertStringContainsString('식별자 배열', $describer->describe('ids.*', 'body', 'array')); + $this->assertStringContainsString('식별자 배열', $describer->describe('ids.0', 'body', 'integer')); + } + + #[Test] + public function seo_그룹의_중첩_키는_se_o_메타로_한정해_설명한다(): void + { + $describer = new ParameterDescriber; + + // 회귀: leaf 폴백만 태우면 seo_meta.title 이 "제목" 으로 축소돼 오설명이 된다. + // 부모가 의미를 한정하는 그룹(seo/seo_meta)은 leaf 를 SEO 맥락으로 해석해야 한다. + $this->assertStringContainsString('SEO', $describer->describe('seo_meta.title', 'body', 'string')); + $this->assertStringContainsString('SEO', $describer->describe('seo_meta.description', 'body', 'string')); + $this->assertStringContainsString('SEO', $describer->describe('seo_meta.keywords', 'body', 'string')); + $this->assertStringContainsString('SEO', $describer->describe('content.meta.seo.title', 'body', 'string')); + + // 다른 그룹의 title 은 일반 제목 그대로 (오적용 방지) + $this->assertStringNotContainsString('SEO', $describer->describe('basic_info.title', 'body', 'string')); + } + + #[Test] + public function 도메인_특이_중첩_파라미터는_null_로_남는다(): void + { + $describer = new ParameterDescriber; + + // leaf 폴백으로도 공통 사전에 없는 도메인 특이 키는 사람 서술(TODO) 대상이다. + $this->assertNull($describer->describe('mileage.earn_trigger', 'body', 'string')); + $this->assertNull($describer->describe('report_policy.auto_hide_target', 'body', 'string')); + } } diff --git a/tests/Unit/Support/ApiDoc/ResourceFieldDescriberTest.php b/tests/Unit/Support/ApiDoc/ResourceFieldDescriberTest.php index 5569f231..743d88da 100644 --- a/tests/Unit/Support/ApiDoc/ResourceFieldDescriberTest.php +++ b/tests/Unit/Support/ApiDoc/ResourceFieldDescriberTest.php @@ -267,4 +267,41 @@ class ResourceFieldDescriberTest extends TestCase // 정수가 아니면 미적용 (오설명 방지) $this->assertNull($describer->describe('depth', 'string')); } + + #[Test] + public function 중첩_응답_필드를_leaf_세그먼트로_설명한다(): void + { + $describer = new ResourceFieldDescriber; + + // 중첩 응답 필드(settings.general.site_url 등)는 leaf 가 의미를 결정한다. + $this->assertStringContainsString('생성 일시', $describer->describe('meta.created_at', 'string')); + $this->assertStringContainsString('개수', $describer->describe('stats.total', 'integer')); + + // 회귀: 전체명 접미 매칭이 부모 세그먼트를 설명문에 흘리던 문제 + $userId = $describer->describe('order.user_id', 'integer'); + $this->assertStringNotContainsString('order.', $userId); + $this->assertStringContainsString('식별자', $userId); + } + + #[Test] + public function 로케일_접미_응답_필드를_부모_설명_기준으로_설명한다(): void + { + $describer = new ResourceFieldDescriber; + + // 다국어 필드(name.ko)는 부모 필드의 로케일별 값이다. + $ko = $describer->describe('name.ko', 'string'); + $this->assertStringContainsString('이름', $ko); + $this->assertStringContainsString('ko', $ko); + + // 부모가 도메인 특이면 로케일 접미도 TODO 로 남는다. + $this->assertNull($describer->describe('refund_priority.ko', 'string')); + } + + #[Test] + public function 도메인_특이_중첩_응답_필드는_null_로_남는다(): void + { + $describer = new ResourceFieldDescriber; + + $this->assertNull($describer->describe('order.refund_priority', 'string')); + } } diff --git a/tests/scenarios/identity-challenge-status-field-exposure.yaml b/tests/scenarios/identity-challenge-status-field-exposure.yaml new file mode 100644 index 00000000..a31aaadb --- /dev/null +++ b/tests/scenarios/identity-challenge-status-field-exposure.yaml @@ -0,0 +1,40 @@ +# audit:allow test-scenario-coverage reason: 본 매니페스트는 challenge 상태 조회 응답의 노출 필드 SSoT. 회귀 가드는 test_files 의 Feature 테스트가 cover. +# e2e:allow 챌린지 진행 화면(auth/identity_challenge)의 프론트 변경은 서버가 더는 주지 않는 attempts 필드 참조를 제거한 것뿐이며, 남은 시도 횟수 UI 는 URL query fallback 과 verify 실패 시 로컬 증가로 동일하게 동작한다. 실질 회귀 대상(응답에서 attempts 제거)은 IdentityChallengeShowTest 가 검증하므로 브라우저 E2E 는 불요. + +feature: 본인인증 challenge 상태 조회 — 노출 필드 제한 (민감 필드 비노출) + +description: | + GET /api/identity/challenges/{id} 는 비동기 검증 흐름(외부 redirect 콜백 대기 등)에서 + 클라이언트가 진행 상태를 추적하기 위한 폴링 엔드포인트다. 이 라우트는 권한 가드 없이 + challenge id 만 알면 조회할 수 있으므로(로그인 불요), 응답에는 공개 안전 필드만 담아야 한다. + + 결함 배경: + - Service::getStatus 가 attempts / max_attempts 를 응답에 포함시켜 왔다. + - 두 값을 함께 알면 남은 시도 횟수를 정확히 계산할 수 있어, 인증번호 무차별 대입 시점을 + 노리는 단서가 된다. + + 조치: + - getStatus 응답에서 attempts / max_attempts 제거 (공개 안전 필드만 유지). + - 챌린지 진행 화면(auth/identity_challenge.json — admin_basic / basic)에서 서버 응답의 + attempts 참조를 제거. 남은 시도 횟수 UI 는 URL query fallback + verify 실패 시 로컬 + 증가로 유지되므로 사용자 가시 동작은 동일. + +axes: + field: [id, status, provider_id, purpose, render_hint, expires_at, public_payload, attempts, max_attempts, target_hash, verification_token, metadata] + requester: [guest_with_id, owner, admin] + +exclusions: + - { field: attempts, requester: owner, reason: "노출 여부는 requester 무관 — 필드 자체가 응답에서 제거됨" } + - { field: max_attempts, requester: owner, reason: "동일" } + +effects: + - public_safe_fields_present_in_response # id/status/provider_id/purpose/render_hint/expires_at/public_payload 노출 + - attempts_absent_from_response # 시도 횟수 비노출 (무차별 대입 힌트 차단) + - max_attempts_absent_from_response + - target_hash_absent_from_response # 기존 회귀 가드 유지 + - verification_token_absent_from_response + - metadata_absent_from_response + - challenge_screen_remaining_attempts_ui_works_via_query_fallback # 프론트: 서버 attempts 없이도 남은 횟수 UI 동작 + +test_files: + - tests/Feature/Api/Identity/IdentityChallengeShowTest.php