diff --git a/CHANGELOG.md b/CHANGELOG.md index 11344dc7..a75030fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,10 @@ ### Fixed +#### 설정 화면 입력 + +- 설정 화면에서 여러 입력칸을 고친 뒤 다른 탭에 갔다 돌아오면, 먼저 고친 칸에 입력했던 값이 그대로 남던 문제를 수정했습니다. 저장된 값은 원래 값이라 화면에 보이는 값과 실제 값이 어긋났고, 새로고침해야 드러났습니다. 이제 탭을 오갈 때 모든 입력칸이 저장된 값으로 함께 돌아옵니다. + #### 확장 업데이트 - 모듈·플러그인 업데이트에서 '수정 유지'를 선택해도 직접 고친 화면이 새 버전으로 덮어써지던 문제를 수정했습니다. 업데이트 과정이 보존 여부를 판단하기 전에 모든 화면을 파일 기준으로 먼저 되돌려, 비교할 수정본이 남지 않아 '수정 유지'가 항상 무효가 됐습니다. 이제 선택한 대로 수정한 화면이 그대로 유지됩니다. @@ -124,6 +128,7 @@ #### 설정 저장·입력 검증 +- 모듈·플러그인 설정의 숫자 항목이 숫자가 아닌 형태로 보관되어 있어도, 조회 시 설정 기본값과 같은 숫자 형태로 처리합니다. 이전에는 저장된 형태에 따라 기한 계산 같은 후속 처리에서 오류가 발생할 수 있었습니다. 숫자가 아닌 값, 참/거짓 항목, 목록 항목은 그대로 유지됩니다. 설정 안에 다시 묶여 있는 하위 항목까지 같은 규칙이 적용됩니다. - SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다. - 한글로 쓴 설명이 글자 수 제한에 걸리지 않는데도 "너무 깁니다" 로 거부되던 문제를 수정했습니다. 글자 수를 바이트 단위로 세는 바람에 한글 한 글자가 세 글자로 계산되어, 500자 제한에서 167자만 넘어도 저장이 막혔습니다. 역할·권한·모듈·플러그인·템플릿 설명에 모두 해당합니다. - 사용하던 언어팩을 끈 뒤 기존에 작성해 둔 내용을 수정하려 하면 저장이 통째로 막히던 문제를 수정했습니다. 꺼진 언어로 입력해 둔 번역이 남아 있으면 "지원하지 않는 언어" 로 거부되었습니다. 이제 그 번역은 그대로 보존한 채 수정할 수 있고, 언어팩을 다시 켜면 번역도 함께 되살아납니다. diff --git a/app/Console/Commands/PlaywrightIssueToken.php b/app/Console/Commands/PlaywrightIssueToken.php index cd140197..d64b9670 100644 --- a/app/Console/Commands/PlaywrightIssueToken.php +++ b/app/Console/Commands/PlaywrightIssueToken.php @@ -38,6 +38,7 @@ class PlaywrightIssueToken extends Command { protected $signature = 'playwright:issue-token {--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능} + {--no-admin-role : admin 역할을 부여하지 않고 지정한 권한만 가진 계정을 만든다 (권한 분기 검증용)} {--gc-hours=6 : 이 시간(시)보다 오래된 playwright 테스트 유저/역할을 발급 전 정리. 0 이면 정리 안 함}'; protected $description = 'Playwright E2E 용 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS 3중 가드)'; @@ -76,7 +77,9 @@ class PlaywrightIssueToken extends Command $permissions = $this->option('permissions') ?: []; - $user = $this->makeAdminUser($permissions); + // --no-admin-role: 지정한 권한만 가진 계정을 만든다. + // 기본값(플래그 없음)은 종전대로 admin 역할을 함께 부여한다 — 기존 spec 무영향. + $user = $this->makeAdminUser($permissions, ! $this->option('no-admin-role')); $token = $user->createToken('playwright-'.uniqid())->plainTextToken; $this->line($token); @@ -139,9 +142,18 @@ class PlaywrightIssueToken extends Command * 1. User factory 로 신규 유저 생성 * 2. 권한 식별자별로 Permission 행 보장 (firstOrCreate) * 3. uniqid 접미사로 격리된 test role 생성 + 권한 sync - * 4. admin role 보장 (firstOrCreate) + 유저-역할 부여 + * 4. (withAdminRole 일 때만) admin role 보장 (firstOrCreate) + 유저-역할 부여 + * + * `withAdminRole = false` 는 **권한 분기(읽기 전용 등) 검증 전용**이다. 기본값 true 는 + * admin 역할을 함께 붙이므로, 요청한 권한만 가진 세션을 만들 수 없다 — + * admin 역할이 사이트의 전체 권한을 보유하기 때문에 `--permissions` 로 좁혀도 + * 화면은 항상 최대 권한으로 렌더된다(실측: admin 역할 권한 263건). + * + * @param array $permissions 부여할 권한 식별자 목록 + * @param bool $withAdminRole admin 역할 동반 부여 여부 + * @return User 생성된 유저 */ - private function makeAdminUser(array $permissions): User + private function makeAdminUser(array $permissions, bool $withAdminRole = true): User { $user = User::factory()->create(); @@ -171,23 +183,28 @@ class PlaywrightIssueToken extends Command 'is_active' => true, ]); - $adminRole = Role::firstOrCreate( - ['identifier' => 'admin'], - [ - 'name' => ['ko' => '관리자', 'en' => 'Admin'], - 'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'], - 'extension_type' => ExtensionOwnerType::Core, - 'extension_identifier' => 'core', - 'type' => 'admin', - 'is_active' => true, - ] - ); - if (! empty($permissionIds)) { $testRole->permissions()->sync($permissionIds); } - $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + if ($withAdminRole) { + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + [ + 'name' => ['ko' => '관리자', 'en' => 'Admin'], + 'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'], + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'type' => 'admin', + 'is_active' => true, + ] + ); + + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + } + + // test role 은 항상 부여한다 — GC(pruneStaleTestArtifacts)가 이 역할로 테스트 계정을 + // 식별하므로, 빠지면 --no-admin-role 로 만든 계정이 영구 잔존한다. $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]); return $user->fresh(); diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php index 3bc9e0ab..eb69fc7f 100644 --- a/app/Services/AuthService.php +++ b/app/Services/AuthService.php @@ -549,7 +549,8 @@ class AuthService } // 4. 만료 시간 체크 - $expireMinutes = config('auth.passwords.users.expire', 60); + // Carbon 날짜 연산은 strict 타입 경계라 설정이 문자열이면 TypeError 가 난다 → 정수 보장 + $expireMinutes = (int) config('auth.passwords.users.expire', 60); if ($record->created_at->addMinutes($expireMinutes)->isPast()) { $record->delete(); @@ -598,7 +599,8 @@ class AuthService } // 만료 시간 체크 (기본 60분) - $expireMinutes = config('auth.passwords.users.expire', 60); + // Carbon 날짜 연산은 strict 타입 경계라 설정이 문자열이면 TypeError 가 난다 → 정수 보장 + $expireMinutes = (int) config('auth.passwords.users.expire', 60); if ($record->created_at->addMinutes($expireMinutes)->isPast()) { // 만료된 토큰 삭제 diff --git a/app/Traits/NormalizesSettingsData.php b/app/Traits/NormalizesSettingsData.php index d43472ec..c9a8e3a8 100644 --- a/app/Traits/NormalizesSettingsData.php +++ b/app/Traits/NormalizesSettingsData.php @@ -62,15 +62,42 @@ trait NormalizesSettingsData $settings[$key] = $this->convertStringToMultilingual($value, $defaultValue); } + // 기본값이 숫자인데 현재 값이 숫자 문자열인 경우 (HTML number 입력 등) + $numeric = $this->normalizeNumericScalar($value, $defaultValue); + if ($numeric !== null) { + $settings[$key] = $numeric; + } + // 배열 내부의 객체도 정규화 (currencies 같은 경우) - if (is_array($value) && is_array($defaultValue) && $this->isIndexedArray($value)) { - $settings[$key] = $this->normalizeArrayItems($value, $defaultValue); + if (is_array($value) && is_array($defaultValue)) { + $settings[$key] = $this->isIndexedArray($value) + ? $this->normalizeArrayItems($value, $defaultValue) + : $this->normalizeNestedGroup($value, $defaultValue); } } return $settings; } + /** + * 중첩된 연관 배열(설정 그룹)을 같은 규칙으로 재귀 정규화합니다. + * + * 인덱스 배열은 "같은 구조의 항목 목록" 이라 normalizeArrayItems 가 담당하지만, + * 연관 배열은 "하위 설정 그룹" 이므로 카테고리와 동일하게 다루어야 합니다. + * 이 분기가 없으면 그룹 한 단계 아래의 숫자 문자열이 정규화되지 않고 남습니다. + * + * 다국어 필드(`{"ko": "...", "en": "..."}`)도 연관 배열이지만, 기본값의 각 로케일 값이 + * 문자열이라 숫자 캐스트 조건에 걸리지 않아 그대로 통과합니다. + * + * @param array $group 중첩 설정 그룹 + * @param array $defaults 대응 기본값 그룹 + * @return array 정규화된 그룹 + */ + protected function normalizeNestedGroup(array $group, array $defaults): array + { + return $this->normalizeCategoryData($group, $defaults); + } + /** * 배열 내부 항목들을 정규화합니다. * @@ -125,11 +152,55 @@ trait NormalizesSettingsData if (is_array($defaultValue) && is_string($value)) { $item[$key] = $this->convertStringToMultilingual($value, $defaultValue); } + + // 기본값이 숫자인데 현재 값이 숫자 문자열인 경우 (환율/소수 자릿수 등) + $numeric = $this->normalizeNumericScalar($value, $defaultValue); + if ($numeric !== null) { + $item[$key] = $numeric; + } + + // 항목 내부의 중첩 그룹도 동일 규칙으로 정규화 + if (is_array($value) && is_array($defaultValue) && ! $this->isIndexedArray($value)) { + $item[$key] = $this->normalizeNestedGroup($value, $defaultValue); + } } return $item; } + /** + * 숫자 기본값을 가진 설정의 숫자 문자열을 스칼라 숫자로 정규화합니다. + * + * HTML number 입력의 DOM 값은 문자열이고 검증 규칙(integer/numeric)은 숫자 문자열을 + * 통과시키되 캐스트하지 않으므로, 설정 파일에 문자열로 영속될 수 있습니다. + * 그 값이 Carbon 처럼 strict 타입을 요구하는 경계에 닿으면 TypeError 가 발생하므로 + * 조회 시점에 defaults 스키마의 타입으로 되돌립니다. + * + * 기본값이 int/float 스칼라이고 값이 숫자 문자열일 때만 변환하며, + * null·불리언·배열·비숫자 문자열은 변경하지 않습니다. + * + * @param mixed $value 현재 설정값 + * @param mixed $defaultValue defaults 스키마의 기본값 + * @return int|float|null 정규화된 값 (대상이 아니면 null) + */ + protected function normalizeNumericScalar(mixed $value, mixed $defaultValue): int|float|null + { + if (! is_string($value) || ! is_numeric(trim($value))) { + return null; + } + + // is_int/is_float 는 불리언을 포함하지 않으므로 bool 기본값은 자동 제외된다. + if (is_int($defaultValue)) { + return (int) trim($value); + } + + if (is_float($defaultValue)) { + return (float) trim($value); + } + + return null; + } + /** * 문자열을 다국어 배열로 변환합니다. * diff --git a/docs/testing/e2e-testing.md b/docs/testing/e2e-testing.md index 9b0cd666..06290ada 100644 --- a/docs/testing/e2e-testing.md +++ b/docs/testing/e2e-testing.md @@ -117,7 +117,7 @@ spec 이 전부 실패한다. | 데이터 종류 | 책임 영역 | 위치 | 호출 | |---|---|---|---| -| 코어 권한/역할/유저/Sanctum 토큰 | 코어 | `app/Console/Commands/PlaywrightIssueToken.php` | `php artisan playwright:issue-token --permissions=core.xxx` | +| 코어 권한/역할/유저/Sanctum 토큰 | 코어 | `app/Console/Commands/PlaywrightIssueToken.php` | `php artisan playwright:issue-token --permissions=core.xxx` (권한 경계 검증은 `--no-admin-role` 추가 — §5.1) | | 편집기 저장 spec 대상 시드 화면 | 코어 | `app/Console/Commands/PlaywrightSeedLayout.php` | `php artisan playwright:seed-layout [--remove]` (globalSetup/globalTeardown 자동 호출) | | 모듈 권한 (`sirsoft-ecommerce.*`) | 모듈 | 코어 커맨드의 `--permissions=` 임의 식별자 | 동일 (Permission::firstOrCreate 자동 생성) | | 모듈 도메인 데이터 (상품/주문) | 모듈 | `modules/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | `php artisan playwright:seed-{id}` | @@ -223,6 +223,28 @@ export const test = base.extend({ }); ``` +#### 권한 경계를 검증할 때는 `issueScopedToken` + +`issueToken` 은 커맨드 기본 동작대로 사이트의 `admin` 역할을 함께 부여한다. `admin` 역할은 전체 +권한을 보유하므로, `--permissions` 로 권한을 좁혀 넘겨도 **화면은 항상 최대 권한으로 렌더된다**. +읽기 전용 분기·권한 미보유 분기처럼 권한 경계 자체를 검증하려면 `issueScopedToken` 을 쓴다 — +`--no-admin-role` 을 붙여 지정한 권한만 가진 계정을 만든다. + +```typescript +// ❌ 읽기 전용 분기를 만들 수 없다 — admin 역할이 update 권한까지 함께 부여된다 +await authenticatePage(page, issueToken('sirsoft-ecommerce.settings.read')); +await expect(page.locator('input[name="..."]')).toBeDisabled(); // 실패: enabled + +// ✅ 지정한 권한만 가진 계정 +await authenticatePage(page, issueScopedToken('sirsoft-ecommerce.settings.read')); +await expect(page.locator('input[name="..."]')).toBeDisabled(); // 통과 +``` + +권한을 좁힌 계정은 코어 메뉴·알림 데이터소스에도 접근하지 못해 콘솔에 403 이 남을 수 있다. +그 화면 자체의 검증과 무관한 잡음이므로, 콘솔 에러 0 을 단언하는 테스트에는 이 토큰을 쓰지 않는다. +권한 밖 탭·라우트로 이동하면 403 에러 페이지로 전환되므로, 왕복 시나리오는 그 권한으로 접근 +가능한 대상만 경유해야 한다. + ### 5.2 확장 fixture — 권한 + 시드 분리 ```typescript 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 7714efaf..6276b040 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,17 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.8] - 2026-08-01 + +### Added + +- 무통장입금 자동취소 기한을 비운 채 저장할 때 나오는 안내 문구 일본어 번역 추가. +- 유효기간이 설정되지 않은 쿠폰을 발급할 때 나오는 안내 문구 일본어 번역 추가. + +### Fixed + +- 자동취소 기한 안내가 실제 입력 하한(1일)과 다른 "0일 이상"으로 표시되던 일본어 문구를 수정했습니다. + ## [1.0.7] - 2026-07-31 ### Added 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 d1661982..0dfa3ab6 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 @@ -311,6 +311,7 @@ return [ 'not_downloadable' => 'ダウンロードできないクーポンです。', 'quantity_exhausted' => 'クーポン数量が尽きました。', 'issue_period_expired' => 'クーポン発行期間が終了しました。', + 'validity_not_configured' => 'クーポンの有効期間が設定されていないため発行できません。管理者にお問い合わせください。', 'expired' => '有効期限切れのクーポンです。', 'min_amount_not_met' => '最小注文金額の条件を満たしていません。', 'not_combinable' => '他のクーポンと併用できないクーポンです。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index 4717a939..c0e72585 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -1577,8 +1577,9 @@ return [ 'boolean' => '未決済の自動キャンセルの可否は真偽値である必要があります。', ], 'auto_cancel_days' => [ + 'required' => '自動キャンセルの期限を入力してください。', 'integer' => '自動キャンセルの期限は整数である必要があります。', - 'min' => '自動キャンセルの期限は0日以上である必要があります。', + 'min' => '自動キャンセルの期限は1日以上である必要があります。', 'max' => '自動キャンセルの期限は最大30日まで設定可能です。', ], 'cart_expiry_days' => [ diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index 79f8ebbd..feecaa32 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.0.7", + "version": "1.0.8", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 291890e7..bfcf6ab3 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -28,6 +28,7 @@ ### Fixed +- 게시판 설정의 '새 글 표시 시간'이 저장 직후에는 숫자가 아닌 형태로 다뤄져, 조회 시점에 따라 값의 형태가 달라지던 문제를 수정했습니다. 새 글 표시 여부 판정에 쓰이는 값이므로 항상 숫자로 처리합니다. - 글을 쓰면서 파일을 함께 첨부하면 글은 저장되는데 첨부파일만 사라지던 문제를 수정했습니다. 첨부 개수·용량·형식 검사와 권한 확인은 모두 통과한 뒤 저장 단계에서만 빠져, 등록된 글에 첨부가 하나도 남지 않았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 게시판 신고 정책의 자동 숨김 기준 횟수를 0(자동 숨김 사용 안 함)으로 입력할 수 없던 문제를 수정했습니다. 서버는 0을 "사용 안 함"으로 처리하는데 화면에서만 1 이상을 요구해, 0을 입력하면 저장은 되면서도 입력칸이 계속 오류 상태로 남았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) diff --git a/modules/_bundled/sirsoft-board/src/Models/Board.php b/modules/_bundled/sirsoft-board/src/Models/Board.php index 5759b9ce..0e0afef0 100644 --- a/modules/_bundled/sirsoft-board/src/Models/Board.php +++ b/modules/_bundled/sirsoft-board/src/Models/Board.php @@ -180,6 +180,8 @@ class Board extends Model 'use_comment' => 'boolean', 'use_reply' => 'boolean', 'max_reply_depth' => 'integer', + // Post::isNew() 가 Carbon 시간 연산에 넘기는 값 — 조회 시점과 무관하게 정수를 보장한다. + 'new_display_hours' => 'integer', 'use_report' => 'boolean', 'use_file_upload' => 'boolean', 'notify_author' => 'boolean', @@ -329,8 +331,9 @@ class Board extends Model * * g7_permissions 및 role_permissions 테이블에서 권한 정보를 조회하여 * 각 권한별로 할당된 역할 identifier 배열을 반환합니다. + * (접근자 결과: 키 permission_key, 값 [role_identifiers] 또는 null=전체 허용) * - * @return array 권한 정보 (키: permission_key, 값: [role_identifiers] or null) + * @return Attribute 권한 정보 접근자 */ protected function permissions(): Attribute { diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Models/NewDisplayHoursCastTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Models/NewDisplayHoursCastTest.php new file mode 100644 index 00000000..79896ffc --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Models/NewDisplayHoursCastTest.php @@ -0,0 +1,82 @@ + '48']); + + $this->assertSame(48, $board->new_display_hours); + } + + #[Test] + public function new_display_hours_is_integer_after_create_without_refresh(): void + { + $board = Board::create([ + 'slug' => 'cast-new-display-hours', + 'name' => ['ko' => '캐스트 테스트', 'en' => 'Cast Test'], + 'is_active' => true, + 'new_display_hours' => '48', + ]); + + $this->assertSame(48, $board->new_display_hours); + } + + #[Test] + public function new_display_hours_is_cast_to_integer_when_loaded_from_db(): void + { + DB::table('boards')->where('id', $this->board->id)->update(['new_display_hours' => 12]); + + $board = Board::findOrFail($this->board->id); + + $this->assertSame(12, $board->new_display_hours); + } + + #[Test] + public function post_is_new_does_not_crash_when_board_attribute_is_string(): void + { + $postId = $this->createTestPost(); + $post = Post::with('board')->findOrFail($postId); + + // 문자열 유입 재현 (캐스트 도입 후 정수로 해석되어야 함 — 비회귀 가드) + $post->board->new_display_hours = '24'; + + $this->assertTrue($post->isNew()); + } + + #[Test] + public function post_is_new_returns_false_for_old_post(): void + { + $postId = $this->createTestPost(); + DB::table('board_posts')->where('id', $postId)->update(['created_at' => now()->subHours(5)]); + + $post = Post::with('board')->findOrFail($postId); + $post->board->new_display_hours = '1'; + + $this->assertFalse($post->isNew()); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 2599086c..7246fc03 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -34,6 +34,12 @@ ### Fixed +- 미입금 자동취소 기한을 비운 채 저장하면 "저장되었습니다"라고 안내되면서도 값은 사라지던 문제를 수정했습니다. 숫자 항목에 숫자가 아닌 값을 입력하면 입력칸이 비워지는데, 그대로 저장하면 설정이 없는 상태로 남아 화면에 보이는 값과 실제 동작이 어긋났습니다. 이제 비워 둔 채로는 저장되지 않고 값을 입력하라고 안내합니다. +- 미입금 자동취소 기한 입력 안내가 실제 입력 하한과 다른 "0일 이상"으로 표시되던 문구를 "1일 이상"으로 바로잡았습니다. +- 쿠폰 수정 화면에서 유효기간을 "발급일로부터"로 두고 일수를 비운 채 저장할 수 있던 문제를 수정했습니다. 그렇게 저장된 쿠폰은 발급되는 즉시 만료 상태가 되어, 받은 회원이 사용할 수 없으면서도 아무 오류가 나지 않았습니다. 이제 저장 단계에서 일수를 입력하도록 안내하고, 이미 그렇게 저장돼 있던 쿠폰은 발급 시 유효기간이 설정되지 않았다고 알려 줍니다. +- 리뷰 작성 기한 판정이 화면 표시와 실제 저장 가능 여부에서 서로 다른 기준을 쓸 수 있던 문제를 수정했습니다. 이제 두 곳이 같은 설정값을 사용하므로 "작성 가능"으로 보이는데 저장이 거부되는 일이 없습니다. +- 관리자 환경설정에서 미입금 자동취소 기한을 한 번이라도 저장하면, 이후 모든 무통장입금·가상계좌 주문이 결제 처리 오류로 실패하던 문제를 수정했습니다. 저장된 기한 값의 형태 때문에 입금기한을 계산하지 못한 것으로, 이제 저장 형태와 무관하게 주문이 정상 생성됩니다. 기한이 비어 있거나 허용 범위(1~30일)를 벗어난 값이면 기본값 3일로 처리합니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.) +- 설정 화면에서 숫자 항목(기한·수량·금액 등)을 저장할 때 값이 숫자가 아닌 형태로 보관되던 문제를 수정했습니다. 화면 표시는 같았지만 저장된 형태가 달라, 그 값을 사용하는 기능에서 오류가 날 수 있었습니다. - 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 로그인하지 않은 상태로 장바구니에 담아 둔 상품이 로그인 후 사라지던 문제를 수정했습니다. 로그인 시 장바구니를 회원 계정으로 옮기는 처리가 실행되지 않아, 담아 둔 상품이 아무 안내 없이 없어졌습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 설정 화면이 허용하는 값과 저장 규칙이 서로 달라 저장이 거부되거나 그 반대가 되던 항목들을 맞췄습니다. 리뷰 이미지 최대 개수(최대 20개), 리뷰 이미지 최대 크기(정수 MB, 최대 50MB), 마일리지 기본 적립률(최대 100%), 미입금 자동취소 기한(최소 1일 — 0일은 주문 즉시 만료라 사용할 수 없는 값)이 대상입니다. 정수만 받는 항목은 입력칸의 증감 단위도 1로 맞춰, 화살표로 값을 올리다 소수가 되어 저장이 거부되는 일이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md index a2293b6e..643854ab 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md @@ -2050,6 +2050,11 @@ HTTP/1.1 200 **설명** 관리자가 이커머스 환경설정을 저장합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, `_tab` 으로 저장할 카테고리를 지정하고 각 섹션(`basic_info`·`shipping`·`claim` 등)을 배열로 전달합니다. `EcommerceSettingsService::saveSettings()`가 JSON 설정을 저장하되, DB 관리 대상인 `shipping.carriers`·`shipping.types`·`claim.refund_reasons` 는 분리해 각 Service 의 sync 메서드로 동기화합니다. 저장 성공 시 `sirsoft-ecommerce.settings.after_save` 훅을 발화하고, 관리자 UI 상태 갱신을 위해 병합된 전체 설정을 다시 반환합니다. +**숫자 필드 정규화** 검증 규칙에 `integer` / `numeric` 이 선언된 모든 필드는 검증 직전에 숫자 타입으로 캐스트되어 저장됩니다(중첩 배열의 와일드카드 경로 포함 — 예: `mileage.currency_rules.*.use_unit`). HTML `number` 입력의 값은 문자열(`"5"`)로 전송되고 Laravel 의 `integer` 규칙은 숫자 문자열을 통과시키되 캐스트하지 않으므로, 정규화가 없으면 문자열이 그대로 설정 파일에 영속되어 이후 날짜/수치 연산에서 타입 오류를 유발합니다. + +- 정규화 대상: 정수 표기 문자열(`"5"`, `"05"`) → `int`, 소수 표기 문자열(`"1.5"`) → `float`(단, `numeric` 필드에 한함) +- 정규화 제외: 비숫자 문자열(`"abc"`), 빈 문자열, `null`, 불리언, 그리고 `integer` 필드에 전달된 소수 문자열(`"3.7"`) — 검증을 느슨하게 만들지 않기 위해 캐스트하지 않고 그대로 검증 실패시킵니다 +- 조회(`GET`) 응답도 `defaults.json` 스키마의 숫자 타입으로 정규화되어 반환되므로, 저장 시점의 표현 형태와 무관하게 숫자로 수신됩니다 ### PUT /api/modules/sirsoft-ecommerce/admin/settings/banks diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponNotIssuableException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponNotIssuableException.php new file mode 100644 index 00000000..db171827 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponNotIssuableException.php @@ -0,0 +1,41 @@ +reason; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/Concerns/ValidatesCouponValidityPair.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/Concerns/ValidatesCouponValidityPair.php new file mode 100644 index 00000000..6affc0d7 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/Concerns/ValidatesCouponValidityPair.php @@ -0,0 +1,84 @@ +has('valid_type') && ! $this->has('valid_days')) { + return; + } + + $validator->after(function (Validator $validator) { + $coupon = $this->existingCouponForValidity(); + + $effectiveType = $this->has('valid_type') + ? $this->input('valid_type') + : $coupon?->valid_type; + + if ($effectiveType !== 'days_from_issue') { + return; + } + + $effectiveDays = $this->has('valid_days') + ? $this->input('valid_days') + : $coupon?->valid_days; + + if (is_numeric($effectiveDays) && (int) $effectiveDays > 0) { + return; + } + + $validator->errors()->add( + 'valid_days', + __('sirsoft-ecommerce::validation.coupon.valid_days_required') + ); + }); + } + + /** + * 승계 대상이 되는 기존 쿠폰을 조회합니다. + * + * 생성 요청에는 라우트 키가 없으므로 항상 null 이며, 그때는 요청값만으로 판정합니다. + * + * @return Coupon|null 라우트가 가리키는 쿠폰 (생성 요청·미존재 시 null) + */ + protected function existingCouponForValidity(): ?Coupon + { + $id = $this->route('id'); + + if ($id === null) { + return null; + } + + return app(CouponRepositoryInterface::class)->findById((int) $id); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreCouponRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreCouponRequest.php index 17785ecb..48d41ddb 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreCouponRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreCouponRequest.php @@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Enums\CouponIssueStatus; use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope; use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\Concerns\ValidatesCouponTargetScope; +use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\Concerns\ValidatesCouponValidityPair; use Modules\Sirsoft\Ecommerce\Models\Category; use Modules\Sirsoft\Ecommerce\Models\Product; @@ -25,6 +26,7 @@ use Modules\Sirsoft\Ecommerce\Models\Product; class StoreCouponRequest extends FormRequest { use ValidatesCouponTargetScope; + use ValidatesCouponValidityPair; /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 @@ -44,6 +46,7 @@ class StoreCouponRequest extends FormRequest public function withValidator(Validator $validator): void { $this->validateTargetScopeSelection($validator); + $this->validateValidityPair($validator); } /** @@ -102,7 +105,10 @@ class StoreCouponRequest extends FormRequest // 유효기간 'valid_type' => 'required|string|in:period,days_from_issue', - 'valid_days' => 'nullable|required_if:valid_type,days_from_issue|integer|min:1', + // days_from_issue 쌍의 정합성은 ValidatesCouponValidityPair 가 Update 와 공통으로 판정한다. + // 규칙 배열의 required_if 는 조건 필드가 요청에 없으면 발화하지 않아 부분 수정 경로에서 + // 우회로가 되므로, 두 요청이 같은 trait 를 쓰도록 정책을 한 곳으로 모았다. + 'valid_days' => 'nullable|integer|min:1', 'valid_from' => 'nullable|required_if:valid_type,period|date', 'valid_to' => 'nullable|required_if:valid_type,period|date|after_or_equal:valid_from', diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php index 12f4cd57..2aa94ffa 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php @@ -27,6 +27,138 @@ class StoreEcommerceSettingsRequest extends FormRequest return true; } + /** + * 검증 전 입력 데이터 정규화 + * + * HTML number 입력의 DOM 값은 문자열이고, Laravel 의 integer/numeric 규칙은 숫자 문자열을 + * 통과시키되 캐스트하지 않는다. 캐스트하지 않으면 문자열이 그대로 설정 파일에 영속되어, + * 이후 Carbon 같은 strict 타입 경계에서 TypeError 가 발생한다(무통장입금 주문 500 실패). + * + * 대상 필드는 rules() 에서 파생한다 — 손으로 열거하면 규칙이 추가될 때 누락된다. + */ + protected function prepareForValidation(): void + { + $data = $this->all(); + $changed = false; + + foreach ($this->rules() as $field => $fieldRules) { + $castType = $this->numericCastTypeFor($fieldRules); + if ($castType === null) { + continue; + } + + $changed = $this->castNumericPath($data, explode('.', $field), $castType) || $changed; + } + + if ($changed) { + $this->replace($data); + } + } + + /** + * 검증 규칙 목록에서 숫자 캐스트 종류를 판정합니다. + * + * @param mixed $fieldRules 단일 필드의 검증 규칙 + * @return string|null 'integer' | 'numeric' | null(캐스트 대상 아님) + */ + private function numericCastTypeFor(mixed $fieldRules): ?string + { + $list = is_array($fieldRules) ? $fieldRules : explode('|', (string) $fieldRules); + + $castType = null; + foreach ($list as $rule) { + if (! is_string($rule)) { + continue; + } + if ($rule === 'integer') { + return 'integer'; + } + if ($rule === 'numeric') { + $castType = 'numeric'; + } + } + + return $castType; + } + + /** + * 도트 경로(와일드카드 `*` 포함)를 따라가며 숫자 문자열을 캐스트합니다. + * + * @param array $data 대상 데이터 (참조로 변경) + * @param array $segments 경로 세그먼트 + * @param string $castType 'integer' | 'numeric' + * @return bool 값이 하나라도 변경되었으면 true + */ + private function castNumericPath(array &$data, array $segments, string $castType): bool + { + $segment = array_shift($segments); + + if ($segment === '*') { + $changed = false; + foreach ($data as $key => $unused) { + if (is_array($data[$key])) { + $changed = $this->castNumericPath($data[$key], $segments, $castType) || $changed; + } + } + + return $changed; + } + + if (! array_key_exists($segment, $data)) { + return false; + } + + if ($segments !== []) { + if (! is_array($data[$segment])) { + return false; + } + + return $this->castNumericPath($data[$segment], $segments, $castType); + } + + $cast = $this->castNumericValue($data[$segment], $castType); + if ($cast === null) { + return false; + } + + $data[$segment] = $cast; + + return true; + } + + /** + * 단일 값을 숫자로 캐스트합니다. + * + * 검증을 느슨하게 만들지 않기 위해, integer 필드는 정수 표기 문자열만 캐스트한다 + * (예: "3.7" 은 캐스트하지 않아 integer 규칙에서 그대로 실패해야 한다). + * + * @param mixed $value 원본 값 + * @param string $castType 'integer' | 'numeric' + * @return int|float|null 캐스트 결과 (대상이 아니면 null) + */ + private function castNumericValue(mixed $value, string $castType): int|float|null + { + if (! is_string($value)) { + return null; + } + + $trimmed = trim($value); + if ($trimmed === '') { + return null; + } + + if ($castType === 'integer') { + return preg_match('/^[+-]?\d+$/', $trimmed) === 1 ? (int) $trimmed : null; + } + + if (! is_numeric($trimmed)) { + return null; + } + + // numeric 필드는 정수 표기를 int 로 유지해 저장 round-trip 타입 오염을 막는다. + return preg_match('/^[+-]?\d+$/', $trimmed) === 1 ? (int) $trimmed : (float) $trimmed; + } + /** * 요청에 적용할 검증 규칙 * @@ -133,7 +265,12 @@ class StoreEcommerceSettingsRequest extends FormRequest 'order_settings.bank_accounts.*.is_default' => ['nullable', 'boolean'], 'order_settings.auto_cancel_expired' => ['nullable', 'boolean'], // 0 은 now()->addDays(0) = 즉시 만료라 실질적으로 사용할 수 없는 값이다 → UI(min:1)가 옳다 - 'order_settings.auto_cancel_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.auto_cancel_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.auto_cancel_days_max', 30)], + // nullable 금지: 숫자칸에 비숫자를 넣으면 브라우저가 값을 "" 로 비우고, + // ConvertEmptyStringsToNull 로 null 이 되어 "저장되었습니다" 를 띄운 채 값만 사라진다. + // 이후 소비처의 숨은 기본값(3일)으로 동작해 화면 표시와 실제 동작이 어긋난다. + // sometimes 필수: rules() 는 탭 구분 없이 적용되므로 무조건 required 로 두면 + // 이 키를 보내지 않는 다른 탭(마일리지 등) 저장이 통째로 막힌다. 키가 온 경우에만 필수. + 'order_settings.auto_cancel_days' => ['sometimes', 'required', 'integer', 'min:'.config('sirsoft-ecommerce.limits.auto_cancel_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.auto_cancel_days_max', 30)], 'order_settings.cart_expiry_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.cart_expiry_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.cart_expiry_days_max', 365)], 'order_settings.stock_restore_on_cancel' => ['nullable', 'boolean'], 'order_settings.confirmable_statuses' => ['nullable', 'array'], @@ -1030,6 +1167,7 @@ class StoreEcommerceSettingsRequest extends FormRequest 'order_settings.bank_accounts.*.is_active.boolean' => __('sirsoft-ecommerce::validation.custom.order_settings.bank_accounts.is_active.boolean'), 'order_settings.bank_accounts.*.is_default.boolean' => __('sirsoft-ecommerce::validation.custom.order_settings.bank_accounts.is_default.boolean'), 'order_settings.auto_cancel_expired.boolean' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_expired.boolean'), + 'order_settings.auto_cancel_days.required' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.required'), 'order_settings.auto_cancel_days.integer' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.integer'), 'order_settings.auto_cancel_days.min' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.min'), 'order_settings.auto_cancel_days.max' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.max'), diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php index 001aca77..e786735f 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php @@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Enums\CouponIssueStatus; use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope; use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\Concerns\ValidatesCouponTargetScope; +use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\Concerns\ValidatesCouponValidityPair; use Modules\Sirsoft\Ecommerce\Models\Category; use Modules\Sirsoft\Ecommerce\Models\Product; @@ -25,6 +26,7 @@ use Modules\Sirsoft\Ecommerce\Models\Product; class UpdateCouponRequest extends FormRequest { use ValidatesCouponTargetScope; + use ValidatesCouponValidityPair; /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 @@ -44,6 +46,7 @@ class UpdateCouponRequest extends FormRequest public function withValidator(Validator $validator): void { $this->validateTargetScopeSelection($validator); + $this->validateValidityPair($validator); } /** @@ -102,11 +105,12 @@ class UpdateCouponRequest extends FormRequest // 다른 탭만 고치는 요청도 1인당 사용 제한을 매번 실어 보내야 저장된다. 'per_user_limit' => 'sometimes|required|integer|min:0', - // 유효기간 — 조건부 규칙은 StoreCouponRequest 와 동일해야 한다. - // required_if 는 조건 필드(valid_type)가 요청에 없으면 발화하지 않으므로 - // 부분 수정을 깨지 않으면서 "기간 지정인데 기간이 비어 있는" 저장만 차단한다. + // days_from_issue 쌍의 정합성은 ValidatesCouponValidityPair 가 Store 와 공통으로 판정한다. + // 규칙 배열의 required_if 는 조건 필드(valid_type)가 요청에 없으면 발화하지 않아, + // `valid_days: null` 만 보내는 부분 수정이 그대로 통과한다(실측). 저장된 유형이 + // days_from_issue 인 쿠폰이 그 경로로 일수를 잃으면 발급 즉시 만료되는 쿠폰이 조용히 나간다. 'valid_type' => 'sometimes|required|string|in:period,days_from_issue', - 'valid_days' => 'nullable|required_if:valid_type,days_from_issue|integer|min:1', + 'valid_days' => 'nullable|integer|min:1', 'valid_from' => 'nullable|required_if:valid_type,period|date', 'valid_to' => 'nullable|required_if:valid_type,period|date|after_or_equal:valid_from', diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderOptionResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderOptionResource.php index 0fb70e75..49233171 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderOptionResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderOptionResource.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices; use Modules\Sirsoft\Ecommerce\Models\ShippingType; +use Modules\Sirsoft\Ecommerce\Support\ReviewWritePolicy; /** * 주문 옵션 리소스 @@ -287,17 +288,11 @@ class OrderOptionResource extends BaseApiResource /** * 구매확정 시점 기준 리뷰 작성 기한이 지났는지 판정 * - * ProductReviewService::canWrite 와 동일한 경계 비교를 사용한다 - * (confirmed_at + N일 < now). N(write_deadline_days)이 0 이하면 무제한으로 간주. + * 판정 규칙은 ReviewWritePolicy 단일 SSoT 를 따른다 — 화면 표시(이 리소스)와 + * 실제 저장 가능 여부(ProductReviewService::canWrite)가 어긋나지 않도록 한다. */ private function isReviewDeadlinePassed(): bool { - $deadlineDays = (int) module_setting('sirsoft-ecommerce', 'review_settings.write_deadline_days', 90); - - if (! $this->confirmed_at || $deadlineDays <= 0) { - return false; - } - - return now()->gt($this->confirmed_at->copy()->addDays($deadlineDays)); + return ReviewWritePolicy::isDeadlinePassed($this->confirmed_at); } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/Traits/HasMultiCurrencyPrices.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/Traits/HasMultiCurrencyPrices.php index 018178fa..807ccee3 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/Traits/HasMultiCurrencyPrices.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/Traits/HasMultiCurrencyPrices.php @@ -2,6 +2,8 @@ namespace Modules\Sirsoft\Ecommerce\Http\Resources\Traits; +use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache; + /** * 다중 통화 가격 변환 Trait * @@ -10,11 +12,6 @@ namespace Modules\Sirsoft\Ecommerce\Http\Resources\Traits; */ trait HasMultiCurrencyPrices { - /** - * 통화 설정 캐시 (동일 요청 내 중복 조회 방지) - */ - private static ?array $currencySettingsCache = null; - /** * 부모 주문 리소스가 주입한 주문 시점 기준 통화 코드 (자식 리소스용). * @@ -96,12 +93,7 @@ trait HasMultiCurrencyPrices */ protected function getCurrencySettings(): array { - if (self::$currencySettingsCache === null) { - $settings = g7_module_settings('sirsoft-ecommerce', 'language_currency'); - self::$currencySettingsCache = $settings['currencies'] ?? []; - } - - return self::$currencySettingsCache; + return CurrencySettingsCache::currencies(); } /** @@ -369,9 +361,11 @@ trait HasMultiCurrencyPrices * 통화 설정 캐시를 초기화합니다. * * 테스트 또는 설정 변경 시 캐시를 리셋해야 할 때 사용합니다. + * 실제 보관소는 CurrencySettingsCache 단일 클래스이므로, 어느 사용 클래스에서 + * 호출하든 전체가 비워집니다. */ public static function clearCurrencySettingsCache(): void { - self::$currencySettingsCache = null; + CurrencySettingsCache::clear(); } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Coupon.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Coupon.php index 0748dea9..eede4482 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/Coupon.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Coupon.php @@ -365,6 +365,24 @@ class Coupon extends Model implements FulltextSearchable return $this->issued_count.'/'.$this->total_quantity; } + /** + * 유효기간 설정이 만료일을 계산할 수 있는 상태인지 확인 + * + * valid_days 는 nullable 컬럼이라 (valid_type=days_from_issue, valid_days=NULL) 조합이 + * 저장될 수 있습니다. 그대로 발급하면 만료일이 발급 시각과 같아져 사실상 즉시 만료된 + * 쿠폰이 조용히 나갑니다. 발급 전에 이 조합을 걸러내기 위한 판정입니다. + * + * @return bool 만료일 계산이 가능한 설정이면 true + */ + public function hasResolvableValidity(): bool + { + if ($this->valid_type !== 'days_from_issue') { + return true; + } + + return is_numeric($this->valid_days) && (int) $this->valid_days > 0; + } + /** * 발급 가능 여부 확인 * @@ -382,6 +400,11 @@ class Coupon extends Model implements FulltextSearchable return false; } + // 유효기간 미설정 (발급일 기준인데 일수가 비어 있음) + if (! $this->hasResolvableValidity()) { + return false; + } + // 발급 기간 확인 $now = now(); if ($this->issue_from && $now->lt($this->issue_from)) { @@ -481,6 +504,8 @@ class Coupon extends Model implements FulltextSearchable /** * MySQL FULLTEXT 엔진에서는 인덱스 업데이트가 불필요합니다. + * + * @return bool 검색 인덱스를 갱신해야 하면 true */ public function searchIndexShouldBeUpdated(): bool { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php index 9bd03aff..30f7e021 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php @@ -46,6 +46,15 @@ use Modules\Sirsoft\Ecommerce\Support\VatCalculator; */ class OrderProcessingService { + /** 입금기한 기본값(일) — 설정이 비어 있거나 사용할 수 없는 값일 때 적용 */ + private const AUTO_CANCEL_DAYS_DEFAULT = 3; + + /** 입금기한 허용 하한(일) — 설정(limits)에 값이 없을 때의 fallback */ + private const AUTO_CANCEL_DAYS_MIN_FALLBACK = 1; + + /** 입금기한 허용 상한(일) — 설정(limits)에 값이 없을 때의 fallback */ + private const AUTO_CANCEL_DAYS_MAX_FALLBACK = 30; + public function __construct( protected OrderRepositoryInterface $orderRepository, protected TempOrderService $tempOrderService, @@ -928,9 +937,7 @@ class OrderProcessingService if ($paymentMethod === PaymentMethodEnum::VBANK->value) { $paymentData['vbank_holder'] = $depositorName; // 입금기한 단일 SSoT: auto_cancel_days (결제수단 무관) - $paymentData['vbank_due_at'] = Carbon::now()->addDays( - module_setting('sirsoft-ecommerce', 'order_settings.auto_cancel_days', 3) - ); + $paymentData['vbank_due_at'] = Carbon::now()->addDays($this->resolveAutoCancelDays()); } // 무통장입금 (수동 입금) 정보 @@ -954,14 +961,48 @@ class OrderProcessingService // 입금기한 단일 SSoT: auto_cancel_days (VBANK 와 동일 기준). // 클라이언트가 보낸 due_days 는 무시한다 — 기한은 서버 정책이며, 이를 받아들이면 // 미입금 자동취소 스케줄러와 안내 기한이 어긋난다. - $paymentData['deposit_due_at'] = Carbon::now()->addDays( - module_setting('sirsoft-ecommerce', 'order_settings.auto_cancel_days', 3) - ); + $paymentData['deposit_due_at'] = Carbon::now()->addDays($this->resolveAutoCancelDays()); } $order->payment()->create($paymentData); } + /** + * 입금기한(일)을 정수로 해석합니다. + * + * 설정값은 관리자 화면(HTML number 입력)을 거치면서 문자열로 저장될 수 있고, + * Carbon 의 날짜 연산은 strict 타입 경계라 문자열을 받으면 TypeError 를 던진다. + * 저장 타입과 무관하게 정수를 보장하고, 허용 범위를 벗어난 값은 안전한 값으로 보정한다. + * + * @return int 입금기한 일수 (허용 범위로 클램프된 값) + */ + private function resolveAutoCancelDays(): int + { + $fallback = self::AUTO_CANCEL_DAYS_DEFAULT; + + $raw = module_setting('sirsoft-ecommerce', 'order_settings.auto_cancel_days', $fallback); + $days = is_numeric($raw) ? (int) $raw : $fallback; + + // 0 이하(빈 문자열/미설정/오염값)는 즉시 만료를 뜻하므로 기본값으로 되돌린다. + if ($days <= 0) { + $days = $fallback; + } + + // 허용 범위는 검증 규칙(StoreEcommerceSettingsRequest)과 같은 limits 설정을 SSoT 로 쓴다. + // 여기서 다시 좁히는 것이 아니라, 검증을 거치지 않고 영속된 값(구버전 데이터·직접 편집)만 + // 안전 범위로 되돌린다 — 주문 생성 자체를 실패시키지 않기 위함. + $min = max( + self::AUTO_CANCEL_DAYS_MIN_FALLBACK, + (int) config('sirsoft-ecommerce.limits.auto_cancel_days_min', self::AUTO_CANCEL_DAYS_MIN_FALLBACK) + ); + $max = max( + $min, + (int) config('sirsoft-ecommerce.limits.auto_cancel_days_max', self::AUTO_CANCEL_DAYS_MAX_FALLBACK) + ); + + return max($min, min($max, $days)); + } + /** * 결제명(상품명 요약)을 생성합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewService.php index 96eb7ffd..2de49193 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewService.php @@ -15,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Models\ProductReview; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewImageRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\ReviewWritePolicy; /** * 상품 리뷰 서비스 @@ -96,17 +97,9 @@ class ProductReviewService return ['can_write' => false, 'reason' => 'not_confirmed']; } - // 작성 기간 확인 - $deadlineDays = (int) $this->settingsService->getSetting( - 'review_settings.write_deadline_days', - config('ecommerce.review.write_deadline_days', 90) - ); - - if ($orderOption->confirmed_at && $deadlineDays > 0) { - $deadline = $orderOption->confirmed_at->addDays($deadlineDays); - if (now()->gt($deadline)) { - return ['can_write' => false, 'reason' => 'deadline_passed']; - } + // 작성 기간 확인 (판정 규칙은 ReviewWritePolicy 단일 SSoT) + if (ReviewWritePolicy::isDeadlinePassed($orderOption->confirmed_at)) { + return ['can_write' => false, 'reason' => 'deadline_passed']; } // 중복 작성 확인 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/UserCouponService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/UserCouponService.php index 52330539..a672d4ea 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/UserCouponService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/UserCouponService.php @@ -3,12 +3,12 @@ namespace Modules\Sirsoft\Ecommerce\Services; use App\Extension\HookManager; -use Exception; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus; use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType; +use Modules\Sirsoft\Ecommerce\Exceptions\CouponNotIssuableException; use Modules\Sirsoft\Ecommerce\Models\Coupon; use Modules\Sirsoft\Ecommerce\Models\CouponIssue; use Modules\Sirsoft\Ecommerce\Models\Product; @@ -309,7 +309,7 @@ class UserCouponService * @param int $couponId 쿠폰 ID * @return CouponIssue 생성된 발급 레코드 * - * @throws Exception 다운로드 불가 시 + * @throws CouponNotIssuableException 다운로드 불가 시 */ public function downloadCoupon(int $userId, int $couponId): CouponIssue { @@ -319,7 +319,7 @@ class UserCouponService $coupon = $this->couponRepository->findByIdForUpdate($couponId); if (! $coupon) { - throw new Exception(__('sirsoft-ecommerce::messages.coupon.not_downloadable'), 400); + throw new CouponNotIssuableException('not_downloadable'); } // 발급 가능 조건 + per_user_limit 검증 (위반 시 사유별 예외) @@ -340,7 +340,7 @@ class UserCouponService * * @param Coupon $coupon 발급 대상 쿠폰 * - * @throws Exception 발급 불가 시(상태/재고/기간) + * @throws CouponNotIssuableException 발급 불가 시(상태/재고/유효기간 미설정/기간) */ public function assertIssuable(Coupon $coupon): void { @@ -349,12 +349,15 @@ class UserCouponService } if ($coupon->issue_status->value !== 'issuing') { - throw new Exception(__('sirsoft-ecommerce::messages.coupon.not_downloadable'), 400); + throw new CouponNotIssuableException('not_downloadable'); } if ($coupon->total_quantity !== null && $coupon->issued_count >= $coupon->total_quantity) { - throw new Exception(__('sirsoft-ecommerce::messages.coupon.quantity_exhausted'), 400); + throw new CouponNotIssuableException('quantity_exhausted'); } - throw new Exception(__('sirsoft-ecommerce::messages.coupon.issue_period_expired'), 400); + if (! $coupon->hasResolvableValidity()) { + throw new CouponNotIssuableException('validity_not_configured'); + } + throw new CouponNotIssuableException('issue_period_expired'); } /** @@ -363,13 +366,13 @@ class UserCouponService * @param Coupon $coupon 발급 대상 쿠폰 * @param int $userId 발급 대상 회원 ID * - * @throws Exception 한도 초과 시 + * @throws CouponNotIssuableException 한도 초과 시 */ public function assertWithinUserLimit(Coupon $coupon, int $userId): void { $userIssuedCount = $this->couponIssueRepository->getUserIssuedCountForCoupon($userId, $coupon->id); if ($coupon->per_user_limit > 0 && $userIssuedCount >= $coupon->per_user_limit) { - throw new Exception(__('sirsoft-ecommerce::messages.coupon.download_limit_exceeded'), 400); + throw new CouponNotIssuableException('download_limit_exceeded'); } } @@ -382,7 +385,7 @@ class UserCouponService * @param int $userId 발급 대상 회원 ID * @return CouponIssue 생성된 발급 레코드 * - * @throws Exception per_user_limit 초과 시 + * @throws CouponNotIssuableException per_user_limit 초과 시 */ public function issueDirectlyToUser(Coupon $coupon, int $userId): CouponIssue { @@ -408,11 +411,18 @@ class UserCouponService $couponCode = $codePrefix.'-'.strtoupper(Str::random(8)); // expired_at 계산 + // + // valid_days 는 nullable 컬럼이라 캐스트를 거쳐도 NULL/문자열이 그대로 도착할 수 있다. + // Carbon 은 strict 타입 경계라 숫자 문자열을 받으면 TypeError 를 던지고, NULL 은 + // 0 일로 흡수해 "발급 즉시 만료" 라는 조용한 오작동을 만든다. 정수로 확정한 뒤 + // 계산하고, 계산 불가(0 이하)면 기간지정 쿠폰의 valid_to 가 비어 있을 때와 같은 + // 규약(만료 없음)을 따른다. 이 조합은 assertIssuable 이 앞단에서 차단한다. $expiredAt = null; if ($coupon->valid_type === 'period') { $expiredAt = $coupon->valid_to; } elseif ($coupon->valid_type === 'days_from_issue') { - $expiredAt = now()->addDays($coupon->valid_days); + $validDays = is_numeric($coupon->valid_days) ? (int) $coupon->valid_days : 0; + $expiredAt = $validDays > 0 ? now()->addDays($validDays) : null; } // CouponIssue 생성 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Support/CurrencySettingsCache.php b/modules/_bundled/sirsoft-ecommerce/src/Support/CurrencySettingsCache.php new file mode 100644 index 00000000..ebb5a091 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Support/CurrencySettingsCache.php @@ -0,0 +1,50 @@ +gt($confirmedAt->copy()->addDays($days)); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php index 4698a1cf..52650cd7 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php @@ -341,6 +341,7 @@ return [ 'not_downloadable' => 'This coupon is not available for download.', 'quantity_exhausted' => 'Coupon quantity exhausted.', 'issue_period_expired' => 'Coupon issue period has ended.', + 'validity_not_configured' => 'This coupon cannot be issued because its validity period is not configured. Please contact the administrator.', // Coupon validation errors (DTO/ValidationError) 'expired' => 'This coupon has expired.', 'min_amount_not_met' => 'Minimum order amount not met.', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index 43481635..bcc48049 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -1743,8 +1743,9 @@ return [ 'boolean' => 'Auto cancel unpaid orders option must be true or false.', ], 'auto_cancel_days' => [ + 'required' => 'Please enter the auto cancel days.', 'integer' => 'Auto cancel days must be an integer.', - 'min' => 'Auto cancel days must be at least 0.', + 'min' => 'Auto cancel days must be at least 1.', 'max' => 'Auto cancel days cannot exceed 30.', ], 'cart_expiry_days' => [ diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php index 378cd5dd..8ebc580f 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php @@ -345,6 +345,7 @@ return [ 'not_downloadable' => '다운로드할 수 없는 쿠폰입니다.', 'quantity_exhausted' => '쿠폰 수량이 소진되었습니다.', 'issue_period_expired' => '쿠폰 발급 기간이 종료되었습니다.', + 'validity_not_configured' => '쿠폰의 유효기간이 설정되어 있지 않아 발급할 수 없습니다. 관리자에게 문의해주세요.', // 쿠폰 검증 오류 (DTO/ValidationError) 'expired' => '만료된 쿠폰입니다.', 'min_amount_not_met' => '최소 주문 금액 조건을 충족하지 않습니다.', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index 4ea94e3d..15f7009a 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -1743,8 +1743,9 @@ return [ 'boolean' => '미결제 자동취소 여부는 참/거짓 값이어야 합니다.', ], 'auto_cancel_days' => [ + 'required' => '자동취소 기한을 입력해주세요.', 'integer' => '자동취소 기한은 정수여야 합니다.', - 'min' => '자동취소 기한은 0일 이상이어야 합니다.', + 'min' => '자동취소 기한은 1일 이상이어야 합니다.', 'max' => '자동취소 기한은 최대 30일까지 설정 가능합니다.', ], 'cart_expiry_days' => [ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponValidDaysIntegrityTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponValidDaysIntegrityTest.php new file mode 100644 index 00000000..53643681 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponValidDaysIntegrityTest.php @@ -0,0 +1,266 @@ +setLocale('ko'); + + $this->adminUser = $this->createAdminUser([ + 'sirsoft-ecommerce.promotion-coupon.read', + 'sirsoft-ecommerce.promotion-coupon.create', + 'sirsoft-ecommerce.promotion-coupon.update', + ]); + } + + /** + * 검증을 우회해 쿠폰 행을 직접 만듭니다. (기존에 저장돼 있던 데이터 재현용) + * + * @param array $overrides 오버라이드할 속성 + * @return Coupon 생성된 쿠폰 + */ + private function makeCoupon(array $overrides = []): Coupon + { + return Coupon::create(array_merge([ + 'name' => ['ko' => '테스트 쿠폰', 'en' => 'Test Coupon'], + 'target_type' => CouponTargetType::PRODUCT_AMOUNT->value, + 'discount_type' => CouponDiscountType::FIXED->value, + 'discount_value' => 1000, + 'min_order_amount' => 0, + 'issue_method' => CouponIssueMethod::DIRECT->value, + 'issue_condition' => CouponIssueCondition::MANUAL->value, + 'issue_status' => CouponIssueStatus::ISSUING->value, + 'per_user_limit' => 0, + 'valid_type' => 'days_from_issue', + 'valid_days' => 30, + 'is_combinable' => true, + 'target_scope' => CouponTargetScope::ALL->value, + ], $overrides)); + } + + /** + * 수정 요청 본문 기본값 (부분 수정이므로 필수 필드만) + * + * @param array $overrides 오버라이드할 속성 + */ + private function updatePayload(array $overrides = []): array + { + return array_merge([ + 'per_user_limit' => 0, + ], $overrides); + } + + /** + * 수정: valid_type=days_from_issue 로 바꾸면서 valid_days 를 비우면 거부된다. + * + * @effects coupon_update_rejects_days_from_issue_without_valid_days + */ + public function test_update_rejects_days_from_issue_with_null_valid_days(): void + { + $coupon = $this->makeCoupon([ + 'valid_type' => 'period', + 'valid_days' => null, + 'valid_from' => now(), + 'valid_to' => now()->addMonth(), + ]); + + $response = $this->actingAs($this->adminUser) + ->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$coupon->id}", $this->updatePayload([ + 'valid_type' => 'days_from_issue', + 'valid_days' => null, + ])); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['valid_days']); + } + + /** + * 수정: valid_type 은 그대로(days_from_issue) 두고 valid_days 만 비워도 거부된다. + * + * valid_type 이 요청에 없으므로, 저장돼 있던 값을 승계해 판정해야 잡힌다. + * + * @effects coupon_update_rejects_days_from_issue_without_valid_days + */ + public function test_update_rejects_clearing_valid_days_when_existing_type_is_days_from_issue(): void + { + $coupon = $this->makeCoupon(['valid_days' => 30]); + + $response = $this->actingAs($this->adminUser) + ->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$coupon->id}", $this->updatePayload([ + 'valid_days' => null, + ])); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['valid_days']); + + $this->assertSame(30, $coupon->fresh()->valid_days); + } + + /** + * 수정: valid_type 만 days_from_issue 로 보내도 저장된 valid_days 가 있으면 통과한다. + * + * @effects coupon_update_rejects_days_from_issue_without_valid_days + */ + public function test_update_allows_type_change_when_stored_valid_days_exists(): void + { + $coupon = $this->makeCoupon(['valid_days' => 15]); + + $response = $this->actingAs($this->adminUser) + ->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$coupon->id}", $this->updatePayload([ + 'valid_type' => 'days_from_issue', + ])); + + $response->assertStatus(200); + $this->assertSame(15, $coupon->fresh()->valid_days); + } + + /** + * 수정: 유효기간과 무관한 부분 수정은 차단되지 않는다. (과잉 차단 회귀 고정) + * + * 이미 valid_days 가 비어 있는 기존 쿠폰이라도, 요청이 유효기간 쌍을 건드리지 + * 않으면 통과해야 한다 — 그렇지 않으면 이름 변경조차 막힌다. + * + * @effects coupon_update_rejects_days_from_issue_without_valid_days + */ + public function test_update_unrelated_field_is_not_blocked_on_legacy_broken_coupon(): void + { + $coupon = $this->makeCoupon(['valid_days' => null]); + + $response = $this->actingAs($this->adminUser) + ->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$coupon->id}", $this->updatePayload([ + 'name' => ['ko' => '이름만 변경', 'en' => 'Name only'], + ])); + + $response->assertStatus(200); + } + + /** + * 발급 관문: 이미 저장돼 있던 (days_from_issue, valid_days=null) 쿠폰은 발급이 차단된다. + * + * 수정 전에는 만료일이 발급 시각과 같아져 즉시 만료된 쿠폰이 조용히 발급됐다. + * + * @effects coupon_issue_blocked_when_validity_not_configured + */ + public function test_issue_is_blocked_when_valid_days_is_null(): void + { + $coupon = $this->makeCoupon(['valid_days' => null]); + + $this->assertFalse($coupon->hasResolvableValidity()); + $this->assertFalse($coupon->isIssuable()); + + $this->expectExceptionMessage( + __('sirsoft-ecommerce::messages.coupon.validity_not_configured') + ); + + app(UserCouponService::class)->assertIssuable($coupon); + } + + /** + * 발급 관문: 0 일도 만료일을 만들 수 없으므로 동일하게 차단된다. + * + * @effects coupon_issue_blocked_when_validity_not_configured + */ + public function test_issue_is_blocked_when_valid_days_is_zero(): void + { + $coupon = $this->makeCoupon(['valid_days' => 0]); + + $this->assertFalse($coupon->isIssuable()); + + $this->expectExceptionMessage( + __('sirsoft-ecommerce::messages.coupon.validity_not_configured') + ); + + app(UserCouponService::class)->assertIssuable($coupon); + } + + /** + * 발급 관문: 기간지정 쿠폰은 valid_days 가 비어 있어도 영향받지 않는다. (과잉 차단 방지) + * + * @effects coupon_issue_blocked_when_validity_not_configured + */ + public function test_period_coupon_is_unaffected_by_empty_valid_days(): void + { + $coupon = $this->makeCoupon([ + 'valid_type' => 'period', + 'valid_days' => null, + 'valid_from' => now(), + 'valid_to' => now()->addMonth(), + ]); + + $this->assertTrue($coupon->hasResolvableValidity()); + $this->assertTrue($coupon->isIssuable()); + } + + /** + * 발급: 정상값은 그대로 발급일 + N일이 된다. (기준선) + * + * @effects coupon_issue_blocked_when_validity_not_configured + */ + public function test_issue_uses_valid_days_when_configured(): void + { + $coupon = $this->makeCoupon(['valid_days' => 7]); + $member = User::factory()->create(); + + $issue = app(UserCouponService::class)->issueDirectlyToUser($coupon, $member->id); + + $this->assertNotNull($issue->expired_at); + $this->assertSame( + now()->addDays(7)->format('Y-m-d'), + $issue->expired_at->format('Y-m-d') + ); + } + + /** + * 발급: 속성이 숫자 문자열로 도착해도 Carbon 경계에서 터지지 않는다. + * + * 이 계획이 제거한 크래시(숫자 문자열 → Carbon strict TypeError)와 같은 부류를 + * 모델 속성 경유 경로에서도 고정한다. + * + * @effects coupon_issue_blocked_when_validity_not_configured + */ + public function test_issue_survives_numeric_string_valid_days(): void + { + $coupon = $this->makeCoupon(['valid_days' => 7]); + $coupon->setAttribute('valid_days', '7'); + $member = User::factory()->create(); + + $issue = app(UserCouponService::class)->issueDirectlyToUser($coupon, $member->id); + + $this->assertSame( + now()->addDays(7)->format('Y-m-d'), + $issue->expired_at->format('Y-m-d') + ); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php index 7414d9db..68f2a5ad 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php @@ -10,10 +10,13 @@ use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus; use Modules\Sirsoft\Ecommerce\Enums\ProductSalesStatus; use Modules\Sirsoft\Ecommerce\Models\ClaimReason; +use Modules\Sirsoft\Ecommerce\Models\MileageTransaction; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\Product; use Modules\Sirsoft\Ecommerce\Models\ProductOption; use Modules\Sirsoft\Ecommerce\Models\TempOrder; +use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService; +use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver; use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; /** @@ -24,6 +27,7 @@ use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; * 비회원 토큰 후속 액션(verify, cancel, 배송지 변경 등) 의 회원 주문 보호/경계를 검증한다. * * @scenario actor=guest, change_mode=manual, e2e_browser=chromium + * * @effects guest_update_shipping_address_succeeds_with_valid_token, * guest_update_shipping_address_blocked_404_without_or_invalid_token, * guest_update_shipping_address_validation_422_when_recipient_fields_missing @@ -367,8 +371,8 @@ class OrderControllerTest extends ModuleTestCase ]]) ); - app(\Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver::class)->flushCache(); - app(\Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService::class)->clearCache(); + app(PaymentMethodResolver::class)->flushCache(); + app(EcommerceSettingsService::class)->clearCache(); } /** @@ -383,7 +387,7 @@ class OrderControllerTest extends ModuleTestCase * * @effects requires_pg_payment_true, pg_payment_handler_present, extension_id_passes_validation */ - public function test_간편결제_주문_생성_응답이_PG_결제_계약을_내려준다(): void + public function test_간편결제_주문_생성_응답이_p_g_결제_계약을_내려준다(): void { $this->registerExtensionEasyPayMethod(); $this->createGuestTempOrder(); @@ -518,6 +522,163 @@ class OrderControllerTest extends ModuleTestCase $this->assertNull($order->guest_lookup_password_hash); } + /** + * 문자열로 저장된 auto_cancel_days 설정 파일을 만들고, 테스트 후 정리합니다. + * + * 관리자 화면(HTML number 입력)을 한 번 저장하면 실제로 이 형태로 영속된다. + * + * @param string $storedValue 저장 형태 그대로의 값 + * @return callable 정리 콜백 + */ + private function withStringAutoCancelDays(string $storedValue): callable + { + $settingsDir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings'); + if (! is_dir($settingsDir)) { + mkdir($settingsDir, 0755, true); + } + + $path = $settingsDir.'/order_settings.json'; + file_put_contents($path, json_encode(['auto_cancel_days' => $storedValue])); + + return function () use ($path) { + if (file_exists($path)) { + unlink($path); + } + }; + } + + /** + * 10-A. 입금기한 설정이 문자열로 저장되어 있어도 비회원 무통장 주문이 생성된다 + * + * 수정 전: Carbon 이 strict 타입 경계라 문자열을 받으면 TypeError → 주문 생성 500. + * + * @effects order_creation_succeeds_with_string_setting + */ + public function test_비회원_무통장_주문은_입금기한_설정이_문자열이어도_생성된다(): void + { + $cleanup = $this->withStringAutoCancelDays('5'); + + try { + $this->createGuestTempOrder(); + + $response = $this->postJson( + '/api/modules/sirsoft-ecommerce/user/orders', + $this->guestOrderPayload(), + ['X-Cart-Key' => $this->cartKey] + ); + + $response->assertStatus(201); + + $order = Order::latest('id')->first(); + $this->assertNotNull($order->payment->deposit_due_at); + $this->assertSame( + now()->addDays(5)->toDateString(), + $order->payment->deposit_due_at->toDateString(), + ); + } finally { + $cleanup(); + } + } + + /** + * 10-B. 입금기한 설정이 문자열로 저장되어 있어도 회원 무통장 주문이 생성된다 + * + * @effects order_creation_succeeds_with_string_setting + */ + public function test_회원_무통장_주문은_입금기한_설정이_문자열이어도_생성된다(): void + { + $cleanup = $this->withStringAutoCancelDays('5'); + + try { + $user = $this->createUser(); + $this->actingAs($user); + + TempOrder::create([ + 'user_id' => $user->id, + 'cart_key' => $this->cartKey, + 'items' => [[ + 'product_id' => $this->product->id, + 'product_option_id' => $this->productOption->id, + 'quantity' => 2, + ]], + 'calculation_result' => [ + 'items' => [[ + 'product_id' => $this->product->id, + 'product_option_id' => $this->productOption->id, + 'quantity' => 2, + 'unit_price' => 15000, + 'subtotal' => 30000, + 'final_amount' => 30000, + ]], + 'summary' => [ + 'subtotal' => 30000, + 'total_discount' => 0, + 'total_shipping' => 0, + 'payment_amount' => 30000, + 'final_amount' => 30000, + ], + ], + 'expires_at' => now()->addMinutes(30), + ]); + + $payload = $this->guestOrderPayload(); + unset($payload['guest_lookup_password'], $payload['guest_lookup_password_confirmation']); + + $this->postJson( + '/api/modules/sirsoft-ecommerce/user/orders', + $payload, + ['X-Cart-Key' => $this->cartKey] + )->assertStatus(201); + + $order = Order::where('user_id', $user->id)->latest('id')->first(); + $this->assertSame( + now()->addDays(5)->toDateString(), + $order->payment->deposit_due_at->toDateString(), + ); + } finally { + $cleanup(); + } + } + + /** + * 10-C. 입금기한 설정이 문자열이어도 가상계좌(vbank) 주문이 생성된다 + * + * dbank 와 같은 산정 경로(auto_cancel_days)를 쓰지만 기록 컬럼(`vbank_due_at`)이 달라 + * HTTP 레벨에서도 따로 고정한다. + * + * @effects order_creation_succeeds_with_string_setting + */ + public function test_비회원_가상계좌_주문은_입금기한_설정이_문자열이어도_생성된다(): void + { + $cleanup = $this->withStringAutoCancelDays('5'); + + try { + $this->createGuestTempOrder(); + + $payload = $this->guestOrderPayload([ + 'payment_method' => PaymentMethodEnum::VBANK->value, + ]); + unset($payload['dbank']); + + $response = $this->postJson( + '/api/modules/sirsoft-ecommerce/user/orders', + $payload, + ['X-Cart-Key' => $this->cartKey] + ); + + $response->assertStatus(201); + + $order = Order::latest('id')->first(); + $this->assertNotNull($order->payment->vbank_due_at); + $this->assertSame( + now()->addDays(5)->toDateString(), + $order->payment->vbank_due_at->toDateString(), + ); + } finally { + $cleanup(); + } + } + /** * 10-1. 회원 주문은 주문자 이메일이 없어도 생성된다 (이메일 필수는 비회원 한정) */ @@ -579,7 +740,7 @@ class OrderControllerTest extends ModuleTestCase * 결제수단(card)을 선택했더라도 결제할 금액이 0원이면 PG 호출 없이 통과해야 한다. * 추후 예치금 등 다른 비현금 충당이 추가되어도 동일하게 동작한다(판정 기준 = total_due_amount). */ - public function test_전액_마일리지_결제는_PG_없이_결제완료(): void + public function test_전액_마일리지_결제는_p_g_없이_결제완료(): void { $this->enableMileageForFeature(); @@ -587,7 +748,7 @@ class OrderControllerTest extends ModuleTestCase $this->actingAs($user); // 결제액 전액(30,000) 충당용 마일리지 잔액 시드 - \Modules\Sirsoft\Ecommerce\Models\MileageTransaction::create([ + MileageTransaction::create([ 'user_id' => $user->id, 'currency' => 'KRW', 'type' => 'purchase_earn', @@ -934,7 +1095,6 @@ class OrderControllerTest extends ModuleTestCase // 회원 응답은 OrderResource — user 필드가 포함되어 비회원 응답(GuestOrderResource)과 구분됨 $this->assertArrayHasKey('user', $data); - // 회귀 차단: 회원 분기도 getDetail() 로 풀로드되어 shippings/shipping_address 가 응답에 포함되어야 한다. // (이전 회귀: getByOrderNumber() 만 호출 시 whenLoaded 가 빈 응답 → 화면에 배송 메모/배송 현황 미표시) $this->assertArrayHasKey('shippings', $data, '회원 응답에 shippings 누락 — getByOrderNumber 만 호출 시 whenLoaded 가 빈 응답'); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/ModuleTestCase.php b/modules/_bundled/sirsoft-ecommerce/tests/ModuleTestCase.php index 3f5710d2..871ca28d 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/ModuleTestCase.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/ModuleTestCase.php @@ -27,6 +27,7 @@ use Modules\Sirsoft\Ecommerce\Models\ShippingType; use Modules\Sirsoft\Ecommerce\Module; use Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider; use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService; +use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache; use Tests\TestCase; /** @@ -79,16 +80,23 @@ abstract class ModuleTestCase extends TestCase */ protected function migrateFreshUsing(): array { + // 모든 번들 확장 migrations 포함 — 여러 확장 스위트를 한 프로세스에서 함께 돌릴 때 + // 가장 먼저 실행된 TestCase 가 스키마를 확정하므로, 자기 확장만 넘기면 뒤따르는 + // 확장의 테이블이 생성되지 않는다 (troubleshooting-backend.md 사례 21). + $paths = ['database/migrations']; + foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { + $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p); + } + foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { + $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p); + } + return [ '--drop-views' => $this->shouldDropViews(), '--drop-types' => $this->shouldDropTypes(), '--seed' => $this->shouldSeed(), '--seeder' => $this->seeder(), - '--path' => [ - base_path('database/migrations'), - $this->getModuleBasePath().'/database/migrations', - ], - '--realpath' => true, + '--path' => $paths, ]; } @@ -185,6 +193,11 @@ abstract class ModuleTestCase extends TestCase if (method_exists(ShippingType::class, 'clearCodeCache')) { ShippingType::clearCodeCache(); } + + // - CurrencySettingsCache: 통화 설정을 요청 단위로 캐시한다. 비우지 않으면 앞선 테스트가 + // 읽어 둔 통화 구성(기본 통화의 소수 자릿수 등)을 뒤 테스트가 물려받아, 금액 필드가 + // int 로 나와야 할 자리에서 float 이 나오는 등 단독 통과 / 함께 실행 실패가 발생한다. + CurrencySettingsCache::clear(); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/dbank-checkout-deposit-due.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/dbank-checkout-deposit-due.spec.ts new file mode 100644 index 00000000..8905e4bb --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/dbank-checkout-deposit-due.spec.ts @@ -0,0 +1,469 @@ +/** + * E2E: 무통장입금(dbank) 체크아웃 완료 — 입금기한 산정이 500 없이 끝난다 + * + * 시나리오 매니페스트: `modules/_bundled/sirsoft-ecommerce/tests/scenarios/numeric-setting-type-safety.yaml` + * (케이스 마킹은 각 test 의 docblock 에 있다 — 파일 헤더에 두면 축 값이 비어 매칭되지 않는다) + * + * 배경: 관리자 주문설정을 한 번만 저장해도 `auto_cancel_days` 가 문자열(`"5"`)로 영속됐다. + * HTML `type="number"` 의 DOM 값은 문자열이고 Laravel `integer` 규칙은 숫자 문자열을 통과시키되 + * 캐스트하지 않기 때문이다. 그 값이 `Carbon::now()->addDays(...)` 에 닿으면 + * `rawAddUnit(int|float)` 가 `declare(strict_types=1)` 파일에서 호출되므로 TypeError 가 났고, + * **모든 무통장입금·가상계좌 주문이 500 으로 실패**했다. + * + * 이 결함은 PHPUnit 이 red 를 잡을 수 있는 형태지만, 실제로 발견된 경로는 브라우저였다. + * 상품 선택 → 체크아웃 → 결제하기까지의 흐름 전체가 이어져야 주문 생성 요청이 나가고, + * 그 응답이 500 이면 화면은 토스트 한 줄만 띄운 채 멈춘다. 그래서 흐름 전체를 브라우저로 고정한다. + * + * 검증 지점 3개: + * ① 공개 결제설정 응답의 입금기한이 문자열이 아니다 (계층 2·3 — 요청 캐스트 + 조회 정규화) + * ② 회원 — 주문서 화면에서 무통장 선택 → 결제하기 → 201 + 완료 화면 입금기한 (계층 1) + * ③ 비회원 — 같은 브라우저 세션의 주문 생성 요청으로 201 + 입금기한 + * + * 커버리지 경계(침묵 누락 방지): ③ 은 주문서 **화면**을 거치지 않는다. 비회원은 저장된 배송지가 + * 없어 우편번호·주소 칸을 외부 주소검색 위젯으로만 채울 수 있는데(두 칸 모두 `readOnly` — 실측), + * 외부 위젯을 E2E 로 몰면 이 스펙이 결제 회귀가 아니라 그 위젯의 가용성을 감시하게 된다. + * 비회원 주문서 화면의 브라우저 실측은 Chrome MCP 매트릭스 D2 로 1회 확보되어 있고, + * 여기서는 회귀가 실제로 터지는 지점(서버의 입금기한 산정)을 같은 세션의 요청으로 고정한다. + * + * 부작용: ②③ 은 실제 주문을 생성한다(결제 대기 상태). 무통장입금은 외부 PG 를 거치지 않으므로 + * 샌드박스 의존이 없고, 생성된 주문은 설정된 기한이 지나면 미입금 자동취소 대상이 된다. + * + * @see .claude/docs/frontend/troubleshooting-backend.md 사례 21 + */ +import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth'; +import type { Page } from '@playwright/test'; + +const PAYMENT_SETTINGS_API = '/api/modules/sirsoft-ecommerce/settings/payment'; +const ORDER_CREATE_API = /\/api\/modules\/sirsoft-ecommerce\/(user|guest)\/orders(\?|$)/; + +/** 기본 입금기한 — 서버 `OrderProcessingService::AUTO_CANCEL_DAYS_DEFAULT` 와 같은 값 */ +const AUTO_CANCEL_DAYS_FALLBACK = 3; + +/** + * 이 스펙은 직렬로 실행한다. + * + * 세 테스트가 같은 재고를 소비하고 같은 체크아웃 세션(임시주문)을 만든다. 병렬로 돌리면 + * 재고/임시주문이 서로 간섭해 결함이 아닌 실패가 난다. + */ +test.describe.configure({ mode: 'serial' }); + +/** + * 기본 30초로는 부족하다 — 상품 상세 → 체크아웃 → 주문 생성까지 SPA 라우팅이 세 번 일어나고 + * 각 단계가 자체 데이터소스를 기다린다. 단계별 대기(20~30초)의 합보다 크게 잡아야 + * 결함이 아닌 타임아웃으로 red 가 나지 않는다. + */ +test.setTimeout(180_000); + +/** + * 공개 결제설정에서 입금기한을 읽는다. + * + * 이 값이 화면 안내("입금 기한: N일 이내")와 서버 산정의 공통 입력이므로, 기대 기한을 + * 여기서 뽑아야 설정을 바꾼 환경에서도 스펙이 그대로 성립한다(값 하드코딩 금지). + */ +async function readOrderSettings( + page: Page +): Promise<{ raw: unknown; days: number; accounts: any[] }> { + const response = await page.request.get(PAYMENT_SETTINGS_API); + expect(response.status()).toBe(200); + + const body = await response.json(); + const orderSettings = body?.data?.order_settings; + // 존재를 먼저 확정한다 — 없는 객체에 대고 타입 단언을 하면 그 단언은 무의미하게 통과한다. + expect(orderSettings, '공개 결제설정 응답에 order_settings 가 없다').toBeTruthy(); + + const raw = orderSettings.auto_cancel_days; + const days = typeof raw === 'number' && raw > 0 ? raw : AUTO_CANCEL_DAYS_FALLBACK; + const accounts = (orderSettings.bank_accounts ?? []).filter((a: any) => a?.is_active !== false); + + return { raw, days, accounts }; +} + +/** + * GDPR 쿠키 동의 배너를 닫는다. + * + * 배너는 화면 최상단 레이어라 열려 있으면 뒤쪽 버튼 클릭이 가로채인다. 동의 여부는 이 스펙의 + * 관심사가 아니므로, 떠 있으면 닫고 없으면 넘어간다(이미 동의된 세션에서는 렌더되지 않는다). + */ +async function dismissCookieBanner(page: Page): Promise { + const acceptAll = page.getByRole('button', { name: '모두 동의' }); + if (await acceptAll.isVisible({ timeout: 5_000 }).catch(() => false)) { + await acceptAll.click(); + await expect(acceptAll).toBeHidden({ timeout: 10_000 }); + } +} + +/** + * 회원에게 기본 배송지가 없으면 하나 만든다. + * + * 주문서의 우편번호·주소 칸은 `readOnly` 이고 외부 주소검색 위젯으로만 채워진다(실측). + * 외부 위젯을 E2E 로 몰 수는 없으므로, 저장된 배송지를 미리 두어 주문서가 그 값으로 + * 채워진 채 열리게 한다 — 실제 회원의 재구매 동선과 같은 상태다. + */ +async function ensureDefaultAddress(page: Page, token: string): Promise { + const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' }; + const endpoint = '/api/modules/sirsoft-ecommerce/user/addresses'; + + const readList = async (): Promise => { + const response = await page.request.get(endpoint, { headers }); + if (response.status() !== 200) return []; + const body = await response.json(); + const addresses = body?.data?.addresses?.data ?? body?.data?.addresses ?? body?.data ?? []; + return Array.isArray(addresses) ? addresses : []; + }; + + let addresses = await readList(); + + if (addresses.length === 0) { + const created = await page.request.post(endpoint, { + headers, + data: { + name: 'E2E 기본 배송지', + recipient_name: 'E2E수령인', + recipient_phone: '010-1234-5678', + country_code: 'KR', + zipcode: '06236', + address: '서울특별시 강남구 테헤란로 1', + address_detail: 'E2E 101호', + is_default: true, + }, + }); + expect(created.status(), '기본 배송지 생성이 실패했다').toBe(201); + + // 생성 응답의 봉투 형태에 기대지 않는다 — 목록을 다시 읽어 화면과 같은 표현을 쓴다. + addresses = await readList(); + } + + expect(addresses.length, '기본 배송지를 확보하지 못했다').toBeGreaterThan(0); + + return addresses.find((a: any) => a.is_default) ?? addresses[0]; +} + +/** + * 주문서의 저장된 배송지 칩을 눌러 주소를 채운다. + * + * 저장된 배송지가 있어도 주문서는 자동 적용하지 않고 선택을 기다린다 — 누르지 않으면 + * 우편번호·주소가 빈 채로 남아 '결제하기' 가 계속 비활성이다(실측). 칩 라벨은 배송지 이름이라 + * 환경마다 다르므로 API 로 받은 이름으로 지목한다. + */ +async function applySavedAddress(page: Page, address: any): Promise { + const label = String(address?.name ?? '').trim(); + expect(label, '저장된 배송지에 이름이 없다').not.toBe(''); + + const chip = page.getByRole('button').filter({ hasText: label }).first(); + await expect(chip, `저장된 배송지 "${label}" 칩이 없다`).toBeVisible({ timeout: 20_000 }); + await chip.click(); + + // 주소가 실제로 실렸는지 확정한다 — 비어 있으면 뒤의 '결제하기' 비활성이 회귀로 오독된다. + await expect + .poll(async () => (await page.locator('input[name="zipcode"]').first().inputValue()).trim(), { + timeout: 20_000, + }) + .not.toBe(''); +} + +/** + * 판매중인 상품 하나로 '바로구매' 임시주문을 만들고 체크아웃 화면을 연다. + * + * 상품 상세의 옵션 선택은 이 스펙이 지키려는 대상이 아닌데도 가장 불안정하다 — + * `Select` 는 옵션 데이터가 실릴 때까지 네이티브 `