fix(core,board,page,ecommerce,basic,pay): FULLTEXT 게이트·검색 실패 표면화와 페이지네이션/접두사 결함 정비
- 공개 : MATCH 는 커버 인덱스가 있을 때만 조립 — 부재 시 LIKE 폴백 + 1회 경고, 카테고리 검색 예외를 categories_failed/search_failed 로 표면화하고 basic 템플릿이 오류 안내 렌더 - 공개 동근원: paginate page 명시 전달 (언어팩 check-updates, 상품 문의 목록) - 공개 동근원: raw SQL 접두사/별칭 하드코딩 정리 (board 시더, 7.0.6 업그레이드 스텝, 결제 3플러그인 컨트롤러 51지점 모델 파생 전환) - audit 룰 3종 신설 + repository-raw-hardcoded-table 컨트롤러 확대, 확장 TestCase 오토로더 중복 선언 가드 16지점, ja 언어팩 동기
This commit is contained in:
@@ -42,6 +42,9 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- FULLTEXT 인덱스가 없는 테이블을 검색하면 오류 대신 부분일치 검색으로 자동 전환되고, 그 사실이 기록으로 남습니다. 이전에는 오류가 화면에 드러나지 않아 "검색 결과 0건" 으로만 보였습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 통합검색에서 일부 항목의 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록, 항목별 실패 여부를 응답에 실어 내립니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 언어팩 업데이트 확인이 특정 조건(요청에 페이지 이동 파라미터가 실려 있는 경우)에서 아무 팩도 확인하지 않고 끝나던 문제를 수정했습니다. (#102 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 검색엔진 플러그인을 선택해 저장한 뒤 그 플러그인을 삭제하면 사이트 검색이 오류로 멈추던 문제를 수정했습니다. 이제 다른 드라이버 설정과 동일하게 기본 검색엔진으로 자동 복귀합니다.
|
||||
- 설정을 항목 단위로 저장할 때 전체 저장과 다른 처리를 거치던 문제를 수정했습니다. 자산 주소 방식을 바꾸면 SEO 미리 생성 캐시가, 드라이버를 바꾸면 백그라운드 작업이 각각 갱신되지 않았습니다. SEO 설정도 항목 단위 저장에서 캐시가 정리되지 않아 검색엔진에 예전 정보가 남았습니다. (#114 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 모듈 환경설정(쇼핑몰·게시판)을 저장해도 SEO 미리 생성 캐시가 갱신되지 않던 문제를 수정했습니다. 함께, 모듈 설정 변경이 활동 로그에 기록됩니다.
|
||||
|
||||
+1
-1
@@ -514,8 +514,8 @@ cp .env.example .env
|
||||
<!-- community-contributors:start -->
|
||||
<p>
|
||||
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
|
||||
@@ -528,8 +528,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
||||
<!-- community-contributors:start -->
|
||||
<p>
|
||||
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
|
||||
@@ -72,13 +72,24 @@ interface LanguagePackRepositoryInterface
|
||||
public function getActiveCoreLocales(): array;
|
||||
|
||||
/**
|
||||
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
|
||||
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
|
||||
*
|
||||
* 전량 순회 용도로는 쓰지 않는다 — page 를 생략하면 HTTP `page` 파라미터가 암묵
|
||||
* 해석되어, 무관한 요청 파라미터가 순회 범위를 바꾼다({@see self::allForUpdateCheck()}).
|
||||
*
|
||||
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor)
|
||||
* @param int $perPage 페이지당 건수
|
||||
* @param int|null $page 페이지 번호 (null 이면 요청 파라미터에서 해석)
|
||||
* @return LengthAwarePaginator 페이지네이션 결과
|
||||
*/
|
||||
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator;
|
||||
public function paginate(array $filters = [], int $perPage = 20, ?int $page = null): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* 업데이트 확인용 전체 언어팩 컬렉션을 조회합니다.
|
||||
*
|
||||
* @return Collection<int, LanguagePack> 설치된 전체 언어팩
|
||||
*/
|
||||
public function allForUpdateCheck(): Collection;
|
||||
|
||||
/**
|
||||
* 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
|
||||
|
||||
@@ -130,6 +130,12 @@ class PublicSearchController extends PublicBaseController
|
||||
// 상한에 걸린 숫자가 그 배지에서만 정확한 값처럼 보인다. 코어가 일괄로 붙인다.
|
||||
$response['counts_are_exact'] = $this->resolveCategoryAccuracy($results);
|
||||
|
||||
// 카테고리 검색 실패도 같은 이유로 코어가 일괄 조립한다 — 화면은 이 키로
|
||||
// "검색 결과 없음" 과 "검색 중 오류" 를 구분해 그린다. 모듈별 복사에 맡기면
|
||||
// 빠지는 카테고리가 생겨 그 카테고리의 실패만 0건으로 위장된다.
|
||||
$response['categories_failed'] = $this->resolveCategoryFailures($results);
|
||||
$response['search_failed'] = in_array(true, $response['categories_failed'], true);
|
||||
|
||||
// 특정 탭 조회 시 total을 해당 탭의 count로 설정
|
||||
$type = $context['type'] ?? 'all';
|
||||
if ($type !== 'all') {
|
||||
@@ -180,6 +186,27 @@ class PublicSearchController extends PublicBaseController
|
||||
return $accuracy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 검색 실패 여부를 모읍니다.
|
||||
*
|
||||
* 실패 카테고리 페이로드(`SearchCategoryPayload::failed()`)의 `failed` 플래그를
|
||||
* 카테고리 전수에 대해 수집합니다. 키가 없는 카테고리는 실패하지 않은 것으로
|
||||
* 봅니다 — 구버전 모듈이 플래그를 싣지 않아도 종전 렌더가 유지되어야 합니다.
|
||||
*
|
||||
* @param array $results Hook에서 반환된 검색 결과
|
||||
* @return array<string, bool> 카테고리 => 실패 여부
|
||||
*/
|
||||
private function resolveCategoryFailures(array $results): array
|
||||
{
|
||||
$failures = [];
|
||||
|
||||
foreach ($results as $category => $categoryData) {
|
||||
$failures[$category] = ($categoryData['failed'] ?? false) === true;
|
||||
}
|
||||
|
||||
return $failures;
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 전체의 총 건수 정확도를 정합니다.
|
||||
*
|
||||
|
||||
@@ -125,16 +125,33 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
|
||||
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
|
||||
*
|
||||
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor)
|
||||
* @param int $perPage 페이지당 건수
|
||||
* @param int|null $page 페이지 번호 (null 이면 요청 파라미터에서 해석)
|
||||
* @return LengthAwarePaginator 페이지네이션 결과
|
||||
*/
|
||||
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
public function paginate(array $filters = [], int $perPage = 20, ?int $page = null): LengthAwarePaginator
|
||||
{
|
||||
// audit:allow repository-paginate-column-pruning reason: 언어팩 정의 테이블 — 설치된 팩 수만큼만 존재하고 넓은 컬럼이 없다
|
||||
return $this->buildFilteredQuery($filters)->paginate($perPage);
|
||||
return $this->buildFilteredQuery($filters)->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업데이트 확인용 전체 언어팩 컬렉션을 조회합니다.
|
||||
*
|
||||
* 전량 순회 의도를 `paginate(큰 값)` 으로 흉내 내면 page 인자 암묵 해석 때문에
|
||||
* HTTP `?page=2` 가 순회 범위를 비워 버린다 (공개 이슈 #102 동형). 순회는 이
|
||||
* 메서드로만 한다.
|
||||
*
|
||||
* @return Collection<int, LanguagePack> 설치된 전체 언어팩
|
||||
*/
|
||||
public function allForUpdateCheck(): Collection
|
||||
{
|
||||
// audit:allow query-unbounded-get reason: 언어팩은 운영자가 설치한 팩 수만큼만 존재하는
|
||||
// 설정성 테이블이다 (사용량과 무관) — CLAUDE.md pagination 예외 조항
|
||||
return LanguagePack::query()->orderBy('identifier')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,15 +6,18 @@ use App\Enums\TotalRelation;
|
||||
use App\Search\Contracts\FulltextSearchable;
|
||||
use App\Search\Contracts\KeywordPredicateProvider;
|
||||
use App\Search\DTO\KeywordSearchContext;
|
||||
use App\Search\FulltextIndexInspector;
|
||||
use App\Search\KeywordSearch;
|
||||
use App\Support\Query\BoundedPaginator;
|
||||
use App\Support\Query\PaginationLimits;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
use Laravel\Scout\Builder;
|
||||
use Laravel\Scout\Engines\Engine;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* MySQL FULLTEXT + ngram 커스텀 Scout 엔진
|
||||
@@ -31,6 +34,27 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
|
||||
*/
|
||||
private static ?bool $isMariaDbCache = null;
|
||||
|
||||
/**
|
||||
* FULLTEXT 인덱스 카탈로그 캐시 (프로세스 수명 동안 유지, lazy 적재)
|
||||
*
|
||||
* 프리픽스 제거·소문자 테이블명 => 인덱스별 컬럼 집합(소문자·정렬) 목록.
|
||||
* null 은 미적재. 적재 실패(Throwable)는 캐시하지 않는다 — 일시 장애가
|
||||
* 워커 수명 내내 LIKE 강등으로 굳는 것을 막기 위함이다.
|
||||
*
|
||||
* FPM 워커의 static 캐시이므로 배포로 인덱스가 추가돼도 반영은 워커 재시작
|
||||
* (또는 `addFulltextIndex()` 경유 생성) 시점이다.
|
||||
*
|
||||
* @var array<string, array<int, array<int, string>>>|null
|
||||
*/
|
||||
private static ?array $fulltextIndexCatalog = null;
|
||||
|
||||
/**
|
||||
* 인덱스 부재 폴백 경고를 조합당 한 번만 남기기 위한 기록
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private static array $warnedMissingIndexes = [];
|
||||
|
||||
/**
|
||||
* 모델을 검색 인덱스에 업데이트합니다.
|
||||
*
|
||||
@@ -293,7 +317,7 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
|
||||
$version = DB::selectOne('SELECT VERSION() as version');
|
||||
|
||||
return static::$isMariaDbCache = $version && str_contains(strtolower($version->version), 'mariadb');
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return static::$isMariaDbCache = false;
|
||||
}
|
||||
}
|
||||
@@ -301,6 +325,184 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
|
||||
return static::$isMariaDbCache = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 테이블·컬럼 조합을 커버하는 FULLTEXT 인덱스가 있는지 판정합니다.
|
||||
*
|
||||
* MySQL 의 `MATCH(a, b)` 는 어떤 FULLTEXT 인덱스의 컬럼 **집합과 정확히 일치**
|
||||
* (순서 무관)해야 실행됩니다. 복합 (title,content) 인덱스는 `MATCH(title)` 단독을
|
||||
* 커버하지 못합니다. 판정은 한정자 제거·소문자·정렬 정규화 후 집합 동등 비교입니다.
|
||||
*
|
||||
* @param string $table 테이블명 (프리픽스 포함/미포함 모두 허용)
|
||||
* @param array<int, string> $columns MATCH 대상 컬럼명
|
||||
* @return bool 커버하는 인덱스가 있으면 true
|
||||
*/
|
||||
public static function fulltextIndexCoversColumns(string $table, array $columns): bool
|
||||
{
|
||||
if (! static::supportsFulltext()) {
|
||||
// sqlite/pgsql 등에서는 INFORMATION_SCHEMA 접근 자체를 하지 않는다.
|
||||
return false;
|
||||
}
|
||||
|
||||
$catalog = static::fulltextIndexCatalog();
|
||||
|
||||
if ($catalog === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$wanted = self::normalizeColumnSet($columns);
|
||||
|
||||
if ($wanted === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::tableLookupKeys($table) as $key) {
|
||||
foreach ($catalog[$key] ?? [] as $indexed) {
|
||||
if ($indexed === $wanted) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카탈로그를 lazy 적재해 반환합니다.
|
||||
*
|
||||
* @return array<string, array<int, array<int, string>>>|null 적재 실패 시 null (미캐시)
|
||||
*/
|
||||
private static function fulltextIndexCatalog(): ?array
|
||||
{
|
||||
if (static::$fulltextIndexCatalog !== null) {
|
||||
return static::$fulltextIndexCatalog;
|
||||
}
|
||||
|
||||
try {
|
||||
$sets = (new FulltextIndexInspector)->indexedColumnSets();
|
||||
} catch (Throwable $e) {
|
||||
// 실패는 캐시하지 않는다 — 다음 요청에서 다시 시도한다.
|
||||
if (! isset(static::$warnedMissingIndexes['__catalog_load__'])) {
|
||||
static::$warnedMissingIndexes['__catalog_load__'] = true;
|
||||
Log::warning('FULLTEXT 인덱스 카탈로그 적재에 실패해 이번 요청은 부분일치(LIKE)로 검색합니다.', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($sets as $table => $columnSets) {
|
||||
$normalized[strtolower((string) $table)] = array_map(
|
||||
static fn (array $set): array => self::normalizeColumnSet($set),
|
||||
$columnSets
|
||||
);
|
||||
}
|
||||
|
||||
return static::$fulltextIndexCatalog = $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 집합을 비교 가능한 형태로 정규화합니다 (한정자 제거·소문자·정렬·중복 제거).
|
||||
*
|
||||
* @param array<int, string> $columns 컬럼명 목록 (`posts.title` 형태 허용)
|
||||
* @return array<int, string> 정규화된 컬럼 집합
|
||||
*/
|
||||
private static function normalizeColumnSet(array $columns): array
|
||||
{
|
||||
$normalized = array_values(array_unique(array_map(
|
||||
static function (string $column): string {
|
||||
$pos = strrpos($column, '.');
|
||||
|
||||
return strtolower($pos === false ? $column : substr($column, $pos + 1));
|
||||
},
|
||||
$columns
|
||||
)));
|
||||
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블명 조회 키 후보를 만듭니다 (프리픽스 유/무 양쪽).
|
||||
*
|
||||
* 카탈로그 키는 프리픽스 제거 형태다. 호출자가 프리픽스 포함 이름을 넘겨도 판정이
|
||||
* 되도록 제거 후보를 함께 만든다. 프리픽스가 빈 문자열인 설치에서도 동작한다.
|
||||
*
|
||||
* @param string $table 테이블명
|
||||
* @return array<int, string> 소문자 조회 키 후보
|
||||
*/
|
||||
private static function tableLookupKeys(string $table): array
|
||||
{
|
||||
$keys = [strtolower($table)];
|
||||
|
||||
$prefix = DB::getTablePrefix();
|
||||
if ($prefix !== '' && str_starts_with($table, $prefix)) {
|
||||
$keys[] = strtolower(substr($table, strlen($prefix)));
|
||||
}
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카탈로그를 테스트용으로 시드합니다.
|
||||
*
|
||||
* @param array<string, array<int, array<int, string>>> $catalog 테이블명 => 컬럼 집합 목록
|
||||
*/
|
||||
public static function primeFulltextIndexCatalog(array $catalog): void
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($catalog as $table => $columnSets) {
|
||||
$normalized[strtolower((string) $table)] = array_map(
|
||||
static fn (array $set): array => self::normalizeColumnSet($set),
|
||||
$columnSets
|
||||
);
|
||||
}
|
||||
|
||||
static::$fulltextIndexCatalog = $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카탈로그 캐시와 경고 기록을 함께 초기화합니다 (인덱스 생성 직후·테스트).
|
||||
*/
|
||||
public static function forgetFulltextIndexCatalog(): void
|
||||
{
|
||||
static::$fulltextIndexCatalog = null;
|
||||
static::$warnedMissingIndexes = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 부재 폴백 사실을 테이블+컬럼 조합당 한 번 기록합니다.
|
||||
*
|
||||
* 드라이버 미지원 폴백(정상 경로)과 달리, 드라이버는 지원하는데 인덱스가 없어
|
||||
* 내려가는 폴백은 설치 결함의 신호이므로 기록을 남긴다. 기록하지 않으면
|
||||
* "검색이 느리고 관련도가 없다" 는 증상만 남고 원인을 찾을 단서가 없다.
|
||||
*
|
||||
* @param string $table 테이블명
|
||||
* @param array<int, string> $columns MATCH 대상 컬럼명
|
||||
*/
|
||||
protected static function warnMissingFulltextIndexOnce(string $table, array $columns): void
|
||||
{
|
||||
$normalized = self::normalizeColumnSet($columns);
|
||||
$key = strtolower($table).':'.implode(',', $normalized);
|
||||
|
||||
if (isset(static::$warnedMissingIndexes[$key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::$warnedMissingIndexes[$key] = true;
|
||||
|
||||
Log::warning('대상 컬럼 조합의 FULLTEXT 인덱스가 없어 부분일치(LIKE)로 검색합니다. 관련도 정렬이 적용되지 않고 전체 스캔이 발생합니다.', [
|
||||
'table' => $table,
|
||||
'columns' => $normalized,
|
||||
// search:index 는 **이미 존재하는** 인덱스만 열거해 상태를 보므로, 인덱스가
|
||||
// 아예 없는 이 상황은 그 목록에 나타나지도 재생성 대상이 되지도 않는다.
|
||||
// 안내가 그 커맨드를 가리키면 운영자는 "이상 없음" 을 보고 추적이 끊긴다.
|
||||
'hint' => '해당 테이블의 FULLTEXT 인덱스를 만드는 마이그레이션이 적용되지 않았습니다. 그 확장의 마이그레이션을 다시 실행해 인덱스를 생성하세요.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* FULLTEXT BOOLEAN MODE 검색어에서 연산자 문자를 제거하고 안전한 구문으로 변환합니다.
|
||||
*
|
||||
@@ -414,6 +616,19 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $query->getModel()->getTable();
|
||||
|
||||
if (! static::fulltextIndexCoversColumns($table, $columns)) {
|
||||
// 드라이버는 지원하는데 이 컬럼 조합을 커버하는 인덱스가 없다 — MATCH 를
|
||||
// 조립하면 실행 시 1191(Can't find FULLTEXT index) 이 되고, 그 오류는
|
||||
// 소비처의 catch 에 삼켜져 "검색 결과 0건" 으로 위장된다. LIKE 로 내려가고
|
||||
// 설치 결함의 신호로 조합당 1회 기록한다.
|
||||
static::warnMissingFulltextIndexOnce($table, $columns);
|
||||
KeywordSearch::applyLikeMatch($query, $columns, $keyword, $boolean);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ftKeyword = static::sanitizeBooleanModeKeyword($keyword);
|
||||
|
||||
if ($ftKeyword === '') {
|
||||
@@ -478,6 +693,9 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
|
||||
"ALTER TABLE `{$prefix}{$table}` "
|
||||
."ADD FULLTEXT INDEX `{$indexName}` (`{$columnList}`){$parserClause}"
|
||||
);
|
||||
|
||||
// 방금 만든 인덱스가 이 프로세스의 커버 판정에 바로 반영되도록 카탈로그를 비운다.
|
||||
static::forgetFulltextIndexCatalog();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -523,6 +741,21 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
|
||||
|
||||
$useFulltext = static::supportsFulltext();
|
||||
|
||||
if ($useFulltext) {
|
||||
// 이 경로는 컬럼마다 단일 MATCH(col) 를 조립하므로, 각 컬럼이 **개별**
|
||||
// FULLTEXT 인덱스로 커버될 때만 MATCH 를 쓴다. 복합 인덱스만 있는 테이블은
|
||||
// 실행 시 1191 이 되므로 LIKE 분기로 내려간다.
|
||||
$uncovered = array_values(array_filter(
|
||||
$columns,
|
||||
static fn (string $column): bool => ! static::fulltextIndexCoversColumns($model->getTable(), [$column])
|
||||
));
|
||||
|
||||
if ($uncovered !== []) {
|
||||
static::warnMissingFulltextIndexOnce($model->getTable(), $uncovered);
|
||||
$useFulltext = false;
|
||||
}
|
||||
}
|
||||
|
||||
// prefix 포함 테이블명 (selectRaw에서 사용)
|
||||
$prefix = DB::getTablePrefix();
|
||||
$qualifiedTable = $prefix.$model->getTable();
|
||||
@@ -555,12 +788,11 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
|
||||
$scoreRaw = '('.implode(' + ', $scoreExpressions).') as _ft_score';
|
||||
$this->applySelect($query, $model, $qualifiedTable, $scoreRaw, $scoreBindings, $keysOnly);
|
||||
} else {
|
||||
// LIKE fallback (PostgreSQL, SQLite 등)
|
||||
$query->where(function ($q) use ($columns, $keyword) {
|
||||
foreach ($columns as $column) {
|
||||
$q->orWhere($column, 'LIKE', "%{$keyword}%");
|
||||
}
|
||||
});
|
||||
// LIKE fallback (FULLTEXT 미지원 드라이버 + 개별 인덱스 미커버)
|
||||
// 폴백 술어 조립은 KeywordSearch 단일 지점에 맡긴다 — 와일드카드(% _ \)
|
||||
// escape 와 드라이버별 대소문자 무시 연산자가 그쪽에만 있어, 여기서 손으로
|
||||
// 조립하면 같은 "부분일치" 인데 검색어에 % 가 섞였을 때 결과가 갈린다.
|
||||
KeywordSearch::applyLikeMatch($query, $columns, $keyword);
|
||||
|
||||
// 스코어 고정 0 (관련성 순위 불가)
|
||||
$this->applySelect($query, $model, $qualifiedTable, '0 as _ft_score', [], $keysOnly);
|
||||
|
||||
@@ -93,6 +93,48 @@ class FulltextIndexInspector
|
||||
return $definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 스키마의 FULLTEXT 인덱스 컬럼 집합을 테이블별로 수집합니다.
|
||||
*
|
||||
* `discover()` 와 같은 INFORMATION_SCHEMA 조회를 쓰되, 인덱스마다 `SHOW CREATE TABLE`
|
||||
* 을 도는 파서 해석은 하지 않는 경량판입니다 — 커버 판정에는 컬럼 집합만 필요합니다.
|
||||
*
|
||||
* 반환 키는 프리픽스를 제거한 테이블명입니다. `Model::getTable()` 이 프리픽스 미포함
|
||||
* 이름을 돌려주므로, 판정하는 쪽이 그 이름으로 바로 조회할 수 있게 맞춥니다.
|
||||
*
|
||||
* @return array<string, array<int, array<int, string>>> 테이블명 => 인덱스별 컬럼 집합 목록
|
||||
*/
|
||||
public function indexedColumnSets(): array
|
||||
{
|
||||
if (! DatabaseFulltextEngine::supportsFulltext()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::select(
|
||||
'SELECT TABLE_NAME AS table_name,
|
||||
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND INDEX_TYPE = ?
|
||||
GROUP BY TABLE_NAME, INDEX_NAME',
|
||||
['FULLTEXT']
|
||||
);
|
||||
|
||||
$prefix = DB::getTablePrefix();
|
||||
$sets = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$table = (string) $row->table_name;
|
||||
|
||||
if ($prefix !== '' && str_starts_with($table, $prefix)) {
|
||||
$table = substr($table, strlen($prefix));
|
||||
}
|
||||
|
||||
$sets[strtolower($table)][] = explode(',', (string) $row->columns);
|
||||
}
|
||||
|
||||
return $sets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 하나의 건강도를 판정합니다.
|
||||
*
|
||||
|
||||
@@ -80,6 +80,33 @@ final class SearchCategoryPayload
|
||||
], $extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 검색이 예외로 실패했을 때의 페이로드를 만듭니다.
|
||||
*
|
||||
* 실패한 0건을 "정확한 0건" 으로 말하지 않는다 — `total_is_exact=false` 로 내보내
|
||||
* 배지가 정확한 값처럼 그려지는 것을 막고, `failed` 플래그로 화면이 "결과 없음" 과
|
||||
* 구분되는 오류 안내를 그릴 수 있게 한다. 키 집합은 다른 팩토리와 동일하게 유지해
|
||||
* 화면이 분기 없이 읽게 한다.
|
||||
*
|
||||
* @param array<string, mixed> $extra 도메인 고유 필드 (available_boards 등)
|
||||
* @return array<string, mixed> 카테고리 페이로드
|
||||
*/
|
||||
public static function failed(array $extra = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'failed' => true,
|
||||
'total' => 0,
|
||||
'total_relation' => TotalRelation::AtLeast->value,
|
||||
'total_is_exact' => false,
|
||||
'result_cap' => null,
|
||||
'last_page' => null,
|
||||
'has_more_pages' => false,
|
||||
'next_cursor' => null,
|
||||
'prev_cursor' => null,
|
||||
'items' => [],
|
||||
], $extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 없이 건수만 필요한 자리(비활성 탭 배지)의 페이로드를 만듭니다.
|
||||
*
|
||||
|
||||
@@ -1498,9 +1498,11 @@ class LanguagePackService
|
||||
public function checkUpdates(?string $identifier = null): array
|
||||
{
|
||||
// 요구사항 #4: 모듈 패턴 — 모든 source_type 점검 (GitHub 1순위, 실패 시 bundled 폴백)
|
||||
// 전량 순회는 allForUpdateCheck() — paginate 는 HTTP page 파라미터를 암묵 해석해
|
||||
// `?page=2` 요청에서 순회가 0건이 된다 (공개 이슈 #102 동형)
|
||||
$packs = $identifier
|
||||
? collect([$this->repository->findByIdentifier($identifier)])->filter()
|
||||
: $this->repository->paginate([], 1000)->getCollection();
|
||||
: $this->repository->allForUpdateCheck();
|
||||
|
||||
$checked = 0;
|
||||
$updates = 0;
|
||||
@@ -1675,8 +1677,9 @@ class LanguagePackService
|
||||
return $updates;
|
||||
}
|
||||
|
||||
// DB 에 설치된 모든 언어팩 (보호 가상 행 제외 — protected 로 필터됨)
|
||||
$packs = $this->repository->paginate([], 1000)->getCollection();
|
||||
// DB 에 설치된 모든 언어팩 (보호 가상 행 제외 — protected 로 필터됨).
|
||||
// 전량 순회는 allForUpdateCheck() — paginate 의 암묵 page 해석 회피 (#102 동형)
|
||||
$packs = $this->repository->allForUpdateCheck();
|
||||
|
||||
foreach ($packs as $pack) {
|
||||
if (! $this->hasBundledManifest($pack->identifier)) {
|
||||
|
||||
@@ -156,8 +156,10 @@ class BoundedPaginator
|
||||
|
||||
// newQuery() 는 같은 커넥션의 빈 빌더를 만든다. fromSub 가 바인딩까지 옮겨 주므로
|
||||
// 서브쿼리 SQL 을 문자열로 조립하거나 mergeBindings 를 부를 필요가 없다.
|
||||
// 별칭은 빌더가 wrapTable 로 접두사를 붙이므로 `g7_` 을 직접 쓰면 `g7_g7_...` 이
|
||||
// 된다 (참조 0곳이라 실동작 무해였으나 명명 혼선 제거).
|
||||
$counted = (int) $bounded->newQuery()
|
||||
->fromSub($bounded, 'g7_bounded_total')
|
||||
->fromSub($bounded, 'bounded_total')
|
||||
->count();
|
||||
|
||||
return $counted > $resultCap
|
||||
|
||||
@@ -62,10 +62,16 @@ _단건 응답: `data` 객체의 필드._
|
||||
| next_cursor | string\|null | `null` | 다음 페이지 커서. 커서 방식으로 응답했을 때만 채워지며, `page` 방식 응답에서는 `null` 입니다 |
|
||||
| prev_cursor | string\|null | `null` | 이전 페이지 커서. 위와 같습니다 |
|
||||
| counts_are_exact | object | `{}` | 카테고리별 총 건수 정확도 (`{"posts": true, "products": false}`). 탭 배지가 카테고리마다 그려지므로 정확도도 카테고리마다 제공됩니다 — 정확하지 않은 배지는 화면에서 "이상" 으로 표기됩니다 |
|
||||
| categories_failed | object | `{}` | 카테고리별 검색 실패 여부 (`{"posts": true, "products": false}`). 카테고리 검색이 서버 예외로 실패하면 그 카테고리만 `true` 가 되며, 화면은 이 값으로 "검색 결과 없음" 과 구분되는 오류 안내를 그립니다. 실패해도 HTTP 는 200 입니다 (다른 카테고리 결과는 정상 전달) |
|
||||
| search_failed | boolean | `false` | 하나 이상의 카테고리가 실패했는지 여부 (`categories_failed` 의 논리합) |
|
||||
|
||||
카테고리(탭) 중 하나라도 상한에 걸리면 합계도 정확하지 않습니다 — 정확한 카테고리 몇 개를
|
||||
더해 봐야 전체가 정확해지지 않기 때문입니다. 그 경우 `total_is_exact` 는 `false` 가 됩니다.
|
||||
|
||||
실패한 카테고리의 페이로드는 `failed: true` 와 함께 `total: 0`, `total_is_exact: false`
|
||||
(`total_relation: at_least`) 로 내려갑니다 — 실패한 0건을 "정확한 0건" 으로 말하지 않기
|
||||
위함입니다. 배지·건수 표기는 이 정확도를 그대로 따릅니다.
|
||||
|
||||
> 상한·페이지 이동 규약 상세: [pagination.md](../pagination.md)
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
```text
|
||||
1. Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ngram 기반 검색 (기본 드라이버)
|
||||
2. FulltextSearchable 인터페이스: searchableColumns() + searchableWeights() 구현 필수
|
||||
3. LIKE fallback 자동 적용: FULLTEXT 미지원 DBMS(SQLite, PostgreSQL)에서 자동 전환
|
||||
3. LIKE fallback 자동 적용: FULLTEXT 미지원 DBMS(SQLite, PostgreSQL) + 대상 테이블·컬럼
|
||||
조합의 FULLTEXT 인덱스 부재 시 (후자는 설치 결함 신호라 조합당 프로세스 1회 기록)
|
||||
4. 확장 포인트: core.search.engine_drivers(엔진) + core.search.index_maintainers(인덱스 점검) 필터 훅
|
||||
5. 인덱스 재생성은 언제나 선택 사항 — 자동 트리거 없음 (테이블 잠금·전체 재색인 비용)
|
||||
6. AsUnicodeJson 캐스트: JSON 컬럼 FULLTEXT 검색 시 한글 \uXXXX 이스케이프 방지 필수
|
||||
@@ -176,6 +177,39 @@ DatabaseFulltextEngine::supportsFulltext();
|
||||
DatabaseFulltextEngine::supportsNgramParser();
|
||||
```
|
||||
|
||||
### 인덱스 커버 판정 — MATCH 는 인덱스가 있어야만 조립한다
|
||||
|
||||
드라이버가 FULLTEXT 를 지원해도, MySQL 의 `MATCH(a, b)` 는 어떤 FULLTEXT 인덱스의
|
||||
컬럼 **집합과 정확히 일치**(순서 무관)해야 실행된다. 복합 `(title, content)` 인덱스는
|
||||
`MATCH(title)` 단독을 커버하지 못한다. 인덱스가 없는 채 MATCH 를 실행하면
|
||||
`1191 Can't find FULLTEXT index` 가 되고, 그 오류가 소비처의 catch 에 삼켜지면
|
||||
화면에는 "검색 결과 0건" 으로만 나타난다 (부분 실패 설치에서 실제 발생 — 공개 #103).
|
||||
|
||||
그래서 엔진은 MATCH 조립 전에 `fulltextIndexCoversColumns($table, $columns)` 로
|
||||
커버 여부를 판정한다:
|
||||
|
||||
- 판정은 컬럼 한정자 제거·소문자·정렬 정규화 후 **집합 동등 비교**다. 순서가 달라도
|
||||
같은 집합이면 커버한다 (`['content','title']` ≡ `['title','content']`).
|
||||
- Scout 경로(`performSearch`)는 컬럼마다 단일 `MATCH(col)` 를 조립하므로, **각 컬럼이
|
||||
개별 단일 인덱스로** 커버될 때만 MATCH 를 쓴다. 복합 인덱스만 있는 테이블은 LIKE 로
|
||||
내려간다.
|
||||
- `applyAny()` 는 컬럼별 `apply(단일)` 재귀라 게이트도 단일 컬럼으로 판정한다 — 한 OR
|
||||
그룹 안에 MATCH 와 LIKE 가 혼재할 수 있으며, 유효한 SQL 이고 의도된 동작이다.
|
||||
- 커버되지 않아 내려가는 폴백은 **설치 결함의 신호**이므로 테이블+컬럼 조합당 프로세스
|
||||
1회 `Log::warning` 을 남긴다 (복구 안내 포함). 드라이버 미지원 폴백은
|
||||
종전대로 무경고다 — 그 DBMS 에서는 부분일치가 정상 경로다.
|
||||
- 이 경고의 복구 경로는 **해당 확장의 인덱스 생성 마이그레이션 재실행**이다.
|
||||
`search:index` 를 안내하지 않는다 — 그 점검은 `INFORMATION_SCHEMA` 에 **이미 존재하는**
|
||||
FULLTEXT 인덱스만 열거하고 `--repair` 는 그중 색인이 낡은 것만 재생성하므로, 인덱스가
|
||||
아예 없는 이 상황은 목록에 나타나지도 재생성 대상이 되지도 않는다. 안내가 그쪽을
|
||||
가리키면 운영자는 "이상 없음" 을 보고 원인 추적이 거기서 끊긴다.
|
||||
|
||||
인덱스 카탈로그는 `INFORMATION_SCHEMA.STATISTICS` 에서 lazy 적재하는 **프로세스(static)
|
||||
캐시**다. 적재 실패(Throwable)는 캐시하지 않고 그 요청만 LIKE 로 내려간다 — 일시 장애가
|
||||
워커 수명 내내 강등으로 굳지 않게 하기 위함이다. `addFulltextIndex()` 성공 직후에는
|
||||
카탈로그를 무효화해 같은 프로세스에서 바로 반영된다. 그 외의 인덱스 변경(배포 등)은
|
||||
FPM 워커 재시작 시점에 반영된다.
|
||||
|
||||
### SCOUT_DRIVER 전환
|
||||
|
||||
`.env`에서 드라이버를 변경하면 즉시 적용됩니다:
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
### Added
|
||||
|
||||
- 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다.
|
||||
- 결제 취소에 필요한 서버 IP 등록 안내의 일본어 번역을 추가했습니다 — 등록이 필요한 이유, 상점 관리자에서의 등록 경로, 등록할 IP 확인·복사 안내가 일본어로 표시됩니다.
|
||||
- 관리자 결제 상세의 결제 상태·취소 금액·취소 일시·환불 상태·환불 번호 항목명과, 취소 완료·부분 취소 상태 표시의 일본어 번역을 추가했습니다.
|
||||
- 가상계좌 입금 통보 주소의 등록 경로 안내와 간편결제 표시 설정 항목명의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.2] - 2026-08-10
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"nhnkcp_test_map": "テスト決済マップ",
|
||||
"nicepay_test_map": "テスト決済マップ",
|
||||
"settings": "決済設定",
|
||||
"vbank_info": "仮想口座情報"
|
||||
"vbank_info": "仮想口座情報",
|
||||
"nhnkcp_easy_pay_display_map": "簡易決済表示マップ"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
@@ -78,7 +79,14 @@
|
||||
"escrow_delivery_change_button": "配送情報編集",
|
||||
"escrow_delivery_registering": "登録中...",
|
||||
"escrow_delivery_success": "配送情報がKCPに登録されました。",
|
||||
"escrow_delivery_error": "配送登録に失敗しました。"
|
||||
"escrow_delivery_error": "配送登録に失敗しました。",
|
||||
"pay_status_cancel_completed": "キャンセル完了",
|
||||
"pay_status_partial_cancelled": "部分キャンセル",
|
||||
"result_payment_status": "現在の決済ステータス",
|
||||
"result_cancelled_amount": "キャンセル金額",
|
||||
"result_cancelled_at": "キャンセル日時",
|
||||
"result_refund_status": "返金ステータス",
|
||||
"result_refund_number": "返金番号"
|
||||
},
|
||||
"settings": {
|
||||
"title": "NHN KCP設定",
|
||||
@@ -133,7 +141,16 @@
|
||||
"escrow_test_site_cd_hint": "KCPエスクロー テスト用site_cd(デフォルト:T0007)",
|
||||
"section_escrow_notify": "エスクロー共通通知URL",
|
||||
"escrow_common_notify_hint": "NHN KCP加盟店管理画面でこのURLを共通通知URLとして登録する必要があり、購入確認·キャンセルが自動的に処理されます。",
|
||||
"escrow_common_notify_copied": "URLがクリップボードにコピーされました。"
|
||||
"escrow_common_notify_copied": "URLがクリップボードにコピーされました。",
|
||||
"cancel_server_ip_title": "決済キャンセルサーバーIP登録が必要です",
|
||||
"cancel_server_ip_body": "NHN KCPは取引キャンセルのセキュリティのため、ショッピングモールサーバーの外部送信IPが店舗管理者に登録された場合にのみ、ショッピングモール管理者から実決済のキャンセルを許可します。",
|
||||
"cancel_server_ip_path": "NHN KCP店舗管理者 > 技術管理センター > セキュリティ管理 > サーバーIP設定(決済)",
|
||||
"cancel_server_ip_effect": "IPが登録されていないか、サーバーIPが変更された場合、この管理者画面で決済キャンセルが失敗する可能性があり、この場合KCP店舗管理者から直接キャンセルする必要があります。",
|
||||
"cancel_server_ip_value_label": "登録するサーバーIP",
|
||||
"cancel_server_ip_unavailable": "サーバーIP確認が必要です",
|
||||
"cancel_server_ip_copied": "サーバーIPがクリップボードにコピーされました。",
|
||||
"cancel_server_ip_link": "NHN KCP店舗管理者を開く",
|
||||
"vbank_notify_path": "登録パス: NHN KCP店舗管理者 > 技術管理センター > ウェブフック(Webhook)管理 > ウェブフックURL設定"
|
||||
},
|
||||
"easy_pay": {
|
||||
"section_title": "簡便決済"
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-16
|
||||
|
||||
### Added
|
||||
|
||||
- 통합검색 실패 안내 문구(`search.failed.title`, `search.failed.suggestion`) 일본어 번역 추가 — 검색 중 오류가 발생했을 때 "검색 결과 없음" 과 구분되는 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.1.0] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"pages_in_all": "ページの検索結果がありません",
|
||||
"suggestion": "別の検索キーワードで試してみてください"
|
||||
},
|
||||
"failed": {
|
||||
"title": "検索中にエラーが発生しました",
|
||||
"suggestion": "しばらくしてからもう一度お試しください"
|
||||
},
|
||||
"page_published_at": "公開日:{{date}}",
|
||||
"view_more": "さらに表示",
|
||||
"view_all": "すべて表示",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
|
||||
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-basic",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Gnuboard7\HelloModule\Tests;
|
||||
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\Module;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
@@ -10,6 +11,7 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Gnuboard7\HelloModule\Providers\HelloModuleServiceProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -52,7 +54,7 @@ abstract class ModuleTestCase extends TestCase
|
||||
|
||||
$this->registerModuleAutoload();
|
||||
|
||||
$this->app->register(\Modules\Gnuboard7\HelloModule\Providers\HelloModuleServiceProvider::class);
|
||||
$this->app->register(HelloModuleServiceProvider::class);
|
||||
|
||||
$this->runModuleMigrationIfNeeded();
|
||||
|
||||
@@ -80,7 +82,7 @@ abstract class ModuleTestCase extends TestCase
|
||||
*/
|
||||
private function snapshotHookManager(): void
|
||||
{
|
||||
$ref = new \ReflectionClass(\App\Extension\HookManager::class);
|
||||
$ref = new \ReflectionClass(HookManager::class);
|
||||
$this->hookSnapshot = [
|
||||
'hooks' => $ref->getProperty('hooks')->getValue(),
|
||||
'filters' => $ref->getProperty('filters')->getValue(),
|
||||
@@ -97,7 +99,7 @@ abstract class ModuleTestCase extends TestCase
|
||||
return;
|
||||
}
|
||||
|
||||
$ref = new \ReflectionClass(\App\Extension\HookManager::class);
|
||||
$ref = new \ReflectionClass(HookManager::class);
|
||||
$ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']);
|
||||
$ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']);
|
||||
$ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']);
|
||||
@@ -153,8 +155,12 @@ abstract class ModuleTestCase extends TestCase
|
||||
$file = $moduleBasePath.'/src/'.str_replace('\\', '/', $relativeClass).'.php';
|
||||
}
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -221,12 +227,12 @@ abstract class ModuleTestCase extends TestCase
|
||||
'version' => '0.1.0-beta.1',
|
||||
]);
|
||||
|
||||
$hookManager = app(\App\Extension\HookManager::class);
|
||||
$hookManager = app(HookManager::class);
|
||||
foreach ($module->getHookListeners() as $listenerClass) {
|
||||
foreach ($listenerClass::getSubscribedHooks() as $hookName => $config) {
|
||||
$method = is_array($config) ? ($config['method'] ?? 'handle') : 'handle';
|
||||
$priority = is_array($config) ? ($config['priority'] ?? 10) : 10;
|
||||
$hookManager->addAction($hookName, [new $listenerClass(), $method], $priority);
|
||||
$hookManager->addAction($hookName, [new $listenerClass, $method], $priority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 통합검색에서 게시글 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 샘플 게시글 생성 도구가 데이터베이스 접두어를 기본값이 아닌 값으로 설치한 사이트에서 오류가 나던 문제를 수정했습니다. (#101 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 관리자 게시판 설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 정보가 계속 노출되던 문제를 수정했습니다. 환경설정 기본값 일괄 적용에도 같은 처리가 적용됩니다.
|
||||
- 일부 설정 저장 경로가 다른 저장 경로와 다른 처리를 거쳐, 저장 직후 조회가 저장 전 값을 돌려주거나 값의 형식이 정리되지 않은 채 남던 문제를 수정했습니다.
|
||||
- 게시판 유형 삭제가 실패했을 때 내부 오류 원문이 그대로 표시되던 문제를 수정했습니다. 이제 사용 중인 유형·기본 유형이라 삭제할 수 없는 경우에는 그 사유가 안내되고, 그 밖의 오류는 서버 오류로 구분됩니다.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Modules\Sirsoft\Board\Database\Seeders\Sample;
|
||||
|
||||
use App\Extension\Storage\ModuleStorageDriver;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
@@ -10,7 +11,6 @@ use Carbon\Carbon;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
|
||||
@@ -362,71 +362,75 @@ class PostSampleSeeder extends Seeder
|
||||
{
|
||||
$this->command->info('비정규화 카운트 컬럼 동기화 중...');
|
||||
|
||||
// raw SQL 은 접두사가 자동 부착되지 않는다 — 리터럴 `g7_` 을 박으면
|
||||
// DB_PREFIX 가 다른 설치에서 1146(테이블 없음)이 된다.
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
// posts.comments_count (다른 테이블 참조 — 직접 서브쿼리 가능)
|
||||
DB::statement('
|
||||
UPDATE g7_board_posts p
|
||||
DB::statement("
|
||||
UPDATE {$prefix}board_posts p
|
||||
SET comments_count = (
|
||||
SELECT COUNT(*) FROM g7_board_comments c
|
||||
SELECT COUNT(*) FROM {$prefix}board_comments c
|
||||
WHERE c.post_id = p.id AND c.deleted_at IS NULL
|
||||
)
|
||||
');
|
||||
");
|
||||
|
||||
// posts.replies_count (자기 참조 — 파생 테이블 사용)
|
||||
DB::statement('
|
||||
UPDATE g7_board_posts p
|
||||
DB::statement("
|
||||
UPDATE {$prefix}board_posts p
|
||||
LEFT JOIN (
|
||||
SELECT parent_id, COUNT(*) AS cnt
|
||||
FROM g7_board_posts
|
||||
FROM {$prefix}board_posts
|
||||
WHERE parent_id IS NOT NULL AND deleted_at IS NULL
|
||||
GROUP BY parent_id
|
||||
) t ON t.parent_id = p.id
|
||||
SET p.replies_count = COALESCE(t.cnt, 0)
|
||||
');
|
||||
");
|
||||
|
||||
// posts.attachments_count (다른 테이블 참조 — 직접 서브쿼리 가능)
|
||||
DB::statement('
|
||||
UPDATE g7_board_posts p
|
||||
DB::statement("
|
||||
UPDATE {$prefix}board_posts p
|
||||
SET attachments_count = (
|
||||
SELECT COUNT(*) FROM g7_board_attachments a
|
||||
SELECT COUNT(*) FROM {$prefix}board_attachments a
|
||||
WHERE a.post_id = p.id AND a.deleted_at IS NULL
|
||||
)
|
||||
');
|
||||
");
|
||||
|
||||
// comments.replies_count (자기 참조 — 파생 테이블 사용)
|
||||
DB::statement('
|
||||
UPDATE g7_board_comments c
|
||||
DB::statement("
|
||||
UPDATE {$prefix}board_comments c
|
||||
LEFT JOIN (
|
||||
SELECT parent_id, COUNT(*) AS cnt
|
||||
FROM g7_board_comments
|
||||
FROM {$prefix}board_comments
|
||||
WHERE parent_id IS NOT NULL AND deleted_at IS NULL
|
||||
GROUP BY parent_id
|
||||
) t ON t.parent_id = c.id
|
||||
SET c.replies_count = COALESCE(t.cnt, 0)
|
||||
');
|
||||
");
|
||||
|
||||
// boards.posts_count (게시판별 미삭제 게시글 수 — 답글 포함)
|
||||
DB::statement('
|
||||
UPDATE g7_boards b
|
||||
DB::statement("
|
||||
UPDATE {$prefix}boards b
|
||||
LEFT JOIN (
|
||||
SELECT board_id, COUNT(*) AS cnt
|
||||
FROM g7_board_posts
|
||||
FROM {$prefix}board_posts
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY board_id
|
||||
) t ON t.board_id = b.id
|
||||
SET b.posts_count = COALESCE(t.cnt, 0)
|
||||
');
|
||||
");
|
||||
|
||||
// boards.comments_count (게시판별 미삭제 댓글 수)
|
||||
DB::statement('
|
||||
UPDATE g7_boards b
|
||||
DB::statement("
|
||||
UPDATE {$prefix}boards b
|
||||
LEFT JOIN (
|
||||
SELECT board_id, COUNT(*) AS cnt
|
||||
FROM g7_board_comments
|
||||
FROM {$prefix}board_comments
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY board_id
|
||||
) t ON t.board_id = b.id
|
||||
SET b.comments_count = COALESCE(t.cnt, 0)
|
||||
');
|
||||
");
|
||||
|
||||
$this->command->info(' ✅ posts.comments_count / posts.replies_count / posts.attachments_count / comments.replies_count / boards.posts_count / boards.comments_count 일괄 갱신 완료');
|
||||
}
|
||||
@@ -470,6 +474,9 @@ class PostSampleSeeder extends Seeder
|
||||
|
||||
$datePath = date('Y/m/d');
|
||||
|
||||
// 모듈 스토리지 드라이버 경유 — 최종 경로는 {identifier}/attachments/{path} 로 종전과 동일
|
||||
$storage = new ModuleStorageDriver('sirsoft-board', 'modules');
|
||||
|
||||
for ($i = 0; $i < self::SAMPLE_IMAGE_COUNT; $i++) {
|
||||
$filename = Str::uuid().'.jpg';
|
||||
$path = "samples/{$datePath}/{$filename}";
|
||||
@@ -484,7 +491,7 @@ class PostSampleSeeder extends Seeder
|
||||
|
||||
// 스토리지에 파일 저장
|
||||
$storagePath = "sirsoft-board/attachments/{$path}";
|
||||
Storage::disk('modules')->put($storagePath, $imageContent);
|
||||
$storage->put('attachments', $path, $imageContent);
|
||||
|
||||
// 파일 정보 저장
|
||||
$this->sampleImages[] = [
|
||||
|
||||
@@ -120,7 +120,10 @@ class SearchPostsListener implements HookListenerInterface
|
||||
|
||||
$results['posts'] = $this->buildSearchResult($boards, $q, $context);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Search posts error', ['message' => $e->getMessage(), 'q' => $q]);
|
||||
// 실패를 카테고리 키 미설정으로 삼키면 화면이 "검색 결과 없음" 을 그린다 —
|
||||
// failed 페이로드로 표면화하고, 원인 추적을 위해 스택을 함께 남긴다 (#103).
|
||||
Log::error('Search posts error', ['message' => $e->getMessage(), 'q' => $q, 'exception' => $e]);
|
||||
$results['posts'] = SearchCategoryPayload::failed(['available_boards' => []]);
|
||||
}
|
||||
|
||||
return $results;
|
||||
|
||||
@@ -261,8 +261,12 @@ abstract class ModuleTestCase extends TestCase
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $moduleBasePath.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,15 +7,16 @@ use App\Models\User;
|
||||
use App\Support\Query\BoundedPage;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Board\Listeners\SearchPostsListener;
|
||||
use Modules\Sirsoft\Board\Services\BoardService;
|
||||
use Modules\Sirsoft\Board\Services\PostService;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* SearchPostsListener 단위 테스트 — 게시판별 권한 필터링 및 날짜 포맷 검증
|
||||
*/
|
||||
class SearchPostsListenerTest extends TestCase
|
||||
class SearchPostsListenerTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 검색 결과 페이지(BoundedPage)를 만듭니다.
|
||||
@@ -224,6 +225,46 @@ class SearchPostsListenerTest extends TestCase
|
||||
$this->assertNotEmpty($item['created_at_formatted']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 실패가 "결과 0건" 으로 위장되지 않고 failed 페이로드로 표면화되는지 확인 (#103)
|
||||
*
|
||||
* 수정 전에는 catch 가 로그만 남기고 카테고리 키를 설정하지 않아, 화면이
|
||||
* "검색 결과가 없습니다" 를 그렸다.
|
||||
*
|
||||
* @effects failed_flag_in_response, exception_stack_logged
|
||||
*/
|
||||
public function test_search_failure_surfaces_failed_payload_and_logs_exception(): void
|
||||
{
|
||||
Log::spy();
|
||||
|
||||
$exception = new \RuntimeException('DB 오류 재현');
|
||||
$this->boardService
|
||||
->method('getActiveBoardsForSearch')
|
||||
->willThrowException($exception);
|
||||
|
||||
$context = [
|
||||
'type' => 'posts',
|
||||
'q' => '문의',
|
||||
'sort' => 'relevance',
|
||||
'page' => 1,
|
||||
'per_page' => 10,
|
||||
'user' => null,
|
||||
'request' => null,
|
||||
];
|
||||
|
||||
$result = $this->listener->searchPosts([], $context);
|
||||
|
||||
$this->assertArrayHasKey('posts', $result);
|
||||
$this->assertTrue($result['posts']['failed'] ?? false, '실패 카테고리에는 failed 플래그가 실려야 합니다.');
|
||||
$this->assertFalse($result['posts']['total_is_exact'], '실패한 0건을 "정확한 0건" 으로 말하면 안 됩니다.');
|
||||
$this->assertSame([], $result['posts']['items']);
|
||||
$this->assertSame([], $result['posts']['available_boards'], '기존 응답 키 집합(available_boards)은 유지되어야 합니다.');
|
||||
|
||||
Log::shouldHaveReceived('error')
|
||||
->withArgs(fn (string $message, array $ctx = []) => ($ctx['exception'] ?? null) === $exception)
|
||||
->once();
|
||||
}
|
||||
|
||||
/**
|
||||
* id를 포함하는 Board 스텁 생성
|
||||
*
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
- 카테고리를 삭제해도 그 카테고리의 이미지 파일이 서버에 그대로 남던 문제를 수정했습니다. 상품 삭제와 동일하게 파일까지 함께 삭제됩니다.
|
||||
- 카테고리 등록·수정 화면에서 이미지를 목록에서 빼도 화면에 그대로 남아 저장되던 문제를 수정했습니다.
|
||||
|
||||
#### 통합검색·상품 문의
|
||||
|
||||
- 통합검색에서 상품 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 상품 문의 목록이 문의가 많은 상품에서 목록 전체를 읽고 나서 잘라내던 것을, 현재 페이지 분량만 읽도록 조회 방식을 개선했습니다. 화면에 보이는 내용은 이전과 동일합니다.
|
||||
|
||||
#### 삭제된 PG·현금영수증 발급사
|
||||
|
||||
- 결제수단에 지정한 PG 사의 플러그인을 삭제해도 그 결제수단이 주문서에 계속 노출되던 문제를 수정했습니다. 이전에는 구매자가 그 수단을 고르면 결제창이 뜨지 않은 채 주문완료로 넘어갔습니다. 이제 해당 결제수단은 주문서에서 제외되고, 관리자 주문설정 화면에는 「지정 PG 삭제됨」 표시가 붙습니다. 살아 있는 PG 로 다시 지정하면 즉시 복구되며, 주문서를 거치지 않고 직접 주문을 시도하는 경우에도 안내와 함께 거절됩니다.
|
||||
|
||||
@@ -171,10 +171,14 @@ class SearchProductsListener implements HookListenerInterface
|
||||
|
||||
$results['products'] = SearchCategoryPayload::fromBounded($searchPage, $format($searchPage->items()));
|
||||
} catch (\Exception $e) {
|
||||
// 실패를 카테고리 키 미설정으로 삼키면 화면이 "검색 결과 없음" 을 그린다 —
|
||||
// failed 페이로드로 표면화하고, 원인 추적을 위해 스택을 함께 남긴다 (#103).
|
||||
Log::error('Search products error', [
|
||||
'message' => $e->getMessage(),
|
||||
'q' => $q,
|
||||
'exception' => $e,
|
||||
]);
|
||||
$results['products'] = SearchCategoryPayload::failed();
|
||||
}
|
||||
|
||||
return $results;
|
||||
|
||||
+2
-1
@@ -32,9 +32,10 @@ interface ProductInquiryRepositoryInterface
|
||||
*
|
||||
* @param int $productId 상품 ID
|
||||
* @param int $perPage 페이지당 개수
|
||||
* @param int|null $page 페이지 번호 (null 이면 요청 파라미터에서 해석)
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function paginateByProductId(int $productId, int $perPage = 10): LengthAwarePaginator;
|
||||
public function paginateByProductId(int $productId, int $perPage = 10, ?int $page = null): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* inquirable_id로 문의 조회 (단일)
|
||||
|
||||
@@ -48,7 +48,7 @@ class ProductInquiryRepository implements ProductInquiryRepositoryInterface
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function paginateByProductId(int $productId, int $perPage = 10): LengthAwarePaginator
|
||||
public function paginateByProductId(int $productId, int $perPage = 10, ?int $page = null): LengthAwarePaginator
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('product_id', $productId)
|
||||
@@ -57,7 +57,8 @@ class ProductInquiryRepository implements ProductInquiryRepositoryInterface
|
||||
->orderBy('id', 'desc')
|
||||
// audit:allow repository-paginate-column-pruning reason: 상품 1건에 종속된 문의 목록 —
|
||||
// where(product_id) 로 이미 좁혀져 OFFSET 이 깊어질 수 없고, 목록이 본문을 그대로 쓴다
|
||||
->paginate($perPage);
|
||||
// page 명시 하달 — HTTP `page` 파라미터 암묵 해석에 기대지 않는다 (#102 동형 예방)
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,42 +86,39 @@ class ProductInquiryService
|
||||
$boardSlug
|
||||
);
|
||||
|
||||
// 피벗 기준 전체 목록 조회 (페이지네이션 전 — 비밀글 필터 적용 위해)
|
||||
$pivots = $this->repository->findByProductId($productId);
|
||||
|
||||
$currentUserId = Auth::id();
|
||||
|
||||
// inquirable_id 목록으로 Post 데이터 일괄 조회
|
||||
$ids = $pivots->pluck('inquirable_id')->all();
|
||||
$posts = [];
|
||||
if (! empty($ids)) {
|
||||
$rawPosts = HookManager::applyFilters(
|
||||
'sirsoft-ecommerce.inquiry.get_by_ids',
|
||||
[],
|
||||
['ids' => $ids, 'slug' => $boardSlug]
|
||||
);
|
||||
foreach ($rawPosts as $post) {
|
||||
$postId = $post['id'] ?? null;
|
||||
if ($postId) {
|
||||
$posts[$postId] = $post;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 비밀글 원문 마스킹은 게시판 훅(getByIds)이 요청자 신원 기준으로 서버측에서
|
||||
// 이미 수행한다(KVE-2026-1914, SecretContentGate SSoT). 아래 exclude_secret 은
|
||||
// 보안 판정이 아니라 단순 "비밀글 행 숨김" 표시 필터일 뿐이며, 노출 여부는
|
||||
// 클라이언트 파라미터와 무관하게 서버가 결정한다.
|
||||
if ($excludeSecret) {
|
||||
// 비밀글 판정(is_secret)은 게시판 모듈의 게시글 데이터에만 있어 SQL 로 거를 수
|
||||
// 없다 — 이 경로만 전량 조회 후 PHP 필터가 구조적으로 필요하다. 기본 화면
|
||||
// 경로(exclude_secret=false)는 아래 else 의 쿼리 레벨 페이지네이션을 쓴다.
|
||||
$pivots = $this->repository->findByProductId($productId);
|
||||
$posts = $this->fetchPostsByIds($pivots->pluck('inquirable_id')->all(), $boardSlug);
|
||||
|
||||
// 비밀글 제외 필터 적용 (Post의 is_secret 기준)
|
||||
$pivots = $pivots->filter(function ($pivot) use ($posts) {
|
||||
$post = $posts[$pivot->inquirable_id] ?? null;
|
||||
|
||||
return empty($post['is_secret']);
|
||||
})->values();
|
||||
}
|
||||
|
||||
$total = $pivots->count();
|
||||
$pagePivots = $pivots->forPage($page, $perPage);
|
||||
$total = $pivots->count();
|
||||
$pagePivots = $pivots->forPage($page, $perPage);
|
||||
$lastPage = (int) ceil($total / $perPage);
|
||||
} else {
|
||||
// 화면 목록은 쿼리 레벨 페이지네이션 — 전량 적재 후 PHP 잘라내기(#102 동형)를
|
||||
// 하지 않는다. 게시글 데이터도 이 페이지 분량만 일괄 조회한다.
|
||||
$paginator = $this->repository->paginateByProductId($productId, $perPage, $page);
|
||||
$pagePivots = collect($paginator->items());
|
||||
$posts = $this->fetchPostsByIds($pagePivots->pluck('inquirable_id')->all(), $boardSlug);
|
||||
|
||||
$total = $paginator->total();
|
||||
$lastPage = $paginator->lastPage();
|
||||
}
|
||||
|
||||
// user_id 일괄 조회 (N+1 방지)
|
||||
$userIds = $pagePivots->map(fn ($pivot) => $posts[$pivot->inquirable_id]['user_id'] ?? null)
|
||||
@@ -169,8 +166,6 @@ class ProductInquiryService
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
$lastPage = (int) ceil($total / $perPage);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'meta' => [
|
||||
@@ -188,6 +183,36 @@ class ProductInquiryService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* inquirable_id 목록으로 게시글 데이터를 일괄 조회합니다 (N+1 방지).
|
||||
*
|
||||
* @param array<int, int> $ids 게시글 ID 목록
|
||||
* @param string $boardSlug 문의 게시판 슬러그
|
||||
* @return array<int, array<string, mixed>> 게시글 ID => 게시글 데이터
|
||||
*/
|
||||
private function fetchPostsByIds(array $ids, string $boardSlug): array
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rawPosts = HookManager::applyFilters(
|
||||
'sirsoft-ecommerce.inquiry.get_by_ids',
|
||||
[],
|
||||
['ids' => $ids, 'slug' => $boardSlug]
|
||||
);
|
||||
|
||||
$posts = [];
|
||||
foreach ($rawPosts as $post) {
|
||||
$postId = $post['id'] ?? null;
|
||||
if ($postId) {
|
||||
$posts[$postId] = $post;
|
||||
}
|
||||
}
|
||||
|
||||
return $posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 게시판 설정값 반환
|
||||
*
|
||||
|
||||
+68
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
@@ -317,6 +318,73 @@ class PublicProductInquiryControllerTest extends ModuleTestCase
|
||||
$this->assertSame('비밀 문의 제목', $items[0]['title']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 목록은 저장소 쿼리 레벨 페이지네이션을 써야 한다 (#102 동형 결함 해소).
|
||||
*
|
||||
* 종전에는 전량 get() 후 PHP forPage() 잘라내기라, 문의가 늘수록 한 페이지를 여는
|
||||
* 것만으로 전 행이 적재됐다. 페이지 경계 정확성과 LIMIT 사용을 함께 고정한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function 문의_목록은_쿼리_레벨_페이지네이션을_사용하고_페이지_경계가_정확하다(): void
|
||||
{
|
||||
app(EcommerceSettingsService::class)->setSetting('inquiry.board_slug', 'test-board');
|
||||
|
||||
for ($i = 1; $i <= 15; $i++) {
|
||||
ProductInquiry::create([
|
||||
'product_id' => $this->product->id,
|
||||
'inquirable_type' => 'board_post',
|
||||
'inquirable_id' => 1000 + $i,
|
||||
'user_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.inquiry.get_settings',
|
||||
fn ($defaults) => $defaults,
|
||||
priority: 1
|
||||
);
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.inquiry.get_by_ids',
|
||||
fn ($_, $context) => array_map(
|
||||
fn ($id) => ['id' => $id, 'title' => '문의 '.$id, 'is_secret' => false],
|
||||
$context['ids'] ?? []
|
||||
),
|
||||
priority: 1
|
||||
);
|
||||
|
||||
$queries = [];
|
||||
DB::listen(function ($query) use (&$queries): void {
|
||||
$queries[] = $query->sql;
|
||||
});
|
||||
|
||||
$response = $this->getJson(
|
||||
"/api/modules/sirsoft-ecommerce/products/{$this->product->id}/inquiries?page=2&per_page=10"
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// 페이지 경계: 15건 중 두 번째 묶음(5건)
|
||||
$items = $response->json('data.items');
|
||||
$this->assertCount(5, $items);
|
||||
$this->assertSame(15, $response->json('data.meta.total'));
|
||||
$this->assertSame(2, $response->json('data.meta.current_page'));
|
||||
$this->assertSame(2, $response->json('data.meta.last_page'));
|
||||
// id 내림차순 전순서 — 2페이지 첫 항목은 1005 (1015..1006 이 1페이지)
|
||||
$this->assertSame(1005, $items[0]['post_id']);
|
||||
|
||||
// 쿼리 레벨 페이지네이션 — 피벗 조회 SELECT 에 LIMIT 이 있어야 한다
|
||||
$limitedPivotSelects = array_filter(
|
||||
$queries,
|
||||
fn (string $sql): bool => str_contains($sql, 'product_inquiries')
|
||||
&& stripos(ltrim($sql), 'select') === 0
|
||||
&& stripos($sql, 'limit') !== false
|
||||
);
|
||||
$this->assertNotEmpty(
|
||||
$limitedPivotSelects,
|
||||
'문의 피벗 조회에 LIMIT 이 없습니다 — 전량 적재 후 PHP 잘라내기(#102 동형)입니다.'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function board_slug_미설정_시_빈_목록과_inquiry_available_false를_반환한다(): void
|
||||
{
|
||||
|
||||
@@ -380,8 +380,12 @@ abstract class ModuleTestCase extends TestCase
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $moduleBasePath.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+37
@@ -9,6 +9,7 @@ use App\Models\User;
|
||||
use App\Support\GuestRoleResolver;
|
||||
use App\Support\Query\BoundedCount;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SearchProductsListener;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
@@ -59,6 +60,42 @@ class SearchProductsListenerTest extends ModuleTestCase
|
||||
GuestRoleResolver::flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 실패가 "결과 0건" 으로 위장되지 않고 failed 페이로드로 표면화되는지 확인 (#103)
|
||||
*
|
||||
* @effects failed_flag_in_response, exception_stack_logged
|
||||
*/
|
||||
public function test_search_failure_surfaces_failed_payload_and_logs_exception(): void
|
||||
{
|
||||
Log::spy();
|
||||
|
||||
$exception = new \RuntimeException('DB 오류 재현');
|
||||
$this->productService
|
||||
->method('searchByKeywordWithCursor')
|
||||
->willThrowException($exception);
|
||||
|
||||
$context = [
|
||||
'type' => 'products',
|
||||
'q' => '문의',
|
||||
'sort' => 'relevance',
|
||||
'page' => 1,
|
||||
'per_page' => 10,
|
||||
'user' => null,
|
||||
'request' => null,
|
||||
];
|
||||
|
||||
$result = $this->listener->searchProducts([], $context);
|
||||
|
||||
$this->assertArrayHasKey('products', $result);
|
||||
$this->assertTrue($result['products']['failed'] ?? false, '실패 카테고리에는 failed 플래그가 실려야 합니다.');
|
||||
$this->assertFalse($result['products']['total_is_exact'], '실패한 0건을 "정확한 0건" 으로 말하면 안 됩니다.');
|
||||
$this->assertSame([], $result['products']['items']);
|
||||
|
||||
Log::shouldHaveReceived('error')
|
||||
->withArgs(fn (string $message, array $ctx = []) => ($ctx['exception'] ?? null) === $exception)
|
||||
->once();
|
||||
}
|
||||
|
||||
/**
|
||||
* getSubscribedHooks()가 올바른 훅 목록을 반환하는지 확인
|
||||
*/
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
### Changed
|
||||
|
||||
- 첨부 개수 상한 초과 안내 문구를 만드는 시점이 응답을 만드는 시점과 어긋나 있던 것을 맞췄습니다. 표시되는 문구와 상태는 종전과 동일합니다.
|
||||
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 통합검색에서 페이지 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 페이지 첨부 파일의 최대 개수·용량·허용 형식 설정이 저장해도 반영되지 않던 문제를 수정했습니다. 설정값을 읽어 오는 경로 자체가 없어 항상 내장 기본값이 쓰였습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 페이지 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 발행·발행 취소 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
|
||||
|
||||
|
||||
@@ -150,7 +150,10 @@ class SearchPagesListener implements HookListenerInterface
|
||||
|
||||
$results['pages'] = SearchCategoryPayload::fromBounded($searchPage, $format($searchPage->items()));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Search pages error', ['message' => $e->getMessage(), 'q' => $q]);
|
||||
// 실패를 카테고리 키 미설정으로 삼키면 화면이 "검색 결과 없음" 을 그린다 —
|
||||
// failed 페이로드로 표면화하고, 원인 추적을 위해 스택을 함께 남긴다 (#103).
|
||||
Log::error('Search pages error', ['message' => $e->getMessage(), 'q' => $q, 'exception' => $e]);
|
||||
$results['pages'] = SearchCategoryPayload::failed();
|
||||
}
|
||||
|
||||
return $results;
|
||||
|
||||
+9
-2
@@ -19,6 +19,9 @@ use Tests\TestCase;
|
||||
* 따라서 이 스위트만 매 테스트 fresh DB + commit 되는 INSERT 로 FULLTEXT 경로를
|
||||
* 실제 검증한다. (ModuleTestCase 의 DatabaseTransactions 와 상호배타.)
|
||||
*/
|
||||
// audit:allow test-extension-base-class reason: InnoDB FULLTEXT 는 커밋된 행만 색인하므로
|
||||
// ModuleTestCase 의 DatabaseTransactions 와 상호배타 — RefreshDatabase + 수동 정리로 격리하고
|
||||
// 모듈 부팅(오토로드/Provider/마이그레이션)은 setUp 이 동일 로직으로 수행한다
|
||||
class PageRepositorySearchTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
@@ -104,8 +107,12 @@ class PageRepositorySearchTest extends TestCase
|
||||
$file = $moduleBasePath.'/src/'.str_replace('\\', '/', $relativeClass).'.php';
|
||||
}
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,8 +241,12 @@ abstract class ModuleTestCase extends TestCase
|
||||
$file = $moduleBasePath.'/src/'.str_replace('\\', '/', $relativeClass).'.php';
|
||||
}
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Support\Query\BoundedCount;
|
||||
use App\Support\Query\BoundedPage;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Page\Listeners\SearchPagesListener;
|
||||
use Modules\Sirsoft\Page\Services\PageService;
|
||||
@@ -49,6 +50,42 @@ class SearchPagesListenerTest extends ModuleTestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 실패가 "결과 0건" 으로 위장되지 않고 failed 페이로드로 표면화되는지 확인 (#103)
|
||||
*
|
||||
* @effects failed_flag_in_response, exception_stack_logged
|
||||
*/
|
||||
public function test_search_failure_surfaces_failed_payload_and_logs_exception(): void
|
||||
{
|
||||
Log::spy();
|
||||
|
||||
$exception = new \RuntimeException('DB 오류 재현');
|
||||
$this->pageServiceMock
|
||||
->shouldReceive('searchByKeywordWithCursor')
|
||||
->andThrow($exception);
|
||||
|
||||
$context = [
|
||||
'type' => 'pages',
|
||||
'q' => '문의',
|
||||
'sort' => 'relevance',
|
||||
'page' => 1,
|
||||
'per_page' => 10,
|
||||
'user' => null,
|
||||
'request' => null,
|
||||
];
|
||||
|
||||
$result = $this->listener->searchPages([], $context);
|
||||
|
||||
$this->assertArrayHasKey('pages', $result);
|
||||
$this->assertTrue($result['pages']['failed'] ?? false, '실패 카테고리에는 failed 플래그가 실려야 합니다.');
|
||||
$this->assertFalse($result['pages']['total_is_exact'], '실패한 0건을 "정확한 0건" 으로 말하면 안 됩니다.');
|
||||
$this->assertSame([], $result['pages']['items']);
|
||||
|
||||
Log::shouldHaveReceived('error')
|
||||
->withArgs(fn (string $message, array $ctx = []) => ($ctx['exception'] ?? null) === $exception)
|
||||
->once();
|
||||
}
|
||||
|
||||
// ─── 훅 구독 등록 ───────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -310,7 +347,9 @@ class SearchPagesListenerTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* PageService 예외 발생 시 결과가 변경되지 않는지 확인
|
||||
* PageService 예외 발생 시 다른 카테고리는 보존하고 pages 는 failed 로 표면화하는지 확인
|
||||
*
|
||||
* 종전에는 키 미설정으로 삼켜 화면이 "검색 결과 없음" 을 그렸다 (#103).
|
||||
*/
|
||||
public function test_search_pages_handles_service_exception_gracefully(): void
|
||||
{
|
||||
@@ -325,8 +364,11 @@ class SearchPagesListenerTest extends ModuleTestCase
|
||||
'type' => 'all',
|
||||
]);
|
||||
|
||||
// 예외 발생 시 기존 results 그대로 반환
|
||||
$this->assertEquals($original, $result);
|
||||
// 예외가 전파되지 않고, 다른 카테고리 결과는 그대로 보존된다
|
||||
$this->assertSame($original['posts'], $result['posts']);
|
||||
// pages 는 실패 페이로드로 표면화된다
|
||||
$this->assertTrue($result['pages']['failed'] ?? false);
|
||||
$this->assertFalse($result['pages']['total_is_exact']);
|
||||
}
|
||||
|
||||
// ─── buildPagesResponse() ───────────────────────────
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Plugins\Gnuboard7\HelloPlugin\Tests;
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Plugins\Gnuboard7\HelloPlugin\Providers\HelloPluginServiceProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -52,7 +53,7 @@ abstract class PluginTestCase extends TestCase
|
||||
|
||||
$this->registerPluginAutoload();
|
||||
|
||||
$this->app->register(\Plugins\Gnuboard7\HelloPlugin\Providers\HelloPluginServiceProvider::class);
|
||||
$this->app->register(HelloPluginServiceProvider::class);
|
||||
|
||||
$this->runCoreMigrationIfNeeded();
|
||||
|
||||
@@ -153,8 +154,12 @@ abstract class PluginTestCase extends TestCase
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $pluginBasePath.'/src/'.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -188,7 +193,7 @@ abstract class PluginTestCase extends TestCase
|
||||
$priority = is_array($config) ? ($config['priority'] ?? 10) : 10;
|
||||
$type = is_array($config) ? ($config['type'] ?? 'action') : 'action';
|
||||
|
||||
$listenerInstance = new $listenerClass();
|
||||
$listenerInstance = new $listenerClass;
|
||||
$callback = [$listenerInstance, $method];
|
||||
|
||||
if ($type === 'filter') {
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
## [1.1.1] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
- 주문·결제 정보 조회가 데이터베이스 표 이름을 직접 쓰지 않고 쇼핑몰 모듈의 정의를 따르도록 내부 정리했습니다. 표시되는 내용과 동작은 종전과 동일합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다.
|
||||
|
||||
+89
-84
@@ -4,12 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
|
||||
@@ -25,20 +30,20 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
|
||||
/** 택배사 코드 → 택배사명 매핑 (KG 이니시스 공식 코드표) */
|
||||
private const COURIER_CODES = [
|
||||
'hanjin' => '한진택배',
|
||||
'cjgls' => 'CJ대한통운',
|
||||
'loge' => '롯데택배',
|
||||
'epost' => '우체국택배',
|
||||
'lotte' => '롯데글로벌로지스',
|
||||
'kdexp' => '경동택배',
|
||||
'cvs' => '편의점택배',
|
||||
'ilyang' => '일양로지스',
|
||||
'chunil' => '천일택배',
|
||||
'cvsnet' => 'CVSnet편의점',
|
||||
'daesin' => '대신택배',
|
||||
'kunyoung' => '건영택배',
|
||||
'hanjin' => '한진택배',
|
||||
'cjgls' => 'CJ대한통운',
|
||||
'loge' => '롯데택배',
|
||||
'epost' => '우체국택배',
|
||||
'lotte' => '롯데글로벌로지스',
|
||||
'kdexp' => '경동택배',
|
||||
'cvs' => '편의점택배',
|
||||
'ilyang' => '일양로지스',
|
||||
'chunil' => '천일택배',
|
||||
'cvsnet' => 'CVSnet편의점',
|
||||
'daesin' => '대신택배',
|
||||
'kunyoung' => '건영택배',
|
||||
'gsilogis' => 'GSI Express',
|
||||
'etc' => '기타',
|
||||
'etc' => '기타',
|
||||
];
|
||||
|
||||
/** 에스크로 배송등록 PG 응답 저장 허용 필드 */
|
||||
@@ -75,8 +80,8 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
return ResponseHelper::success('common.success', null);
|
||||
}
|
||||
|
||||
$address = DB::table('ecommerce_order_addresses as a')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'a.order_id')
|
||||
$address = DB::table((new OrderAddress)->getTable().' as a')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'a.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('a.address_type', 'shipping')
|
||||
->select([
|
||||
@@ -89,25 +94,25 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
->first();
|
||||
|
||||
// payment_meta에서 이력 추출
|
||||
$meta = $payment->payment_meta ? json_decode($payment->payment_meta, true) : [];
|
||||
$meta = $payment->payment_meta ? json_decode($payment->payment_meta, true) : [];
|
||||
$escrowDelivery = $meta['escrow_delivery'] ?? null;
|
||||
$escrowConfirm = $meta['escrow_confirm'] ?? null;
|
||||
$denyConfirmed = isset($meta['escrow_deny_confirm']);
|
||||
$escrowConfirm = $meta['escrow_confirm'] ?? null;
|
||||
$denyConfirmed = isset($meta['escrow_deny_confirm']);
|
||||
|
||||
return ResponseHelper::success('common.success', [
|
||||
'has_escrow_payment' => true,
|
||||
'tid' => $payment->transaction_id,
|
||||
'price' => (int) round((float) $payment->paid_amount_local),
|
||||
'courier_codes' => self::COURIER_CODES,
|
||||
'prefill' => [
|
||||
'recvName' => $address?->recipient_name ?? '',
|
||||
'recvTel' => $address?->recipient_phone ?? '',
|
||||
'recvPost' => $address?->zipcode ?? '',
|
||||
'recvAddr' => trim(($address?->address ?? '') . ' ' . ($address?->address_detail ?? '')),
|
||||
'has_escrow_payment' => true,
|
||||
'tid' => $payment->transaction_id,
|
||||
'price' => (int) round((float) $payment->paid_amount_local),
|
||||
'courier_codes' => self::COURIER_CODES,
|
||||
'prefill' => [
|
||||
'recvName' => $address?->recipient_name ?? '',
|
||||
'recvTel' => $address?->recipient_phone ?? '',
|
||||
'recvPost' => $address?->zipcode ?? '',
|
||||
'recvAddr' => trim(($address?->address ?? '').' '.($address?->address_detail ?? '')),
|
||||
],
|
||||
'registered_delivery' => $escrowDelivery,
|
||||
'escrow_confirm' => $escrowConfirm,
|
||||
'deny_confirmed' => $denyConfirmed,
|
||||
'registered_delivery' => $escrowDelivery,
|
||||
'escrow_confirm' => $escrowConfirm,
|
||||
'deny_confirmed' => $denyConfirmed,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -121,7 +126,7 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
public function register(Request $request, string $orderNumber): JsonResponse
|
||||
{
|
||||
$invoice = trim((string) $request->input('invoice', ''));
|
||||
$exCode = trim((string) $request->input('ex_code', ''));
|
||||
$exCode = trim((string) $request->input('ex_code', ''));
|
||||
|
||||
if ($invoice === '') {
|
||||
return ResponseHelper::error('common.failed', 422, ['invoice' => ['운송장번호를 입력해주세요.']]);
|
||||
@@ -137,37 +142,37 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
return ResponseHelper::error('common.failed', 404, null);
|
||||
}
|
||||
|
||||
$report = in_array($request->input('report'), ['I', 'U'], true) ? $request->input('report') : 'I';
|
||||
$charge = in_array($request->input('charge'), ['SH', 'BH'], true) ? $request->input('charge') : 'SH';
|
||||
$price = (int) round((float) $payment->paid_amount_local);
|
||||
$exName = self::COURIER_CODES[$exCode];
|
||||
$report = in_array($request->input('report'), ['I', 'U'], true) ? $request->input('report') : 'I';
|
||||
$charge = in_array($request->input('charge'), ['SH', 'BH'], true) ? $request->input('charge') : 'SH';
|
||||
$price = (int) round((float) $payment->paid_amount_local);
|
||||
$exName = self::COURIER_CODES[$exCode];
|
||||
|
||||
// 수신자 주소: 요청 우선, 없으면 DB 조회
|
||||
$recvName = trim((string) $request->input('recv_name', ''));
|
||||
$recvTel = trim((string) $request->input('recv_tel', ''));
|
||||
$recvTel = trim((string) $request->input('recv_tel', ''));
|
||||
$recvPost = trim((string) $request->input('recv_post', ''));
|
||||
$recvAddr = trim((string) $request->input('recv_addr', ''));
|
||||
|
||||
if ($recvName === '' || $recvTel === '' || $recvAddr === '') {
|
||||
$address = DB::table('ecommerce_order_addresses as a')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'a.order_id')
|
||||
$address = DB::table((new OrderAddress)->getTable().' as a')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'a.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('a.address_type', 'shipping')
|
||||
->select(['a.recipient_name', 'a.recipient_phone', 'a.zipcode', 'a.address', 'a.address_detail'])
|
||||
->first();
|
||||
|
||||
$recvName = $recvName ?: ($address?->recipient_name ?? '');
|
||||
$recvTel = $recvTel ?: ($address?->recipient_phone ?? '');
|
||||
$recvTel = $recvTel ?: ($address?->recipient_phone ?? '');
|
||||
$recvPost = $recvPost ?: ($address?->zipcode ?? '');
|
||||
$recvAddr = $recvAddr ?: trim(($address?->address ?? '') . ' ' . ($address?->address_detail ?? ''));
|
||||
$recvAddr = $recvAddr ?: trim(($address?->address ?? '').' '.($address?->address_detail ?? ''));
|
||||
}
|
||||
|
||||
Log::info('KG Inicis: escrow delivery register requested', [
|
||||
'order_number' => $orderNumber,
|
||||
'tid' => $payment->transaction_id,
|
||||
'invoice' => $invoice,
|
||||
'ex_code' => $exCode,
|
||||
'report' => $report,
|
||||
'tid' => $payment->transaction_id,
|
||||
'invoice' => $invoice,
|
||||
'ex_code' => $exCode,
|
||||
'report' => $report,
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -175,24 +180,24 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
$this->apiService->useEscrowCredentials(true);
|
||||
|
||||
$pgResponse = $this->apiService->registerEscrowDelivery([
|
||||
'tid' => $payment->transaction_id,
|
||||
'oid' => $orderNumber,
|
||||
'price' => $price,
|
||||
'report' => $report,
|
||||
'invoice' => $invoice,
|
||||
'registName' => trim((string) $request->input('regist_name', '')),
|
||||
'exCode' => $exCode,
|
||||
'exName' => $exName,
|
||||
'charge' => $charge,
|
||||
'invoiceDay' => date('Y-m-d H:i:s'),
|
||||
'sendName' => trim((string) $request->input('send_name', '')),
|
||||
'sendTel' => trim((string) $request->input('send_tel', '')),
|
||||
'sendPost' => trim((string) $request->input('send_post', '')),
|
||||
'sendAddr1' => trim((string) $request->input('send_addr', '')),
|
||||
'recvName' => $recvName,
|
||||
'recvTel' => $recvTel,
|
||||
'recvPost' => $recvPost,
|
||||
'recvAddr' => $recvAddr,
|
||||
'tid' => $payment->transaction_id,
|
||||
'oid' => $orderNumber,
|
||||
'price' => $price,
|
||||
'report' => $report,
|
||||
'invoice' => $invoice,
|
||||
'registName' => trim((string) $request->input('regist_name', '')),
|
||||
'exCode' => $exCode,
|
||||
'exName' => $exName,
|
||||
'charge' => $charge,
|
||||
'invoiceDay' => date('Y-m-d H:i:s'),
|
||||
'sendName' => trim((string) $request->input('send_name', '')),
|
||||
'sendTel' => trim((string) $request->input('send_tel', '')),
|
||||
'sendPost' => trim((string) $request->input('send_post', '')),
|
||||
'sendAddr1' => trim((string) $request->input('send_addr', '')),
|
||||
'recvName' => $recvName,
|
||||
'recvTel' => $recvTel,
|
||||
'recvPost' => $recvPost,
|
||||
'recvAddr' => $recvAddr,
|
||||
]);
|
||||
|
||||
$resultCode = $pgResponse['resultCode'] ?? '';
|
||||
@@ -201,9 +206,9 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
if ($resultCode !== '00') {
|
||||
Log::warning('KG Inicis: escrow delivery register failed', [
|
||||
'order_number' => $orderNumber,
|
||||
'result_code' => $resultCode,
|
||||
'result_msg' => $pgResponse['resultMsg'] ?? '',
|
||||
'pg_response' => $sanitizedPgResponse,
|
||||
'result_code' => $resultCode,
|
||||
'result_msg' => $pgResponse['resultMsg'] ?? '',
|
||||
'pg_response' => $sanitizedPgResponse,
|
||||
]);
|
||||
|
||||
return ResponseHelper::error('common.failed', 502, [
|
||||
@@ -216,41 +221,41 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
$meta['pg_response_sanitized'] = true;
|
||||
$meta['escrow_delivery'] = [
|
||||
'registered_at' => now()->toDateTimeString(),
|
||||
'report' => $report,
|
||||
'invoice' => $invoice,
|
||||
'ex_code' => $exCode,
|
||||
'ex_name' => $exName,
|
||||
'charge' => $charge,
|
||||
'recv_name' => $recvName,
|
||||
'recv_addr' => $recvAddr,
|
||||
'pg_response' => $sanitizedPgResponse,
|
||||
'report' => $report,
|
||||
'invoice' => $invoice,
|
||||
'ex_code' => $exCode,
|
||||
'ex_name' => $exName,
|
||||
'charge' => $charge,
|
||||
'recv_name' => $recvName,
|
||||
'recv_addr' => $recvAddr,
|
||||
'pg_response' => $sanitizedPgResponse,
|
||||
];
|
||||
|
||||
DB::table('ecommerce_order_payments')
|
||||
DB::table((new OrderPayment)->getTable())
|
||||
->where('id', $payment->id)
|
||||
->update([
|
||||
'payment_meta' => json_encode($meta, JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info('KG Inicis: escrow delivery registered', [
|
||||
'order_number' => $orderNumber,
|
||||
'tid' => $payment->transaction_id,
|
||||
'invoice' => $invoice,
|
||||
'ex_name' => $exName,
|
||||
'tid' => $payment->transaction_id,
|
||||
'invoice' => $invoice,
|
||||
'ex_name' => $exName,
|
||||
]);
|
||||
|
||||
return ResponseHelper::success('common.success', [
|
||||
'result_code' => $resultCode,
|
||||
'result_msg' => $pgResponse['resultMsg'] ?? 'OK',
|
||||
'invoice' => $invoice,
|
||||
'ex_name' => $exName,
|
||||
'result_msg' => $pgResponse['resultMsg'] ?? 'OK',
|
||||
'invoice' => $invoice,
|
||||
'ex_name' => $exName,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('KG Inicis: escrow delivery register exception', [
|
||||
'order_number' => $orderNumber,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ResponseHelper::error('common.failed', 500, [
|
||||
@@ -261,8 +266,8 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
|
||||
private function findEscrowPayment(string $orderNumber): ?object
|
||||
{
|
||||
return DB::table('ecommerce_order_payments as p')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
|
||||
return DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('p.pg_provider', 'kginicis')
|
||||
->where('p.is_escrow', true)
|
||||
|
||||
+18
-14
@@ -4,12 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
|
||||
@@ -69,7 +73,7 @@ class AdminEscrowDenyConfirmController extends AdminBaseController
|
||||
|
||||
Log::info('KG Inicis: escrow deny confirm requested', [
|
||||
'order_number' => $orderNumber,
|
||||
'tid' => $payment->transaction_id,
|
||||
'tid' => $payment->transaction_id,
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -77,7 +81,7 @@ class AdminEscrowDenyConfirmController extends AdminBaseController
|
||||
|
||||
$pgResponse = $this->apiService->denyConfirmEscrow([
|
||||
'originalTid' => $payment->transaction_id,
|
||||
'dcnfName' => $dcnfName,
|
||||
'dcnfName' => $dcnfName,
|
||||
]);
|
||||
|
||||
$resultCode = $pgResponse['resultCode'] ?? '';
|
||||
@@ -86,9 +90,9 @@ class AdminEscrowDenyConfirmController extends AdminBaseController
|
||||
if ($resultCode !== '00') {
|
||||
Log::warning('KG Inicis: escrow deny confirm failed', [
|
||||
'order_number' => $orderNumber,
|
||||
'result_code' => $resultCode,
|
||||
'result_msg' => $pgResponse['resultMsg'] ?? '',
|
||||
'pg_response' => $sanitizedPgResponse,
|
||||
'result_code' => $resultCode,
|
||||
'result_msg' => $pgResponse['resultMsg'] ?? '',
|
||||
'pg_response' => $sanitizedPgResponse,
|
||||
]);
|
||||
|
||||
return ResponseHelper::error('common.failed', 502, [
|
||||
@@ -100,31 +104,31 @@ class AdminEscrowDenyConfirmController extends AdminBaseController
|
||||
$meta['pg_response_sanitized'] = true;
|
||||
$meta['escrow_deny_confirm'] = [
|
||||
'confirmed_at' => now()->toDateTimeString(),
|
||||
'dcnf_name' => $dcnfName,
|
||||
'pg_response' => $sanitizedPgResponse,
|
||||
'dcnf_name' => $dcnfName,
|
||||
'pg_response' => $sanitizedPgResponse,
|
||||
];
|
||||
|
||||
DB::table('ecommerce_order_payments')
|
||||
DB::table((new OrderPayment)->getTable())
|
||||
->where('id', $payment->id)
|
||||
->update([
|
||||
'payment_meta' => json_encode($meta, JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info('KG Inicis: escrow deny confirm completed', [
|
||||
'order_number' => $orderNumber,
|
||||
'tid' => $payment->transaction_id,
|
||||
'tid' => $payment->transaction_id,
|
||||
]);
|
||||
|
||||
return ResponseHelper::success('common.success', [
|
||||
'result_code' => $resultCode,
|
||||
'result_msg' => $pgResponse['resultMsg'] ?? 'OK',
|
||||
'result_msg' => $pgResponse['resultMsg'] ?? 'OK',
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('KG Inicis: escrow deny confirm exception', [
|
||||
'order_number' => $orderNumber,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ResponseHelper::error('common.failed', 500, [
|
||||
@@ -135,8 +139,8 @@ class AdminEscrowDenyConfirmController extends AdminBaseController
|
||||
|
||||
private function findEscrowPayment(string $orderNumber): ?object
|
||||
{
|
||||
return DB::table('ecommerce_order_payments as p')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
|
||||
return DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('p.pg_provider', 'kginicis')
|
||||
->where('p.is_escrow', true)
|
||||
|
||||
@@ -4,10 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
|
||||
class AdminOrderListController extends AdminBaseController
|
||||
{
|
||||
@@ -31,8 +35,11 @@ class AdminOrderListController extends AdminBaseController
|
||||
*/
|
||||
public function testModeMap(): JsonResponse
|
||||
{
|
||||
$rows = DB::table('ecommerce_orders as o')
|
||||
->join('ecommerce_order_payments as p', 'p.order_id', '=', 'o.id')
|
||||
$orders = (new Order)->getTable();
|
||||
$payments = (new OrderPayment)->getTable();
|
||||
|
||||
$rows = DB::table($orders.' as o')
|
||||
->join($payments.' as p', 'p.order_id', '=', 'o.id')
|
||||
->where('p.pg_provider', 'kginicis')
|
||||
->where('p.created_at', '>=', now()->subMonths(6))
|
||||
->select(['o.order_number', 'p.transaction_id', 'p.payment_meta'])
|
||||
|
||||
+120
-110
@@ -4,12 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
|
||||
class AdminTransactionController extends AdminBaseController
|
||||
@@ -45,13 +50,16 @@ class AdminTransactionController extends AdminBaseController
|
||||
*/
|
||||
public function queryByOrder(string $orderNumber): JsonResponse
|
||||
{
|
||||
$payment = DB::table('ecommerce_order_payments')
|
||||
->join('ecommerce_orders', 'ecommerce_orders.id', '=', 'ecommerce_order_payments.order_id')
|
||||
->where('ecommerce_orders.order_number', $orderNumber)
|
||||
->whereNotNull('ecommerce_order_payments.transaction_id')
|
||||
->where('ecommerce_order_payments.transaction_id', '!=', '')
|
||||
->where('ecommerce_order_payments.pg_provider', 'kginicis')
|
||||
->select(['ecommerce_order_payments.transaction_id', 'ecommerce_order_payments.payment_meta'])
|
||||
$orders = (new Order)->getTable();
|
||||
$payments = (new OrderPayment)->getTable();
|
||||
|
||||
$payment = DB::table($payments.' as p')
|
||||
->join($orders.' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->whereNotNull('p.transaction_id')
|
||||
->where('p.transaction_id', '!=', '')
|
||||
->where('p.pg_provider', 'kginicis')
|
||||
->select(['p.transaction_id', 'p.payment_meta'])
|
||||
->first();
|
||||
|
||||
if (! $payment) {
|
||||
@@ -64,35 +72,38 @@ class AdminTransactionController extends AdminBaseController
|
||||
private function queryByTid(string $tid): JsonResponse
|
||||
{
|
||||
try {
|
||||
$localPayment = DB::table('ecommerce_order_payments')
|
||||
->leftJoin('ecommerce_orders', 'ecommerce_orders.id', '=', 'ecommerce_order_payments.order_id')
|
||||
->where('ecommerce_order_payments.transaction_id', $tid)
|
||||
$orders = (new Order)->getTable();
|
||||
$payments = (new OrderPayment)->getTable();
|
||||
|
||||
$localPayment = DB::table($payments.' as p')
|
||||
->leftJoin($orders.' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('p.transaction_id', $tid)
|
||||
->select([
|
||||
'ecommerce_order_payments.transaction_id',
|
||||
'ecommerce_order_payments.payment_status',
|
||||
'ecommerce_order_payments.is_escrow',
|
||||
'ecommerce_order_payments.payment_meta',
|
||||
'ecommerce_order_payments.embedded_pg_provider',
|
||||
'ecommerce_order_payments.paid_amount_local',
|
||||
'ecommerce_order_payments.currency',
|
||||
'ecommerce_order_payments.card_name',
|
||||
'ecommerce_order_payments.card_number_masked',
|
||||
'ecommerce_order_payments.card_approval_number',
|
||||
'ecommerce_order_payments.card_installment_months',
|
||||
'ecommerce_order_payments.vbank_code',
|
||||
'ecommerce_order_payments.vbank_name',
|
||||
'ecommerce_order_payments.vbank_number',
|
||||
'ecommerce_order_payments.vbank_holder',
|
||||
'ecommerce_order_payments.vbank_due_at',
|
||||
'ecommerce_order_payments.buyer_name',
|
||||
'ecommerce_order_payments.buyer_email',
|
||||
'ecommerce_order_payments.buyer_phone',
|
||||
'ecommerce_order_payments.payment_name',
|
||||
'ecommerce_order_payments.paid_at',
|
||||
'ecommerce_orders.order_number',
|
||||
'ecommerce_orders.order_status',
|
||||
'ecommerce_orders.currency as order_currency',
|
||||
'ecommerce_orders.total_due_amount',
|
||||
'p.transaction_id',
|
||||
'p.payment_status',
|
||||
'p.is_escrow',
|
||||
'p.payment_meta',
|
||||
'p.embedded_pg_provider',
|
||||
'p.paid_amount_local',
|
||||
'p.currency',
|
||||
'p.card_name',
|
||||
'p.card_number_masked',
|
||||
'p.card_approval_number',
|
||||
'p.card_installment_months',
|
||||
'p.vbank_code',
|
||||
'p.vbank_name',
|
||||
'p.vbank_number',
|
||||
'p.vbank_holder',
|
||||
'p.vbank_due_at',
|
||||
'p.buyer_name',
|
||||
'p.buyer_email',
|
||||
'p.buyer_phone',
|
||||
'p.payment_name',
|
||||
'p.paid_at',
|
||||
'o.order_number',
|
||||
'o.order_status',
|
||||
'o.currency as order_currency',
|
||||
'o.total_due_amount',
|
||||
])
|
||||
->first();
|
||||
|
||||
@@ -143,7 +154,7 @@ class AdminTransactionController extends AdminBaseController
|
||||
return ResponseHelper::success('common.success', $result);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('KG Inicis queryTransaction failed', [
|
||||
'tid' => $tid,
|
||||
'tid' => $tid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
@@ -297,7 +308,7 @@ class AdminTransactionController extends AdminBaseController
|
||||
'_embedded_pg_provider' => $embeddedPgProvider,
|
||||
'_embedded_pg_provider_label' => $embeddedPgProviderLabel,
|
||||
'_pay_method_label' => $embeddedPgProviderLabel
|
||||
? $embeddedPgProviderLabel . ' (' . $basePayMethodLabel . ')'
|
||||
? $embeddedPgProviderLabel.' ('.$basePayMethodLabel.')'
|
||||
: $basePayMethodLabel,
|
||||
'_auth_code' => $pick('approve', 'applNo', 'approvalNo', 'authCode', 'confNo', 'receiptNo'),
|
||||
'_auth_date' => $this->formatCbtDateTime(
|
||||
@@ -378,12 +389,12 @@ class AdminTransactionController extends AdminBaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr($value, 0, 4) . '-'
|
||||
. substr($value, 4, 2) . '-'
|
||||
. substr($value, 6, 2) . ' '
|
||||
. substr($value, 8, 2) . ':'
|
||||
. substr($value, 10, 2) . ':'
|
||||
. substr($value, 12, 2);
|
||||
return substr($value, 0, 4).'-'
|
||||
.substr($value, 4, 2).'-'
|
||||
.substr($value, 6, 2).' '
|
||||
.substr($value, 8, 2).':'
|
||||
.substr($value, 10, 2).':'
|
||||
.substr($value, 12, 2);
|
||||
}
|
||||
|
||||
private function formatTimestamp(string $value): ?string
|
||||
@@ -393,7 +404,7 @@ class AdminTransactionController extends AdminBaseController
|
||||
}
|
||||
|
||||
try {
|
||||
return \Carbon\Carbon::parse($value)->format('Y-m-d H:i:s');
|
||||
return Carbon::parse($value)->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
@@ -416,8 +427,7 @@ class AdminTransactionController extends AdminBaseController
|
||||
bool $isEscrow,
|
||||
?string $localVbankDueAt = null,
|
||||
?string $embeddedPgProvider = null,
|
||||
): array
|
||||
{
|
||||
): array {
|
||||
$cardInfo = is_array($result['cardInfo'] ?? null) ? $result['cardInfo'] : [];
|
||||
|
||||
$pick = function (string ...$keys) use ($result, $localRaw, $cardInfo): ?string {
|
||||
@@ -442,45 +452,45 @@ class AdminTransactionController extends AdminBaseController
|
||||
$basePayMethodLabel = $this->payMethodLabel($payMethod);
|
||||
$embeddedPgProviderLabel = $this->embeddedPgProviderLabel($embeddedPgProvider);
|
||||
|
||||
$result['_is_test_mode'] = $this->apiService->isTestMode();
|
||||
$result['_local_is_escrow'] = $isEscrow;
|
||||
$result['_pay_method'] = $payMethod;
|
||||
$result['_is_test_mode'] = $this->apiService->isTestMode();
|
||||
$result['_local_is_escrow'] = $isEscrow;
|
||||
$result['_pay_method'] = $payMethod;
|
||||
$result['_base_pay_method_label'] = $basePayMethodLabel;
|
||||
$result['_embedded_pg_provider'] = $embeddedPgProvider;
|
||||
$result['_embedded_pg_provider_label'] = $embeddedPgProviderLabel;
|
||||
$result['_pay_method_label'] = $embeddedPgProviderLabel
|
||||
$result['_pay_method_label'] = $embeddedPgProviderLabel
|
||||
? $embeddedPgProviderLabel.' ('.$basePayMethodLabel.')'
|
||||
: $basePayMethodLabel;
|
||||
$result['_auth_code'] = $pick('applNum', 'approvedNumber', 'authCode', 'AuthCode');
|
||||
$result['_auth_date'] = $this->formatDateTime(
|
||||
$result['_auth_code'] = $pick('applNum', 'approvedNumber', 'authCode', 'AuthCode');
|
||||
$result['_auth_date'] = $this->formatDateTime(
|
||||
$pick('applDate', 'approvedDate', 'AuthDate'),
|
||||
$pick('applTime', 'approvedTime', 'AuthTime'),
|
||||
);
|
||||
$result['_total_price'] = $pick('TotPrice', 'totalPrice', 'price', 'Amt', 'approvedAmount');
|
||||
$result['_currency'] = $pick('currency', 'Currency', 'currencyCode') ?? 'WON';
|
||||
$result['_moid'] = $pick('MOID', 'moid', 'Moid', 'oid');
|
||||
$result['_buyer_name'] = $pick('buyerName', 'BuyerName');
|
||||
$result['_buyer_email'] = $pick('buyerEmail', 'BuyerEmail', 'buyerMail');
|
||||
$result['_buyer_tel'] = $pick('buyerTel', 'BuyerTel');
|
||||
$result['_status'] = $pick('status', 'Status', 'transactionStatus');
|
||||
$result['_total_price'] = $pick('TotPrice', 'totalPrice', 'price', 'Amt', 'approvedAmount');
|
||||
$result['_currency'] = $pick('currency', 'Currency', 'currencyCode') ?? 'WON';
|
||||
$result['_moid'] = $pick('MOID', 'moid', 'Moid', 'oid');
|
||||
$result['_buyer_name'] = $pick('buyerName', 'BuyerName');
|
||||
$result['_buyer_email'] = $pick('buyerEmail', 'BuyerEmail', 'buyerMail');
|
||||
$result['_buyer_tel'] = $pick('buyerTel', 'BuyerTel');
|
||||
$result['_status'] = $pick('status', 'Status', 'transactionStatus');
|
||||
|
||||
// 취소이력
|
||||
$result['_cancel_price'] = $pick('cancelPrice', 'CancelPrice', 'cancelAmount');
|
||||
$result['_cancel_date'] = $this->formatDateTime($pick('cancelDate', 'CancelDate'), $pick('cancelTime', 'CancelTime'));
|
||||
$result['_cancel_price'] = $pick('cancelPrice', 'CancelPrice', 'cancelAmount');
|
||||
$result['_cancel_date'] = $this->formatDateTime($pick('cancelDate', 'CancelDate'), $pick('cancelTime', 'CancelTime'));
|
||||
$partCancelRaw = $result['partCancelList'] ?? $localRaw['partCancelList'] ?? [];
|
||||
$result['_part_cancel_list'] = $this->normalizePartCancelList(is_array($partCancelRaw) ? $partCancelRaw : []);
|
||||
$result['_part_cancel_list'] = $this->normalizePartCancelList(is_array($partCancelRaw) ? $partCancelRaw : []);
|
||||
|
||||
// 결제수단별 상세 (신구 응답 포맷 호환: 평탄 키 + cardInfo 중첩)
|
||||
$result['_card_name'] = $pick('cardName', 'CardName', 'issuerName');
|
||||
$result['_card_num'] = $pick('cardNum', 'CardNum', 'CARD_Num', 'cardNumber');
|
||||
$result['_card_code'] = $pick('cardCode', 'CardCode');
|
||||
$result['_card_quota'] = $this->formatQuota($pick('cardQuota', 'CardQuota', 'quota'));
|
||||
$result['_card_interest'] = $pick('cardInterest', 'CardInterest', 'isInterestFree');
|
||||
$result['_card_name'] = $pick('cardName', 'CardName', 'issuerName');
|
||||
$result['_card_num'] = $pick('cardNum', 'CardNum', 'CARD_Num', 'cardNumber');
|
||||
$result['_card_code'] = $pick('cardCode', 'CardCode');
|
||||
$result['_card_quota'] = $this->formatQuota($pick('cardQuota', 'CardQuota', 'quota'));
|
||||
$result['_card_interest'] = $pick('cardInterest', 'CardInterest', 'isInterestFree');
|
||||
|
||||
$result['_vbank_num'] = $pick('VACT_Num', 'vactNum', 'vbank_num');
|
||||
$result['_vbank_bank_code'] = $pick('VACT_BankCode', 'vactBankCode', 'vbank_bank_code');
|
||||
$result['_vbank_bank_name'] = $pick('VACT_BankName', 'vactBankName', 'vbank_bank_name') ?? $this->bankNameByCode($result['_vbank_bank_code'] ?? null);
|
||||
$result['_vbank_holder'] = $pick('VACT_Name', 'vactName', 'vbank_holder');
|
||||
$result['_vbank_num'] = $pick('VACT_Num', 'vactNum', 'vbank_num');
|
||||
$result['_vbank_bank_code'] = $pick('VACT_BankCode', 'vactBankCode', 'vbank_bank_code');
|
||||
$result['_vbank_bank_name'] = $pick('VACT_BankName', 'vactBankName', 'vbank_bank_name') ?? $this->bankNameByCode($result['_vbank_bank_code'] ?? null);
|
||||
$result['_vbank_holder'] = $pick('VACT_Name', 'vactName', 'vbank_holder');
|
||||
// 가상계좌 입금기한:
|
||||
// 로컬 vbank_due_at 이 있으면 KST 로 변환해 사용 — 결제 발급 시 KG 이니시스가 보낸
|
||||
// VACT_Date(=다음 영업일) + VACT_Time(=08:59:59) 으로 만든 정확한 cutoff timestamp.
|
||||
@@ -488,24 +498,24 @@ class AdminTransactionController extends AdminBaseController
|
||||
// 조회 응답의 vacctInfo.validDate 는 "마지막 입금 가능일" convention 이라 1일 일찍 표시되어
|
||||
// 로컬 timestamp 가 더 정확.
|
||||
$result['_vbank_expire_date'] = $localVbankDueAt !== null
|
||||
? \Carbon\Carbon::parse($localVbankDueAt, 'UTC')->setTimezone('Asia/Seoul')->format('Y-m-d H:i:s')
|
||||
? Carbon::parse($localVbankDueAt, 'UTC')->setTimezone('Asia/Seoul')->format('Y-m-d H:i:s')
|
||||
: $this->formatDate($pick('VACT_Date', 'vactDate', 'vbank_expire_date', 'validDate'));
|
||||
$vbankStatus = $pick('VACT_Status', 'vactStatus', 'vbank_status');
|
||||
$result['_vbank_status'] = $vbankStatus;
|
||||
$result['_vbank_paid_at'] = $this->formatDateTime($pick('VACT_InputDate', 'VACT_InputTime') ? $pick('VACT_InputDate') : null, $pick('VACT_InputTime'));
|
||||
$vbankStatus = $pick('VACT_Status', 'vactStatus', 'vbank_status');
|
||||
$result['_vbank_status'] = $vbankStatus;
|
||||
$result['_vbank_paid_at'] = $this->formatDateTime($pick('VACT_InputDate', 'VACT_InputTime') ? $pick('VACT_InputDate') : null, $pick('VACT_InputTime'));
|
||||
|
||||
$result['_bank_code'] = $pick('acntBankCode', 'BankCode');
|
||||
$result['_bank_name'] = $pick('acntBankName', 'BankName') ?? $this->bankNameByCode($result['_bank_code'] ?? null);
|
||||
$result['_bank_acnt_num'] = $pick('acntNum', 'AcntNum');
|
||||
$result['_bank_code'] = $pick('acntBankCode', 'BankCode');
|
||||
$result['_bank_name'] = $pick('acntBankName', 'BankName') ?? $this->bankNameByCode($result['_bank_code'] ?? null);
|
||||
$result['_bank_acnt_num'] = $pick('acntNum', 'AcntNum');
|
||||
|
||||
$result['_hpp_num'] = $pick('HPP_Num', 'hppNum', 'phoneNum');
|
||||
$result['_hpp_corp'] = $pick('HPP_Corp', 'hppCorp', 'mobileCarrier');
|
||||
$result['_hpp_num'] = $pick('HPP_Num', 'hppNum', 'phoneNum');
|
||||
$result['_hpp_corp'] = $pick('HPP_Corp', 'hppCorp', 'mobileCarrier');
|
||||
|
||||
$result['_escrow_status'] = $pick('escrowStatus', 'EscrowStatus');
|
||||
$result['_escrow_confirm'] = $this->formatDateTime($pick('escrowConfirmDate'), $pick('escrowConfirmTime'));
|
||||
$result['_escrow_status'] = $pick('escrowStatus', 'EscrowStatus');
|
||||
$result['_escrow_confirm'] = $this->formatDateTime($pick('escrowConfirmDate'), $pick('escrowConfirmTime'));
|
||||
|
||||
// 환경 정보
|
||||
$result['_inquiry_at'] = date('Y-m-d H:i:s');
|
||||
$result['_inquiry_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -527,26 +537,26 @@ class AdminTransactionController extends AdminBaseController
|
||||
}
|
||||
|
||||
return match (strtolower($code)) {
|
||||
'card' => '신용카드',
|
||||
'wcard' => '해외카드',
|
||||
'vbank' => '가상계좌',
|
||||
'directbank', 'inibank', 'banktransfer' => '계좌이체',
|
||||
'hpp', 'mobile' => '휴대폰',
|
||||
'easypay' => '간편결제',
|
||||
'point' => '포인트',
|
||||
'gift' => '상품권',
|
||||
'paybook' => '도서문화상품권',
|
||||
'billing', 'billingpay' => '정기결제',
|
||||
'samsungpay' => '삼성페이',
|
||||
'kakaopay' => '카카오페이',
|
||||
'lpay' => 'L.pay',
|
||||
'payco' => '페이코',
|
||||
'naverpay' => '네이버페이',
|
||||
'tosspay', 'toss' => '토스페이',
|
||||
'ssgpay' => 'SSG페이',
|
||||
'paypay' => 'PayPay',
|
||||
'cvs' => '일본 편의점결제',
|
||||
default => $code,
|
||||
'card' => '신용카드',
|
||||
'wcard' => '해외카드',
|
||||
'vbank' => '가상계좌',
|
||||
'directbank', 'inibank', 'banktransfer' => '계좌이체',
|
||||
'hpp', 'mobile' => '휴대폰',
|
||||
'easypay' => '간편결제',
|
||||
'point' => '포인트',
|
||||
'gift' => '상품권',
|
||||
'paybook' => '도서문화상품권',
|
||||
'billing', 'billingpay' => '정기결제',
|
||||
'samsungpay' => '삼성페이',
|
||||
'kakaopay' => '카카오페이',
|
||||
'lpay' => 'L.pay',
|
||||
'payco' => '페이코',
|
||||
'naverpay' => '네이버페이',
|
||||
'tosspay', 'toss' => '토스페이',
|
||||
'ssgpay' => 'SSG페이',
|
||||
'paypay' => 'PayPay',
|
||||
'cvs' => '일본 편의점결제',
|
||||
default => $code,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -652,10 +662,10 @@ class AdminTransactionController extends AdminBaseController
|
||||
$timePart = '';
|
||||
|
||||
if ($time !== null && $time !== '' && strlen($time) >= 6) {
|
||||
$timePart = ' ' . substr($time, 0, 2) . ':' . substr($time, 2, 2) . ':' . substr($time, 4, 2);
|
||||
$timePart = ' '.substr($time, 0, 2).':'.substr($time, 2, 2).':'.substr($time, 4, 2);
|
||||
}
|
||||
|
||||
return $datePart . $timePart;
|
||||
return $datePart.$timePart;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -670,7 +680,7 @@ class AdminTransactionController extends AdminBaseController
|
||||
return $date;
|
||||
}
|
||||
|
||||
return substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' . substr($date, 6, 2);
|
||||
return substr($date, 0, 4).'-'.substr($date, 4, 2).'-'.substr($date, 6, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -687,10 +697,10 @@ class AdminTransactionController extends AdminBaseController
|
||||
continue;
|
||||
}
|
||||
$normalized[] = [
|
||||
'price' => $item['price'] ?? $item['cancelPrice'] ?? null,
|
||||
'date' => $this->formatDateTime($item['cancelDate'] ?? null, $item['cancelTime'] ?? null),
|
||||
'msg' => $item['cancelMsg'] ?? $item['msg'] ?? null,
|
||||
'tid' => $item['cancelTid'] ?? $item['tid'] ?? null,
|
||||
'price' => $item['price'] ?? $item['cancelPrice'] ?? null,
|
||||
'date' => $this->formatDateTime($item['cancelDate'] ?? null, $item['cancelTime'] ?? null),
|
||||
'msg' => $item['cancelMsg'] ?? $item['msg'] ?? null,
|
||||
'tid' => $item['cancelTid'] ?? $item['tid'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+42
-35
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -11,6 +13,8 @@ use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
|
||||
/**
|
||||
@@ -21,9 +25,11 @@ use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
*/
|
||||
class UserEscrowConfirmController extends Controller
|
||||
{
|
||||
private const PC_JS_URL_TEST = 'https://stgstdpay.inicis.com/stdjs/INIStdPay_escrow_conf.js';
|
||||
private const PC_JS_URL_LIVE = 'https://stdpay.inicis.com/stdjs/INIStdPay_escrow_conf.js';
|
||||
private const MOBILE_PAY_URL = 'https://mobile.inicis.com/smart/payment/';
|
||||
private const PC_JS_URL_TEST = 'https://stgstdpay.inicis.com/stdjs/INIStdPay_escrow_conf.js';
|
||||
|
||||
private const PC_JS_URL_LIVE = 'https://stdpay.inicis.com/stdjs/INIStdPay_escrow_conf.js';
|
||||
|
||||
private const MOBILE_PAY_URL = 'https://mobile.inicis.com/smart/payment/';
|
||||
|
||||
public function __construct(
|
||||
private readonly KgInicisApiService $apiService,
|
||||
@@ -62,21 +68,21 @@ class UserEscrowConfirmController extends Controller
|
||||
public function pcReturn(Request $request): Response
|
||||
{
|
||||
$resultCode = (string) $request->input('ResultCode', '');
|
||||
$tid = (string) $request->input('tid', '');
|
||||
$tid = (string) $request->input('tid', '');
|
||||
|
||||
Log::info('KG Inicis: escrow PC confirm return', [
|
||||
'result_code' => $resultCode,
|
||||
'tid' => $tid,
|
||||
'tid' => $tid,
|
||||
]);
|
||||
|
||||
$this->saveConfirmResult($tid, [
|
||||
'type' => $resultCode === '00' ? 'confirm' : 'deny',
|
||||
'type' => $resultCode === '00' ? 'confirm' : 'deny',
|
||||
'result_code' => $resultCode,
|
||||
'cnf_date' => (string) $request->input('CNF_Date', ''),
|
||||
'cnf_time' => (string) $request->input('CNF_Time', ''),
|
||||
'dny_date' => (string) $request->input('DNY_Date', ''),
|
||||
'dny_time' => (string) $request->input('DNY_Time', ''),
|
||||
'dny_msg' => (string) $request->input('DNY_DenyMsg', ''),
|
||||
'cnf_date' => (string) $request->input('CNF_Date', ''),
|
||||
'cnf_time' => (string) $request->input('CNF_Time', ''),
|
||||
'dny_date' => (string) $request->input('DNY_Date', ''),
|
||||
'dny_time' => (string) $request->input('DNY_Time', ''),
|
||||
'dny_msg' => (string) $request->input('DNY_DenyMsg', ''),
|
||||
]);
|
||||
|
||||
// 팝업 닫기 + 부모 창 새로고침
|
||||
@@ -103,38 +109,38 @@ class UserEscrowConfirmController extends Controller
|
||||
*/
|
||||
public function mobileReturn(Request $request): RedirectResponse
|
||||
{
|
||||
$status = (string) $request->input('P_STATUS', '');
|
||||
$tid = (string) $request->input('P_ESCROW_TID', '');
|
||||
$status = (string) $request->input('P_STATUS', '');
|
||||
$tid = (string) $request->input('P_ESCROW_TID', '');
|
||||
$clStatus = (string) $request->input('P_CL_STATUS', '');
|
||||
$rmesg = (string) $request->input('P_RMESG1', '');
|
||||
$rmesg = (string) $request->input('P_RMESG1', '');
|
||||
|
||||
Log::info('KG Inicis: escrow mobile confirm return', [
|
||||
'status' => $status,
|
||||
'tid' => $tid,
|
||||
'status' => $status,
|
||||
'tid' => $tid,
|
||||
'cl_status' => $clStatus,
|
||||
]);
|
||||
|
||||
$orderNumber = $this->saveConfirmResult($tid, [
|
||||
'type' => $status === '00' ? 'confirm' : 'deny',
|
||||
'type' => $status === '00' ? 'confirm' : 'deny',
|
||||
'result_code' => $status,
|
||||
'cl_status' => $clStatus,
|
||||
'result_msg' => $rmesg,
|
||||
'cl_status' => $clStatus,
|
||||
'result_msg' => $rmesg,
|
||||
]);
|
||||
|
||||
return redirect($orderNumber ? '/mypage/orders/' . $orderNumber : '/mypage/orders');
|
||||
return redirect($orderNumber ? '/mypage/orders/'.$orderNumber : '/mypage/orders');
|
||||
}
|
||||
|
||||
// ──────────────────────────── Private ────────────────────────────
|
||||
|
||||
private function pcConfirmPage(string $tid): Response
|
||||
{
|
||||
$isTest = $this->apiService->isTestMode();
|
||||
$mid = $this->apiService->getMid();
|
||||
$mKey = $this->apiService->getEscrowConfirmMKey();
|
||||
$isTest = $this->apiService->isTestMode();
|
||||
$mid = $this->apiService->getMid();
|
||||
$mKey = $this->apiService->getEscrowConfirmMKey();
|
||||
$timestamp = (string) round(microtime(true) * 1000);
|
||||
$jsUrl = $isTest ? self::PC_JS_URL_TEST : self::PC_JS_URL_LIVE;
|
||||
$jsUrl = $isTest ? self::PC_JS_URL_TEST : self::PC_JS_URL_LIVE;
|
||||
$returnUrl = url('/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/pc/return');
|
||||
$closeUrl = url('/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/close');
|
||||
$closeUrl = url('/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/close');
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
@@ -173,9 +179,9 @@ class UserEscrowConfirmController extends Controller
|
||||
|
||||
private function mobileConfirmPage(string $tid): Response
|
||||
{
|
||||
$mid = $this->apiService->getMid();
|
||||
$mid = $this->apiService->getMid();
|
||||
$nextUrl = url('/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/mobile/return');
|
||||
$payUrl = self::MOBILE_PAY_URL;
|
||||
$payUrl = self::MOBILE_PAY_URL;
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
@@ -222,8 +228,8 @@ class UserEscrowConfirmController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = DB::table('ecommerce_order_payments as p')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
|
||||
$row = DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('p.transaction_id', $tid)
|
||||
->where('p.pg_provider', 'kginicis')
|
||||
->where('p.is_escrow', true)
|
||||
@@ -232,6 +238,7 @@ class UserEscrowConfirmController extends Controller
|
||||
|
||||
if (! $row) {
|
||||
Log::warning('KG Inicis: escrow confirm — payment not found', ['tid' => $tid]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -241,17 +248,17 @@ class UserEscrowConfirmController extends Controller
|
||||
$data,
|
||||
);
|
||||
|
||||
DB::table('ecommerce_order_payments')
|
||||
DB::table((new OrderPayment)->getTable())
|
||||
->where('id', $row->id)
|
||||
->update([
|
||||
'payment_meta' => json_encode($meta, JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info('KG Inicis: escrow confirm saved', [
|
||||
'order_number' => $row->order_number,
|
||||
'tid' => $tid,
|
||||
'type' => $data['type'] ?? '',
|
||||
'tid' => $tid,
|
||||
'type' => $data['type'] ?? '',
|
||||
]);
|
||||
|
||||
return $row->order_number;
|
||||
@@ -259,8 +266,8 @@ class UserEscrowConfirmController extends Controller
|
||||
|
||||
private function findEscrowPayment(string $orderNumber, int $userId): ?object
|
||||
{
|
||||
return DB::table('ecommerce_order_payments as p')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
|
||||
return DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('o.user_id', $userId)
|
||||
->where('p.pg_provider', 'kginicis')
|
||||
|
||||
@@ -4,11 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ResolvesEasyPaySelection;
|
||||
@@ -41,8 +45,8 @@ class UserReceiptController
|
||||
*/
|
||||
public function show(Request $request, string $orderNumber): JsonResponse
|
||||
{
|
||||
$query = DB::table('ecommerce_order_payments as p')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
|
||||
$query = DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('p.pg_provider', 'kginicis');
|
||||
|
||||
@@ -59,7 +63,7 @@ class UserReceiptController
|
||||
$cookieValue = $request->cookie(self::RECEIPT_COOKIE_NAME);
|
||||
if ($this->verifyReceiptCookie($cookieValue, $orderNumber)) {
|
||||
$query->where('o.id', function ($sub) use ($orderNumber) {
|
||||
$sub->select('id')->from('ecommerce_orders')->where('order_number', $orderNumber);
|
||||
$sub->select('id')->from((new Order)->getTable())->where('order_number', $orderNumber);
|
||||
});
|
||||
} else {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
@@ -107,43 +111,43 @@ class UserReceiptController
|
||||
$receiptLabels = $this->cbtReceiptLabels($cbtPayMethod, (string) ($payment->payment_status ?? ''));
|
||||
|
||||
return response()->json([
|
||||
'receipt_type' => 'cbt_confirmation',
|
||||
'receipt_url' => null,
|
||||
'receipt_label' => $receiptLabels['label'],
|
||||
'receipt_view_label' => $receiptLabels['view_label'],
|
||||
'receipt_title' => $receiptLabels['title'],
|
||||
'receipt_notice' => $receiptLabels['notice'],
|
||||
'receipt_fields' => $this->buildCbtReceiptFields($payment, $paymentMeta),
|
||||
'is_test_mode' => $isTestMode,
|
||||
'payment_method_label' => $cbtPaymentMethodLabel,
|
||||
'payment_method_display_label' => $cbtPaymentMethodLabel,
|
||||
'cbt_pay_method' => $cbtPayMethod,
|
||||
'payment_status' => (string) ($payment->payment_status ?? ''),
|
||||
'selected_payment_method' => $paymentMeta['selected_payment_method'] ?? null,
|
||||
'embedded_pg_provider' => is_string($embeddedPgProvider) ? $embeddedPgProvider : null,
|
||||
'embedded_pg_provider_label' => is_string($embeddedPgProviderLabel) ? $embeddedPgProviderLabel : null,
|
||||
'receipt_type' => 'cbt_confirmation',
|
||||
'receipt_url' => null,
|
||||
'receipt_label' => $receiptLabels['label'],
|
||||
'receipt_view_label' => $receiptLabels['view_label'],
|
||||
'receipt_title' => $receiptLabels['title'],
|
||||
'receipt_notice' => $receiptLabels['notice'],
|
||||
'receipt_fields' => $this->buildCbtReceiptFields($payment, $paymentMeta),
|
||||
'is_test_mode' => $isTestMode,
|
||||
'payment_method_label' => $cbtPaymentMethodLabel,
|
||||
'payment_method_display_label' => $cbtPaymentMethodLabel,
|
||||
'cbt_pay_method' => $cbtPayMethod,
|
||||
'payment_status' => (string) ($payment->payment_status ?? ''),
|
||||
'selected_payment_method' => $paymentMeta['selected_payment_method'] ?? null,
|
||||
'embedded_pg_provider' => is_string($embeddedPgProvider) ? $embeddedPgProvider : null,
|
||||
'embedded_pg_provider_label' => is_string($embeddedPgProviderLabel) ? $embeddedPgProviderLabel : null,
|
||||
]);
|
||||
}
|
||||
|
||||
$receiptUrl = self::RECEIPT_BASE_URL . '?' . http_build_query([
|
||||
'noTid' => $payment->transaction_id,
|
||||
$receiptUrl = self::RECEIPT_BASE_URL.'?'.http_build_query([
|
||||
'noTid' => $payment->transaction_id,
|
||||
'noMethod' => '1',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'receipt_type' => 'inicis_receipt',
|
||||
'receipt_url' => $receiptUrl,
|
||||
'receipt_label' => '영수증',
|
||||
'receipt_view_label' => '영수증 조회',
|
||||
'is_test_mode' => $isTestMode,
|
||||
'payment_method_label' => $basePaymentMethodLabel,
|
||||
'payment_method_display_label' => $this->paymentMethodDisplayLabel(
|
||||
'receipt_type' => 'inicis_receipt',
|
||||
'receipt_url' => $receiptUrl,
|
||||
'receipt_label' => '영수증',
|
||||
'receipt_view_label' => '영수증 조회',
|
||||
'is_test_mode' => $isTestMode,
|
||||
'payment_method_label' => $basePaymentMethodLabel,
|
||||
'payment_method_display_label' => $this->paymentMethodDisplayLabel(
|
||||
$basePaymentMethodLabel,
|
||||
is_string($embeddedPgProviderLabel) ? $embeddedPgProviderLabel : null,
|
||||
),
|
||||
'selected_payment_method' => $paymentMeta['selected_payment_method'] ?? null,
|
||||
'embedded_pg_provider' => is_string($embeddedPgProvider) ? $embeddedPgProvider : null,
|
||||
'embedded_pg_provider_label' => is_string($embeddedPgProviderLabel) ? $embeddedPgProviderLabel : null,
|
||||
'selected_payment_method' => $paymentMeta['selected_payment_method'] ?? null,
|
||||
'embedded_pg_provider' => is_string($embeddedPgProvider) ? $embeddedPgProvider : null,
|
||||
'embedded_pg_provider_label' => is_string($embeddedPgProviderLabel) ? $embeddedPgProviderLabel : null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -282,7 +286,7 @@ class UserReceiptController
|
||||
return '';
|
||||
}
|
||||
|
||||
return number_format((float) $amount) . ' ' . $currency;
|
||||
return number_format((float) $amount).' '.$currency;
|
||||
}
|
||||
|
||||
private function formatCbtDateTime(mixed $date, mixed $time): string
|
||||
@@ -324,7 +328,7 @@ class UserReceiptController
|
||||
|
||||
$months = (int) $value;
|
||||
|
||||
return $months <= 0 ? '일시불' : $months . '개월';
|
||||
return $months <= 0 ? '일시불' : $months.'개월';
|
||||
}
|
||||
|
||||
private function decodePaymentMeta(mixed $paymentMeta): array
|
||||
@@ -387,6 +391,6 @@ class UserReceiptController
|
||||
return $embeddedLabel;
|
||||
}
|
||||
|
||||
return $embeddedLabel . ' (' . $baseLabel . ')';
|
||||
return $embeddedLabel.' ('.$baseLabel.')';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,8 +201,12 @@ abstract class PluginTestCase extends TestCase
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $moduleBasePath.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,8 +235,12 @@ abstract class PluginTestCase extends TestCase
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $pluginBasePath.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
}, true, true);
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
## [1.0.2] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
- 주문·결제 정보 조회가 데이터베이스 표 이름을 직접 쓰지 않고 쇼핑몰 모듈의 정의를 따르도록 내부 정리했습니다. 표시되는 내용과 동작은 종전과 동일합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다.
|
||||
|
||||
+29
-24
@@ -4,12 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\SanitizesPgResponse;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
|
||||
|
||||
@@ -72,8 +77,8 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
return ResponseHelper::success('common.success', null);
|
||||
}
|
||||
|
||||
$address = DB::table('ecommerce_order_addresses as a')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'a.order_id')
|
||||
$address = DB::table((new OrderAddress)->getTable().' as a')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'a.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('a.address_type', 'shipping')
|
||||
->select([
|
||||
@@ -89,14 +94,14 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
$escrowDelivery = $meta['escrow_delivery'] ?? null;
|
||||
|
||||
return ResponseHelper::success('common.success', [
|
||||
'has_escrow_payment' => true,
|
||||
'tno' => $payment->transaction_id,
|
||||
'courier_codes' => self::COURIER_CODES,
|
||||
'prefill' => [
|
||||
'has_escrow_payment' => true,
|
||||
'tno' => $payment->transaction_id,
|
||||
'courier_codes' => self::COURIER_CODES,
|
||||
'prefill' => [
|
||||
'recv_name' => $address?->recipient_name ?? '',
|
||||
'recv_tel' => $address?->recipient_phone ?? '',
|
||||
'recv_tel' => $address?->recipient_phone ?? '',
|
||||
'recv_post' => $address?->zipcode ?? '',
|
||||
'recv_addr' => trim(($address?->address ?? '') . ' ' . ($address?->address_detail ?? '')),
|
||||
'recv_addr' => trim(($address?->address ?? '').' '.($address?->address_detail ?? '')),
|
||||
],
|
||||
'registered_delivery' => $escrowDelivery,
|
||||
]);
|
||||
@@ -135,9 +140,9 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
|
||||
Log::info('KCP: escrow delivery register requested', [
|
||||
'order_number' => $orderNumber,
|
||||
'tno' => $payment->transaction_id,
|
||||
'deli_numb' => $deliNumb,
|
||||
'deli_corp' => $deliCorp,
|
||||
'tno' => $payment->transaction_id,
|
||||
'deli_numb' => $deliNumb,
|
||||
'deli_corp' => $deliCorp,
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -152,37 +157,37 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
$meta = $payment->payment_meta ? json_decode($payment->payment_meta, true) : [];
|
||||
$meta['escrow_delivery'] = [
|
||||
'registered_at' => now()->toDateTimeString(),
|
||||
'deli_numb' => $deliNumb,
|
||||
'deli_corp' => $deliCorp,
|
||||
'courier_name' => $courierName,
|
||||
'deli_numb' => $deliNumb,
|
||||
'deli_corp' => $deliCorp,
|
||||
'courier_name' => $courierName,
|
||||
'pg_response_sanitized' => true,
|
||||
'pg_response' => $this->sanitizePgResponse($pgResponse, self::ESCROW_DELIVERY_RESPONSE_KEYS),
|
||||
'pg_response' => $this->sanitizePgResponse($pgResponse, self::ESCROW_DELIVERY_RESPONSE_KEYS),
|
||||
];
|
||||
|
||||
DB::table('ecommerce_order_payments')
|
||||
DB::table((new OrderPayment)->getTable())
|
||||
->where('id', $payment->id)
|
||||
->update([
|
||||
'payment_meta' => json_encode($meta, JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info('KCP: escrow delivery registered', [
|
||||
'order_number' => $orderNumber,
|
||||
'tno' => $payment->transaction_id,
|
||||
'deli_numb' => $deliNumb,
|
||||
'tno' => $payment->transaction_id,
|
||||
'deli_numb' => $deliNumb,
|
||||
'courier_name' => $courierName,
|
||||
]);
|
||||
|
||||
return ResponseHelper::success('common.success', [
|
||||
'res_cd' => $pgResponse['res_cd'] ?? '0000',
|
||||
'deli_numb' => $deliNumb,
|
||||
'res_cd' => $pgResponse['res_cd'] ?? '0000',
|
||||
'deli_numb' => $deliNumb,
|
||||
'courier_name' => $courierName,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('KCP: escrow delivery register exception', [
|
||||
'order_number' => $orderNumber,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ResponseHelper::error('common.failed', 500, [
|
||||
@@ -193,8 +198,8 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
|
||||
private function findEscrowPayment(string $orderNumber): ?object
|
||||
{
|
||||
return DB::table('ecommerce_order_payments as p')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
|
||||
return DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('p.pg_provider', 'nhnkcp')
|
||||
->where('p.is_escrow', true)
|
||||
|
||||
@@ -4,11 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
|
||||
|
||||
class AdminOrderListController extends AdminBaseController
|
||||
@@ -36,8 +40,8 @@ class AdminOrderListController extends AdminBaseController
|
||||
*/
|
||||
public function testModeMap(): JsonResponse
|
||||
{
|
||||
$query = DB::table('ecommerce_orders as o')
|
||||
->join('ecommerce_order_payments as p', 'p.order_id', '=', 'o.id')
|
||||
$query = DB::table((new Order)->getTable().' as o')
|
||||
->join((new OrderPayment)->getTable().' as p', 'p.order_id', '=', 'o.id')
|
||||
->where('p.pg_provider', 'nhnkcp')
|
||||
->where('p.created_at', '>=', now()->subMonths(6));
|
||||
|
||||
@@ -72,8 +76,8 @@ class AdminOrderListController extends AdminBaseController
|
||||
*/
|
||||
public function easyPayDisplayMap(): JsonResponse
|
||||
{
|
||||
$rows = DB::table('ecommerce_orders as o')
|
||||
->join('ecommerce_order_payments as p', 'p.order_id', '=', 'o.id')
|
||||
$rows = DB::table((new Order)->getTable().' as o')
|
||||
->join((new OrderPayment)->getTable().' as p', 'p.order_id', '=', 'o.id')
|
||||
->where('p.pg_provider', 'nhnkcp')
|
||||
->where('p.created_at', '>=', now()->subMonths(6))
|
||||
->select([
|
||||
|
||||
+16
-11
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Services\PluginSettingsService;
|
||||
@@ -11,6 +13,9 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderRefund;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
|
||||
|
||||
/**
|
||||
@@ -45,8 +50,8 @@ class AdminTransactionController extends AdminBaseController
|
||||
*/
|
||||
public function queryByOrder(string $orderNumber): JsonResponse
|
||||
{
|
||||
$payment = DB::table('ecommerce_order_payments as p')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
|
||||
$payment = DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('p.pg_provider', 'nhnkcp')
|
||||
->whereNotNull('p.transaction_id')
|
||||
@@ -89,15 +94,15 @@ class AdminTransactionController extends AdminBaseController
|
||||
$refundAmount = $refund ? (float) ($refund->refund_amount ?? 0) : 0.0;
|
||||
|
||||
return ResponseHelper::success('common.success', [
|
||||
'tno' => $payment->transaction_id,
|
||||
'app_no' => $rawResponse['app_no'] ?? $meta['app_no'] ?? null,
|
||||
'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,
|
||||
'app_time' => $meta['app_time'] ?? $rawResponse['app_time'] ?? null,
|
||||
'res_cd' => $meta['res_cd'] ?? $rawResponse['res_cd'] ?? '0000',
|
||||
'card_name' => $rawResponse['card_name'] ?? $meta['card_name'] ?? $rawResponse['bank_name'] ?? $meta['bank_name'] ?? null,
|
||||
'account' => $meta['account'] ?? null,
|
||||
'bank_name' => $rawResponse['bank_name'] ?? $meta['bank_name'] ?? null,
|
||||
'_is_test_mode' => $isTest,
|
||||
'app_time' => $meta['app_time'] ?? $rawResponse['app_time'] ?? null,
|
||||
'res_cd' => $meta['res_cd'] ?? $rawResponse['res_cd'] ?? '0000',
|
||||
'card_name' => $rawResponse['card_name'] ?? $meta['card_name'] ?? $rawResponse['bank_name'] ?? $meta['bank_name'] ?? null,
|
||||
'account' => $meta['account'] ?? null,
|
||||
'bank_name' => $rawResponse['bank_name'] ?? $meta['bank_name'] ?? null,
|
||||
'_is_test_mode' => $isTest,
|
||||
'payment_status' => $paymentStatus?->value ?? $payment->payment_status,
|
||||
'payment_status_label' => $paymentStatus?->label(),
|
||||
'payment_status_variant' => $paymentStatus?->variant(),
|
||||
@@ -123,7 +128,7 @@ class AdminTransactionController extends AdminBaseController
|
||||
|
||||
private function latestRefundForOrder(int $orderId): ?object
|
||||
{
|
||||
return DB::table('ecommerce_order_refunds')
|
||||
return DB::table((new OrderRefund)->getTable())
|
||||
->where('order_id', $orderId)
|
||||
->orderByDesc('id')
|
||||
->select([
|
||||
|
||||
@@ -4,10 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
|
||||
@@ -52,8 +56,8 @@ class UserReceiptController
|
||||
public function show(Request $request, string $orderNumber): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$query = DB::table('ecommerce_order_payments as p')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
|
||||
$query = DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('p.pg_provider', 'nhnkcp')
|
||||
->whereNotNull('p.transaction_id');
|
||||
@@ -68,7 +72,7 @@ class UserReceiptController
|
||||
} elseif ($this->verifyReceiptCookie($request->cookie(self::RECEIPT_COOKIE_NAME), $orderNumber)) {
|
||||
$query->whereNull('o.user_id')
|
||||
->where('o.id', function ($sub) use ($orderNumber) {
|
||||
$sub->select('id')->from('ecommerce_orders')->where('order_number', $orderNumber);
|
||||
$sub->select('id')->from((new Order)->getTable())->where('order_number', $orderNumber);
|
||||
});
|
||||
} else {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
@@ -111,10 +115,10 @@ class UserReceiptController
|
||||
|
||||
// 휴대폰결제 → mcash_bill, 그 외 (card / bank / vbank) → card_bill
|
||||
$billCmd = $isPhonePayment ? 'mcash_bill' : 'card_bill';
|
||||
$receiptUrl = $billBaseUrl . $billCmd
|
||||
. '&tno=' . urlencode($tno)
|
||||
. '&order_no=' . urlencode($orderNo)
|
||||
. '&trade_mony=' . $tradeMony;
|
||||
$receiptUrl = $billBaseUrl.$billCmd
|
||||
.'&tno='.urlencode($tno)
|
||||
.'&order_no='.urlencode($orderNo)
|
||||
.'&trade_mony='.$tradeMony;
|
||||
|
||||
// 현금영수증 URL (계좌이체 · 가상계좌만 해당)
|
||||
$cashReceiptUrl = null;
|
||||
@@ -124,16 +128,16 @@ class UserReceiptController
|
||||
$authNo = $pgRaw['app_no'] ?? $pgRaw['receipt_no'] ?? $tno;
|
||||
$siteCd = $this->apiService->getSiteCd();
|
||||
|
||||
$cashReceiptUrl = $cashBaseUrl . $siteCd
|
||||
. '&orderid=' . urlencode($orderNo)
|
||||
. '&bill_yn=Y'
|
||||
. '&authno=' . urlencode((string) $authNo);
|
||||
$cashReceiptUrl = $cashBaseUrl.$siteCd
|
||||
.'&orderid='.urlencode($orderNo)
|
||||
.'&bill_yn=Y'
|
||||
.'&authno='.urlencode((string) $authNo);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'receipt_url' => $receiptUrl,
|
||||
'receipt_url' => $receiptUrl,
|
||||
'cash_receipt_url' => $cashReceiptUrl,
|
||||
'is_test_mode' => $isTest,
|
||||
'is_test_mode' => $isTest,
|
||||
'payment_method_label' => $display['payment_method_label'],
|
||||
'payment_method_display_label' => $display['payment_method_display_label'],
|
||||
'selected_payment_method' => $display['selected_payment_method'],
|
||||
|
||||
@@ -124,8 +124,12 @@ abstract class PluginTestCase extends TestCase
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $moduleBasePath.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,8 +154,12 @@ abstract class PluginTestCase extends TestCase
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $pluginBasePath.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
## [1.0.2] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
- 주문·결제 정보 조회가 데이터베이스 표 이름을 직접 쓰지 않고 쇼핑몰 모듈의 정의를 따르도록 내부 정리했습니다. 표시되는 내용과 동작은 종전과 동일합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 관리자 주문 상세에서 에스크로 배송 등록이 실패하면 내부 오류 원문이 그대로 안내 문구 자리에 표시되던 문제를 수정했습니다. 이제 상황에 맞는 안내가 표시되고, 원인은 서버 로그에만 기록됩니다.
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -11,6 +13,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService;
|
||||
|
||||
@@ -38,12 +41,12 @@ class AdminEscrowController extends AdminBaseController
|
||||
*/
|
||||
public function getEscrowPayments(string $orderNumber): JsonResponse
|
||||
{
|
||||
$payments = DB::table('ecommerce_order_payments')
|
||||
->join('ecommerce_orders', 'ecommerce_orders.id', '=', 'ecommerce_order_payments.order_id')
|
||||
->where('ecommerce_orders.order_number', $orderNumber)
|
||||
->where('ecommerce_order_payments.pg_provider', 'nicepayments')
|
||||
->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']);
|
||||
$payments = DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('p.pg_provider', 'nicepayments')
|
||||
->where('p.is_escrow', 1)
|
||||
->get(['p.id', 'p.transaction_id', 'p.payment_method', 'p.payment_status']);
|
||||
|
||||
return ResponseHelper::success('common.success', [
|
||||
'escrow_payments' => $payments->map(fn ($p) => [
|
||||
|
||||
+8
-4
@@ -4,10 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
|
||||
|
||||
class AdminOrderListController extends AdminBaseController
|
||||
@@ -30,8 +34,8 @@ class AdminOrderListController extends AdminBaseController
|
||||
*/
|
||||
public function testModeMap(): JsonResponse
|
||||
{
|
||||
$rows = DB::table('ecommerce_orders as o')
|
||||
->join('ecommerce_order_payments as p', 'p.order_id', '=', 'o.id')
|
||||
$rows = DB::table((new Order)->getTable().' as o')
|
||||
->join((new OrderPayment)->getTable().' as p', 'p.order_id', '=', 'o.id')
|
||||
->whereIn('p.pg_provider', ['nicepayments', 'nicepay'])
|
||||
->whereNotNull('p.payment_meta')
|
||||
->where('p.created_at', '>=', now()->subMonths(6))
|
||||
@@ -59,8 +63,8 @@ class AdminOrderListController extends AdminBaseController
|
||||
*/
|
||||
public function easyPayDisplayMap(): JsonResponse
|
||||
{
|
||||
$rows = DB::table('ecommerce_orders as o')
|
||||
->join('ecommerce_order_payments as p', 'p.order_id', '=', 'o.id')
|
||||
$rows = DB::table((new Order)->getTable().' as o')
|
||||
->join((new OrderPayment)->getTable().' as p', 'p.order_id', '=', 'o.id')
|
||||
->whereIn('p.pg_provider', ['nicepayments', 'nicepay'])
|
||||
->where('p.created_at', '>=', now()->subMonths(6))
|
||||
->select([
|
||||
|
||||
+14
-9
@@ -2,12 +2,16 @@
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
|
||||
use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService;
|
||||
|
||||
@@ -52,16 +56,16 @@ class AdminTransactionController extends AdminBaseController
|
||||
*/
|
||||
public function queryByOrder(string $orderNumber): JsonResponse
|
||||
{
|
||||
$payment = DB::table('ecommerce_order_payments')
|
||||
->join('ecommerce_orders', 'ecommerce_orders.id', '=', 'ecommerce_order_payments.order_id')
|
||||
->where('ecommerce_orders.order_number', $orderNumber)
|
||||
->whereNotNull('ecommerce_order_payments.transaction_id')
|
||||
->where('ecommerce_order_payments.transaction_id', '!=', '')
|
||||
->whereIn('ecommerce_order_payments.pg_provider', ['nicepayments', 'nicepay'])
|
||||
->select(['ecommerce_order_payments.transaction_id'])
|
||||
$payment = DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->whereNotNull('p.transaction_id')
|
||||
->where('p.transaction_id', '!=', '')
|
||||
->whereIn('p.pg_provider', ['nicepayments', 'nicepay'])
|
||||
->select(['p.transaction_id'])
|
||||
->first();
|
||||
|
||||
if (!$payment) {
|
||||
if (! $payment) {
|
||||
return ResponseHelper::success('common.success', null);
|
||||
}
|
||||
|
||||
@@ -73,7 +77,7 @@ class AdminTransactionController extends AdminBaseController
|
||||
try {
|
||||
$result = $this->apiService->queryTransaction($tid);
|
||||
|
||||
$localPayment = DB::table('ecommerce_order_payments')
|
||||
$localPayment = DB::table((new OrderPayment)->getTable())
|
||||
->where('transaction_id', $tid)
|
||||
->select(['is_escrow', 'payment_meta', 'payment_method', 'embedded_pg_provider'])
|
||||
->first();
|
||||
@@ -104,6 +108,7 @@ class AdminTransactionController extends AdminBaseController
|
||||
'tid' => $tid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ResponseHelper::error('common.failed', 502, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
|
||||
@@ -36,8 +40,8 @@ class UserReceiptController
|
||||
public function show(Request $request, string $orderNumber): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$query = DB::table('ecommerce_order_payments as p')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
|
||||
$query = DB::table((new OrderPayment)->getTable().' as p')
|
||||
->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id')
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('p.pg_provider', 'nicepayments');
|
||||
|
||||
@@ -51,7 +55,7 @@ class UserReceiptController
|
||||
} elseif ($this->verifyReceiptCookie($request->cookie(self::RECEIPT_COOKIE_NAME), $orderNumber)) {
|
||||
$query->whereNull('o.user_id')
|
||||
->where('o.id', function ($sub) use ($orderNumber) {
|
||||
$sub->select('id')->from('ecommerce_orders')->where('order_number', $orderNumber);
|
||||
$sub->select('id')->from((new Order)->getTable())->where('order_number', $orderNumber);
|
||||
});
|
||||
} else {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
@@ -74,7 +78,7 @@ class UserReceiptController
|
||||
|
||||
$receiptUrl = $payment->receipt_url;
|
||||
if (! $receiptUrl && $payment->transaction_id) {
|
||||
$receiptUrl = self::RECEIPT_BASE_URL . '?type=0&TID=' . rawurlencode($payment->transaction_id);
|
||||
$receiptUrl = self::RECEIPT_BASE_URL.'?type=0&TID='.rawurlencode($payment->transaction_id);
|
||||
}
|
||||
|
||||
$cashReceiptUrl = null;
|
||||
@@ -84,7 +88,7 @@ class UserReceiptController
|
||||
$meta = json_decode($payment->payment_meta, true);
|
||||
$rcptTid = $meta['rcpt_tid'] ?? ($meta['pg_raw_response']['RcptTID'] ?? null);
|
||||
if ($rcptTid) {
|
||||
$cashReceiptUrl = self::RECEIPT_BASE_URL . '?type=1&TID=' . rawurlencode($rcptTid);
|
||||
$cashReceiptUrl = self::RECEIPT_BASE_URL.'?type=1&TID='.rawurlencode($rcptTid);
|
||||
}
|
||||
$isTestMode = (bool) ($meta['is_test_mode'] ?? false);
|
||||
}
|
||||
|
||||
@@ -167,8 +167,12 @@ abstract class PluginTestCase extends TestCase
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $moduleBasePath.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,8 +197,12 @@ abstract class PluginTestCase extends TestCase
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $pluginBasePath.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests;
|
||||
|
||||
use App\Enums\PermissionType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Seeders\TestingSeeder;
|
||||
use Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -32,7 +41,7 @@ abstract class PluginTestCase extends TestCase
|
||||
*/
|
||||
protected function seeder(): string
|
||||
{
|
||||
return \Modules\Sirsoft\Ecommerce\Database\Seeders\TestingSeeder::class;
|
||||
return TestingSeeder::class;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +91,7 @@ abstract class PluginTestCase extends TestCase
|
||||
$this->registerPluginAutoload();
|
||||
|
||||
// 이커머스 모듈 ServiceProvider 등록 (Repository 바인딩)
|
||||
$this->app->register(\Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider::class);
|
||||
$this->app->register(EcommerceServiceProvider::class);
|
||||
|
||||
// 모듈 라우트를 수동으로 등록
|
||||
$this->registerModuleRoutes();
|
||||
@@ -93,7 +102,7 @@ abstract class PluginTestCase extends TestCase
|
||||
// SettingsServiceProvider 가 storage/app/settings/general.json 의 site_url 로
|
||||
// app.url 을 override 하면 Laravel 의 assertRedirect (APP_URL 기반) 와 mismatch.
|
||||
// 테스트 환경에서는 APP_URL 그대로 사용하도록 명시 리셋.
|
||||
\Illuminate\Support\Facades\Config::set('app.url', env('APP_URL', 'http://localhost'));
|
||||
Config::set('app.url', env('APP_URL', 'http://localhost'));
|
||||
|
||||
// HookManager 상태 스냅샷 (tearDown 에서 복원)
|
||||
$this->snapshotHookManager();
|
||||
@@ -114,7 +123,7 @@ abstract class PluginTestCase extends TestCase
|
||||
*/
|
||||
private function snapshotHookManager(): void
|
||||
{
|
||||
$ref = new \ReflectionClass(\App\Extension\HookManager::class);
|
||||
$ref = new \ReflectionClass(HookManager::class);
|
||||
$this->hookSnapshot = [
|
||||
'hooks' => $ref->getProperty('hooks')->getValue(),
|
||||
'filters' => $ref->getProperty('filters')->getValue(),
|
||||
@@ -131,7 +140,7 @@ abstract class PluginTestCase extends TestCase
|
||||
return;
|
||||
}
|
||||
|
||||
$ref = new \ReflectionClass(\App\Extension\HookManager::class);
|
||||
$ref = new \ReflectionClass(HookManager::class);
|
||||
$ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']);
|
||||
$ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']);
|
||||
$ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']);
|
||||
@@ -155,15 +164,19 @@ abstract class PluginTestCase extends TestCase
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $moduleBasePath . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
$file = $moduleBasePath.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
// composer.json files 오토로드 (헬퍼 함수 등록)
|
||||
$helpersFile = $moduleBasePath . 'Helpers/helpers.php';
|
||||
$helpersFile = $moduleBasePath.'Helpers/helpers.php';
|
||||
if (file_exists($helpersFile)) {
|
||||
require_once $helpersFile;
|
||||
}
|
||||
@@ -176,7 +189,7 @@ abstract class PluginTestCase extends TestCase
|
||||
{
|
||||
// 활성 디렉토리(plugins/sirsoft-tosspayments)가 아닌 자기 자신 기준 경로 —
|
||||
// _bundled 에서 직접 실행할 때도 소스를 찾도록 한다.
|
||||
$pluginBasePath = dirname(__DIR__) . '/src/';
|
||||
$pluginBasePath = dirname(__DIR__).'/src/';
|
||||
|
||||
spl_autoload_register(function ($class) use ($pluginBasePath) {
|
||||
$prefix = 'Plugins\\Sirsoft\\Tosspayments\\';
|
||||
@@ -187,10 +200,14 @@ abstract class PluginTestCase extends TestCase
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $pluginBasePath . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
$file = $pluginBasePath.str_replace('\\', '/', $relativeClass).'.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -203,7 +220,7 @@ abstract class PluginTestCase extends TestCase
|
||||
$apiRoutesFile = base_path('modules/sirsoft-ecommerce/src/routes/api.php');
|
||||
|
||||
if (file_exists($apiRoutesFile)) {
|
||||
\Illuminate\Support\Facades\Route::prefix('api/modules/sirsoft-ecommerce')
|
||||
Route::prefix('api/modules/sirsoft-ecommerce')
|
||||
->name('api.modules.sirsoft-ecommerce.')
|
||||
->middleware('api')
|
||||
->group($apiRoutesFile);
|
||||
@@ -215,10 +232,10 @@ abstract class PluginTestCase extends TestCase
|
||||
*/
|
||||
protected function registerPluginRoutes(): void
|
||||
{
|
||||
$webRoutesFile = dirname(__DIR__) . '/src/routes/web.php';
|
||||
$webRoutesFile = dirname(__DIR__).'/src/routes/web.php';
|
||||
|
||||
if (file_exists($webRoutesFile)) {
|
||||
\Illuminate\Support\Facades\Route::prefix('plugins/sirsoft-tosspayments')
|
||||
Route::prefix('plugins/sirsoft-tosspayments')
|
||||
->name('plugins.sirsoft-tosspayments.')
|
||||
->middleware('web')
|
||||
->group($webRoutesFile);
|
||||
@@ -228,32 +245,32 @@ abstract class PluginTestCase extends TestCase
|
||||
/**
|
||||
* 관리자 사용자를 생성합니다.
|
||||
*
|
||||
* @param array $permissions 추가 권한 목록
|
||||
* @return \App\Models\User
|
||||
* @param array $permissions 추가 권한 목록
|
||||
* @return User
|
||||
*/
|
||||
protected function createAdminUser(array $permissions = []): \App\Models\User
|
||||
protected function createAdminUser(array $permissions = []): User
|
||||
{
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$uniqueRoleIdentifier = 'admin-test-' . $user->id . '-' . time();
|
||||
$userRole = \App\Models\Role::create([
|
||||
$uniqueRoleIdentifier = 'admin-test-'.$user->id.'-'.time();
|
||||
$userRole = Role::create([
|
||||
'identifier' => $uniqueRoleIdentifier,
|
||||
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
|
||||
]);
|
||||
$user->roles()->attach($userRole->id);
|
||||
|
||||
$adminAccessPermission = \App\Models\Permission::firstOrCreate(
|
||||
$adminAccessPermission = Permission::firstOrCreate(
|
||||
['identifier' => 'admin.access'],
|
||||
[
|
||||
'name' => ['ko' => '관리자 접근', 'en' => 'Admin Access'],
|
||||
'type' => \App\Enums\PermissionType::Admin,
|
||||
'type' => PermissionType::Admin,
|
||||
]
|
||||
);
|
||||
$userRole->permissions()->attach($adminAccessPermission->id);
|
||||
|
||||
if (! empty($permissions)) {
|
||||
foreach ($permissions as $permissionIdentifier) {
|
||||
$permission = \App\Models\Permission::firstOrCreate(
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => $permissionIdentifier],
|
||||
[
|
||||
'name' => ['ko' => $permissionIdentifier, 'en' => $permissionIdentifier],
|
||||
@@ -270,12 +287,12 @@ abstract class PluginTestCase extends TestCase
|
||||
/**
|
||||
* 일반 사용자를 생성합니다.
|
||||
*
|
||||
* @return \App\Models\User
|
||||
* @return User
|
||||
*/
|
||||
protected function createUser(): \App\Models\User
|
||||
protected function createUser(): User
|
||||
{
|
||||
$userRole = \App\Models\Role::where('identifier', 'user')->first();
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$userRole = Role::where('identifier', 'user')->first();
|
||||
$user = User::factory()->create();
|
||||
|
||||
if ($userRole) {
|
||||
$user->roles()->attach($userRole->id);
|
||||
|
||||
@@ -195,8 +195,12 @@ abstract class PluginTestCase extends TestCase
|
||||
return;
|
||||
}
|
||||
$file = $base.str_replace('\\', '/', substr($class, $len)).'.php';
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -198,8 +198,12 @@ abstract class PluginTestCase extends TestCase
|
||||
|
||||
// composer.json 의 psr-4 는 src/ 와 플러그인 루트를 함께 매핑한다 (Plugin 클래스는 plugin.php).
|
||||
foreach ([$base.$relative.'.php', $root.strtolower($relative).'.php'] as $file) {
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
if (file_exists($file)
|
||||
&& ! class_exists($class, false) && ! interface_exists($class, false)
|
||||
&& ! trait_exists($class, false) && ! enum_exists($class, false)) {
|
||||
// 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
|
||||
// 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
|
||||
require_once $file;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 통합검색에서 검색이 실패했을 때 "검색 결과가 없습니다" 와 구분되는 "검색 중 오류" 안내를 표시합니다. 실패한 항목만 오류로 표시되고 나머지 항목의 결과는 정상 표시됩니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 공개 자산 스토리지(직접 URL/CDN)를 사용하는 사이트에서도 첨부 이미지 미리보기와 다운로드가 그 주소를 그대로 사용해 정상 동작합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
"pages_in_all": "No pages found",
|
||||
"suggestion": "Try a different search term"
|
||||
},
|
||||
"failed": {
|
||||
"title": "An error occurred while searching",
|
||||
"suggestion": "Please try again in a moment"
|
||||
},
|
||||
"page_published_at": "Published: {{date}}",
|
||||
"view_more": "View more",
|
||||
"view_all": "View all",
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
"pages_in_all": "페이지 검색 결과가 없습니다",
|
||||
"suggestion": "다른 검색어로 시도해 보세요"
|
||||
},
|
||||
"failed": {
|
||||
"title": "검색 중 오류가 발생했습니다",
|
||||
"suggestion": "잠시 후 다시 시도해 주세요"
|
||||
},
|
||||
"page_published_at": "발행일: {{date}}",
|
||||
"view_more": "더보기",
|
||||
"view_all": "전체보기",
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "게시글 탭 - 결과 없을 때",
|
||||
"comment": "게시글 탭 - 결과 없을 때 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{query?.type === 'posts' && (searchResults?.data?.posts_count ?? 0) === 0}}",
|
||||
"if": "{{query?.type === 'posts' && (searchResults?.data?.posts_count ?? 0) === 0 && !(searchResults?.data?.categories_failed?.posts ?? false)}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
@@ -44,6 +44,25 @@
|
||||
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.empty.suggestion" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "게시글 탭 - 검색 실패",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{query?.type === 'posts' && (searchResults?.data?.categories_failed?.posts ?? false)}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "mb-5 text-red-300 dark:text-red-500/60" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "4x" } }
|
||||
]
|
||||
},
|
||||
{ "type": "basic", "name": "H3", "props": { "className": "text-lg font-medium text-gray-900 dark:text-white mb-2" }, "text": "$t:search.failed.title" },
|
||||
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.failed.suggestion" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "상품 탭 - 결과 있을 때",
|
||||
"type": "basic",
|
||||
@@ -62,10 +81,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "상품 탭 - 결과 없을 때",
|
||||
"comment": "상품 탭 - 결과 없을 때 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{query?.type === 'products' && (searchResults?.data?.products_count ?? 0) === 0}}",
|
||||
"if": "{{query?.type === 'products' && (searchResults?.data?.products_count ?? 0) === 0 && !(searchResults?.data?.categories_failed?.products ?? false)}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
@@ -80,6 +99,25 @@
|
||||
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.empty.suggestion" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "상품 탭 - 검색 실패",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{query?.type === 'products' && (searchResults?.data?.categories_failed?.products ?? false)}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "mb-5 text-red-300 dark:text-red-500/60" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "4x" } }
|
||||
]
|
||||
},
|
||||
{ "type": "basic", "name": "H3", "props": { "className": "text-lg font-medium text-gray-900 dark:text-white mb-2" }, "text": "$t:search.failed.title" },
|
||||
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.failed.suggestion" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "페이지 탭 - 결과 있을 때",
|
||||
"type": "basic",
|
||||
@@ -90,10 +128,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "페이지 탭 - 결과 없을 때",
|
||||
"comment": "페이지 탭 - 결과 없을 때 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{query?.type === 'pages' && (searchResults?.data?.pages_count ?? 0) === 0}}",
|
||||
"if": "{{query?.type === 'pages' && (searchResults?.data?.pages_count ?? 0) === 0 && !(searchResults?.data?.categories_failed?.pages ?? false)}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
@@ -107,6 +145,25 @@
|
||||
{ "type": "basic", "name": "H3", "props": { "className": "text-lg font-medium text-gray-900 dark:text-white mb-2" }, "text": "$t:search.empty.pages" },
|
||||
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.empty.suggestion" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "페이지 탭 - 검색 실패",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{query?.type === 'pages' && (searchResults?.data?.categories_failed?.pages ?? false)}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "mb-5 text-red-300 dark:text-red-500/60" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "4x" } }
|
||||
]
|
||||
},
|
||||
{ "type": "basic", "name": "H3", "props": { "className": "text-lg font-medium text-gray-900 dark:text-white mb-2" }, "text": "$t:search.failed.title" },
|
||||
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.failed.suggestion" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,10 +34,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "상한 초과 안내 — 검색어를 좁히면 정확한 건수와 마지막 페이지를 볼 수 있다",
|
||||
"comment": "상한 초과 안내 — 검색어를 좁히면 정확한 건수와 마지막 페이지를 볼 수 있다. 검색 실패도 total_is_exact=false 를 싣지만(실패한 0건을 '정확한 0건'으로 말하지 않기 위함) 그때는 검색어를 좁혀도 해소되지 않으므로 이 조치 안내를 그리지 않는다",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{(searchResults?.data?.total_is_exact ?? true) === false}}",
|
||||
"if": "{{(searchResults?.data?.total_is_exact ?? true) === false && !(searchResults?.data?.search_failed ?? false)}}",
|
||||
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
|
||||
"text": "$t:search.refine_query_hint"
|
||||
}
|
||||
@@ -114,11 +114,40 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "전체 탭 검색 실패 — 결과가 0건이고 실패가 있으면 '결과 없음' 대신 오류 안내 (키 부재(구버전 코어)는 종전 렌더 유지)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{query?.q && searchResults?.data && (searchResults?.data?.total ?? 0) === 0 && (query?.type ?? 'all') === 'all' && (searchResults?.data?.search_failed ?? false)}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "mb-5 text-red-300 dark:text-red-500/60" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "4x" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": { "className": "text-lg font-medium text-gray-900 dark:text-white mb-2" },
|
||||
"text": "$t:search.failed.title"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
|
||||
"text": "$t:search.failed.suggestion"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "검색 결과 없음 (전체 탭)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{query?.q && searchResults?.data && (searchResults?.data?.total ?? 0) === 0 && (query?.type ?? 'all') === 'all'}}",
|
||||
"if": "{{query?.q && searchResults?.data && (searchResults?.data?.total ?? 0) === 0 && (query?.type ?? 'all') === 'all' && !(searchResults?.data?.search_failed ?? false)}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
|
||||
@@ -96,14 +96,33 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "결과 없음 안내",
|
||||
"comment": "결과 없음 안내 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!searchResults?.data?.pages?.items || searchResults?.data?.pages?.items?.length === 0}}",
|
||||
"if": "{{(!searchResults?.data?.pages?.items || searchResults?.data?.pages?.items?.length === 0) && !(searchResults?.data?.categories_failed?.pages ?? false)}}",
|
||||
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.empty.pages_in_all" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "검색 실패 안내",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{searchResults?.data?.categories_failed?.pages ?? false}}",
|
||||
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center justify-center gap-2" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "sm", "className": "text-red-500 dark:text-red-400" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.failed.title" }
|
||||
]
|
||||
},
|
||||
{ "type": "basic", "name": "P", "props": { "className": "mt-1 text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:search.failed.suggestion" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -138,14 +138,33 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "결과 없음 안내",
|
||||
"comment": "결과 없음 안내 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!searchResults?.data?.posts?.items || searchResults?.data?.posts?.items?.length === 0}}",
|
||||
"if": "{{(!searchResults?.data?.posts?.items || searchResults?.data?.posts?.items?.length === 0) && !(searchResults?.data?.categories_failed?.posts ?? false)}}",
|
||||
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.empty.posts_in_all" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "검색 실패 안내",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{searchResults?.data?.categories_failed?.posts ?? false}}",
|
||||
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center justify-center gap-2" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "sm", "className": "text-red-500 dark:text-red-400" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.failed.title" }
|
||||
]
|
||||
},
|
||||
{ "type": "basic", "name": "P", "props": { "className": "mt-1 text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:search.failed.suggestion" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -59,14 +59,33 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "결과 없음 안내",
|
||||
"comment": "결과 없음 안내 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!searchResults?.data?.products || searchResults?.data?.products?.length === 0}}",
|
||||
"if": "{{(!searchResults?.data?.products || searchResults?.data?.products?.length === 0) && !(searchResults?.data?.categories_failed?.products ?? false)}}",
|
||||
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.empty.products_in_all" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "검색 실패 안내",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{searchResults?.data?.categories_failed?.products ?? false}}",
|
||||
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center justify-center gap-2" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "sm", "className": "text-red-500 dark:text-red-400" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.failed.title" }
|
||||
]
|
||||
},
|
||||
{ "type": "basic", "name": "P", "props": { "className": "mt-1 text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:search.failed.suggestion" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* @file search-failed-state.test.tsx
|
||||
* @description 통합검색 실패 표면화 렌더링 테스트 (공개 이슈 #103)
|
||||
*
|
||||
* 카테고리 검색이 예외로 실패하면(`categories_failed` / `search_failed`) 화면은
|
||||
* "검색 결과가 없습니다" 가 아니라 "검색 중 오류" 안내를 그려야 한다.
|
||||
*
|
||||
* 실제 레이아웃 JSON 을 import 해서 렌더한다 — 손으로 쓴 조각만 검증하면 실파일이
|
||||
* 규약을 어겨도 green 인 채로 유출된다. 부재 단언은 baseline 케이스에서 그 문구의
|
||||
* 존재를 먼저 확정한 뒤에만 의미를 갖는다.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
createLayoutTest,
|
||||
screen,
|
||||
} from '@/core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
import { ComponentRegistry } from '@/core/template-engine/ComponentRegistry';
|
||||
|
||||
// 실제 레이아웃 JSON — failed 분기 회귀 고정
|
||||
import searchResultsPartial from '../../../layouts/partials/search/_search_results.json';
|
||||
import searchStatesPartial from '../../../layouts/partials/search/_search_states.json';
|
||||
import postsSectionPartial from '../../../layouts/partials/search/posts/_section.json';
|
||||
import productsSectionPartial from '../../../layouts/partials/search/products/_section.json';
|
||||
import pagesSectionPartial from '../../../layouts/partials/search/pages/_section.json';
|
||||
|
||||
// 실제 ko 언어 파일 — 문구 단언이 실키와 어긋나면 red
|
||||
import koSearch from '../../../lang/partial/ko/search.json';
|
||||
|
||||
// ========== 테스트용 컴포넌트 정의 ==========
|
||||
|
||||
const TestDiv: React.FC<{ className?: string; children?: React.ReactNode }> = ({ className, children }) => (
|
||||
<div className={className}>{children}</div>
|
||||
);
|
||||
const TestSpan: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({ children, text }) => (
|
||||
<span>{children ?? text}</span>
|
||||
);
|
||||
const TestP: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({ children, text }) => (
|
||||
<p>{children ?? text}</p>
|
||||
);
|
||||
const TestH3: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({ children, text }) => (
|
||||
<h3>{children ?? text}</h3>
|
||||
);
|
||||
const TestButton: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({ children, text }) => (
|
||||
<button type="button">{children ?? text}</button>
|
||||
);
|
||||
const TestIcon: React.FC<{ name?: string }> = ({ name }) => <i data-icon={name} />;
|
||||
const TestHtmlContent: React.FC<{ content?: string }> = ({ content }) => (
|
||||
<div dangerouslySetInnerHTML={{ __html: content ?? '' }} />
|
||||
);
|
||||
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
|
||||
|
||||
function setupTestRegistry(): ComponentRegistry {
|
||||
const registry = ComponentRegistry.getInstance();
|
||||
(registry as any).registry = {
|
||||
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
|
||||
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
|
||||
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
|
||||
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
|
||||
H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } },
|
||||
H4: { component: TestH3, metadata: { name: 'H4', type: 'basic' } },
|
||||
Ul: { component: TestDiv, metadata: { name: 'Ul', type: 'basic' } },
|
||||
Li: { component: TestDiv, metadata: { name: 'Li', type: 'basic' } },
|
||||
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
|
||||
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
|
||||
HtmlContent: { component: TestHtmlContent, metadata: { name: 'HtmlContent', type: 'composite' } },
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ========== partial 참조 인라인 해석 ==========
|
||||
|
||||
/**
|
||||
* 서버측 partial 해석을 테스트에서 재현합니다.
|
||||
*
|
||||
* `{ "partial": "..." }` 노드를 매핑된 실제 JSON 으로 치환하고, 매핑이 없는 참조는
|
||||
* 빈 Div 로 둡니다(이 테스트의 단언 대상 분기가 아니라는 뜻).
|
||||
*
|
||||
* @param node 레이아웃 JSON 노드
|
||||
* @param map partial 경로 => 실제 JSON
|
||||
* @returns 치환된 사본
|
||||
*/
|
||||
function resolvePartials(node: unknown, map: Record<string, unknown>): unknown {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((n) => resolvePartials(n, map));
|
||||
}
|
||||
if (!node || typeof node !== 'object') return node;
|
||||
const obj = node as Record<string, unknown>;
|
||||
if (typeof obj.partial === 'string') {
|
||||
const target = map[obj.partial];
|
||||
return target
|
||||
? resolvePartials(JSON.parse(JSON.stringify(target)), map)
|
||||
: { type: 'basic', name: 'Div' };
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
out[key] = resolvePartials(value, map);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PARTIAL_MAP: Record<string, unknown> = {
|
||||
'partials/search/posts/_section.json': postsSectionPartial,
|
||||
'partials/search/products/_section.json': productsSectionPartial,
|
||||
'partials/search/pages/_section.json': pagesSectionPartial,
|
||||
};
|
||||
|
||||
/**
|
||||
* 실제 partial JSON 을 렌더 가능한 레이아웃으로 감쌉니다.
|
||||
*
|
||||
* @param partial 레이아웃 partial JSON
|
||||
* @returns 레이아웃 정의
|
||||
*/
|
||||
function wrapLayout(partial: unknown) {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
layout_name: 'search_failed_probe',
|
||||
state: {},
|
||||
components: [resolvePartials(JSON.parse(JSON.stringify(partial)), PARTIAL_MAP)],
|
||||
};
|
||||
}
|
||||
|
||||
// ========== 데이터 헬퍼 ==========
|
||||
|
||||
const FAILED_TITLE = (koSearch as any).failed?.title ?? '$t:search.failed.title';
|
||||
|
||||
/**
|
||||
* 검색 응답 데이터를 만듭니다.
|
||||
*
|
||||
* @param overrides 덮어쓸 필드
|
||||
* @returns searchResults 데이터소스 값
|
||||
*/
|
||||
function searchData(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
searchResults: {
|
||||
data: {
|
||||
q: '문의',
|
||||
total: 0,
|
||||
posts_count: 0,
|
||||
products_count: 0,
|
||||
pages_count: 0,
|
||||
posts: { items: [] },
|
||||
products: [],
|
||||
pages: { items: [] },
|
||||
categories_failed: { posts: false, products: false, pages: false },
|
||||
search_failed: false,
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const RENDER_OPTIONS = (queryParams: Record<string, string>, data: Record<string, unknown>) => ({
|
||||
componentRegistry: ComponentRegistry.getInstance(),
|
||||
queryParams,
|
||||
initialData: data,
|
||||
translations: { search: koSearch },
|
||||
});
|
||||
|
||||
// ========== 테스트 케이스 ==========
|
||||
|
||||
describe('통합검색 실패 표면화 렌더링 (#103)', () => {
|
||||
beforeEach(() => {
|
||||
setupTestRegistry();
|
||||
});
|
||||
|
||||
describe('_search_results.json 카테고리 탭 분기', () => {
|
||||
const tabCases: Array<[string, string, string]> = [
|
||||
['posts', 'posts', (koSearch as any).empty.posts],
|
||||
['products', 'products', (koSearch as any).empty.products],
|
||||
['pages', 'pages', (koSearch as any).empty.pages],
|
||||
];
|
||||
|
||||
it.each(tabCases)(
|
||||
'%s 탭: 실패가 아니면 기존 "결과 없음" 문구가 렌더된다 (baseline)',
|
||||
async (_label, type, emptyText) => {
|
||||
const testUtils = createLayoutTest(wrapLayout(searchResultsPartial), RENDER_OPTIONS(
|
||||
{ q: '문의', type },
|
||||
searchData()
|
||||
));
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
// 존재 확정 — 이 문구가 없으면 아래 실패 케이스의 부재 단언이 무의미하다
|
||||
expect(screen.getByText(emptyText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FAILED_TITLE)).not.toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* @scenario category=posts, scope=category_tab, failure=single
|
||||
* @effects error_notice_instead_of_empty
|
||||
*/
|
||||
/**
|
||||
* @scenario category=products, scope=category_tab, failure=single
|
||||
* @effects error_notice_instead_of_empty
|
||||
*/
|
||||
/**
|
||||
* @scenario category=pages, scope=category_tab, failure=single
|
||||
* @effects error_notice_instead_of_empty
|
||||
*/
|
||||
it.each(tabCases)(
|
||||
'%s 탭: 카테고리 실패 시 "결과 없음" 대신 오류 안내가 렌더된다',
|
||||
async (_label, type, emptyText) => {
|
||||
const testUtils = createLayoutTest(wrapLayout(searchResultsPartial), RENDER_OPTIONS(
|
||||
{ q: '문의', type },
|
||||
searchData({
|
||||
categories_failed: { posts: false, products: false, pages: false, [type]: true },
|
||||
search_failed: true,
|
||||
})
|
||||
));
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.getByText(FAILED_TITLE)).toBeInTheDocument();
|
||||
expect(screen.queryByText(emptyText)).not.toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* @scenario category=posts, scope=category_tab, failure=all
|
||||
* @effects error_notice_instead_of_empty
|
||||
*/
|
||||
/**
|
||||
* @scenario category=products, scope=category_tab, failure=all
|
||||
* @effects error_notice_instead_of_empty
|
||||
*/
|
||||
/**
|
||||
* @scenario category=pages, scope=category_tab, failure=all
|
||||
* @effects error_notice_instead_of_empty
|
||||
*/
|
||||
it.each(tabCases)(
|
||||
'%s 탭: 전 카테고리 실패 시에도 그 탭이 오류 안내를 렌더한다',
|
||||
async (_label, type, emptyText) => {
|
||||
const testUtils = createLayoutTest(wrapLayout(searchResultsPartial), RENDER_OPTIONS(
|
||||
{ q: '문의', type },
|
||||
searchData({
|
||||
categories_failed: { posts: true, products: true, pages: true },
|
||||
search_failed: true,
|
||||
})
|
||||
));
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.getByText(FAILED_TITLE)).toBeInTheDocument();
|
||||
expect(screen.queryByText(emptyText)).not.toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('전체 탭 섹션 (partial 인라인 해석)', () => {
|
||||
const sectionCases: Array<[string, string, string]> = [
|
||||
// [실패 카테고리, 그 카테고리의 in_all 빈 문구 키, 정상 렌더를 확인할 다른 카테고리의 빈 문구 키]
|
||||
['posts', 'posts_in_all', 'pages_in_all'],
|
||||
['products', 'products_in_all', 'pages_in_all'],
|
||||
['pages', 'pages_in_all', 'posts_in_all'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @scenario category=posts, scope=all_tab, failure=single
|
||||
* @effects error_notice_instead_of_empty, unaffected_category_renders_normally
|
||||
*/
|
||||
/**
|
||||
* @scenario category=products, scope=all_tab, failure=single
|
||||
* @effects error_notice_instead_of_empty, unaffected_category_renders_normally
|
||||
*/
|
||||
/**
|
||||
* @scenario category=pages, scope=all_tab, failure=single
|
||||
* @effects error_notice_instead_of_empty, unaffected_category_renders_normally
|
||||
*/
|
||||
it.each(sectionCases)(
|
||||
'%s 섹션만 실패하면 그 섹션만 오류 안내를 그리고 다른 섹션은 정상 렌더된다',
|
||||
async (failedCategory, failedEmptyKey, normalEmptyKey) => {
|
||||
const testUtils = createLayoutTest(wrapLayout(searchResultsPartial), RENDER_OPTIONS(
|
||||
{ q: '문의', type: 'all' },
|
||||
searchData({
|
||||
total: 3,
|
||||
products_count: failedCategory === 'products' ? 0 : 3,
|
||||
categories_failed: { posts: false, products: false, pages: false, [failedCategory]: true },
|
||||
search_failed: true,
|
||||
})
|
||||
));
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
// 실패 섹션: 오류 안내 (in_all 빈 문구 대신)
|
||||
expect(screen.getByText(FAILED_TITLE)).toBeInTheDocument();
|
||||
expect(screen.queryByText((koSearch as any).empty[failedEmptyKey])).not.toBeInTheDocument();
|
||||
// 실패하지 않은 섹션은 종전대로 빈 문구를 그린다
|
||||
expect(screen.getByText((koSearch as any).empty[normalEmptyKey])).toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
}
|
||||
);
|
||||
|
||||
it('실패가 없으면 posts 섹션은 종전대로 빈 문구를 그린다 (baseline)', async () => {
|
||||
const testUtils = createLayoutTest(wrapLayout(searchResultsPartial), RENDER_OPTIONS(
|
||||
{ q: '문의', type: 'all' },
|
||||
searchData({ total: 3, products_count: 3 })
|
||||
));
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.getByText((koSearch as any).empty.posts_in_all)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FAILED_TITLE)).not.toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_search_states.json 전체 탭', () => {
|
||||
it('전체 탭 0건 + 실패 없음이면 "검색 결과가 없습니다" 를 그린다 (baseline)', async () => {
|
||||
const testUtils = createLayoutTest(wrapLayout(searchStatesPartial), RENDER_OPTIONS(
|
||||
{ q: '문의', type: 'all' },
|
||||
searchData()
|
||||
));
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.getByText((koSearch as any).empty.all)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FAILED_TITLE)).not.toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario category=posts, scope=all_tab, failure=all
|
||||
* @effects error_notice_instead_of_empty
|
||||
*/
|
||||
/**
|
||||
* @scenario category=products, scope=all_tab, failure=all
|
||||
* @effects error_notice_instead_of_empty
|
||||
*/
|
||||
/**
|
||||
* @scenario category=pages, scope=all_tab, failure=all
|
||||
* @effects error_notice_instead_of_empty
|
||||
*/
|
||||
it('전체 탭 0건 + 실패 존재면 "결과 없음" 대신 오류 안내를 그린다', async () => {
|
||||
const testUtils = createLayoutTest(wrapLayout(searchStatesPartial), RENDER_OPTIONS(
|
||||
{ q: '문의', type: 'all' },
|
||||
searchData({
|
||||
categories_failed: { posts: true, products: true, pages: true },
|
||||
search_failed: true,
|
||||
})
|
||||
));
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.getByText(FAILED_TITLE)).toBeInTheDocument();
|
||||
expect(screen.queryByText((koSearch as any).empty.all)).not.toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* 실패 페이로드는 "정확한 0건" 이라고 말하지 않으려고 total_is_exact=false 를 싣는다.
|
||||
* 그런데 그 값은 원래 "총 건수 상한 초과" 신호이기도 해서, 가드가 없으면 실패 화면에
|
||||
* "검색어를 더 구체적으로 입력하면 정확한 건수를 볼 수 있습니다" 라는 상한 초과용
|
||||
* 조치 안내가 오류 안내와 나란히 렌더된다 — 검색어를 좁혀도 서버 오류는 해소되지 않으므로
|
||||
* 사용자에게 잘못된 조치를 지시하게 된다.
|
||||
*
|
||||
* @scenario category=posts, scope=all_tab, failure=all
|
||||
* @effects error_notice_instead_of_empty
|
||||
*/
|
||||
it('전체 탭 실패 시 상한 초과용 "검색어를 좁히세요" 안내를 그리지 않는다', async () => {
|
||||
const refineHint = (koSearch as any).refine_query_hint;
|
||||
expect(refineHint).toBeTruthy();
|
||||
|
||||
// 존재 확정: 실패가 아닌 상한 초과 상황에서는 그 안내가 실제로 렌더된다.
|
||||
const exceeded = createLayoutTest(wrapLayout(searchStatesPartial), RENDER_OPTIONS(
|
||||
{ q: '문의', type: 'all' },
|
||||
searchData({ total: 10000, total_is_exact: false })
|
||||
));
|
||||
await exceeded.render();
|
||||
expect(screen.getByText(refineHint)).toBeInTheDocument();
|
||||
exceeded.cleanup();
|
||||
|
||||
// 실패 상황에서는 같은 안내가 사라져야 한다.
|
||||
const failed = createLayoutTest(wrapLayout(searchStatesPartial), RENDER_OPTIONS(
|
||||
{ q: '문의', type: 'all' },
|
||||
searchData({
|
||||
total_is_exact: false,
|
||||
categories_failed: { posts: true, products: true, pages: true },
|
||||
search_failed: true,
|
||||
})
|
||||
));
|
||||
await failed.render();
|
||||
|
||||
expect(screen.getByText(FAILED_TITLE)).toBeInTheDocument();
|
||||
expect(screen.queryByText(refineHint)).not.toBeInTheDocument();
|
||||
|
||||
failed.cleanup();
|
||||
});
|
||||
|
||||
it('실패 키가 없는 구버전 응답에서는 종전 렌더가 유지된다 (하위호환)', async () => {
|
||||
const data = searchData();
|
||||
delete (data.searchResults as any).data.categories_failed;
|
||||
delete (data.searchResults as any).data.search_failed;
|
||||
|
||||
const testUtils = createLayoutTest(wrapLayout(searchStatesPartial), RENDER_OPTIONS(
|
||||
{ q: '문의', type: 'all' },
|
||||
data
|
||||
));
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.getByText((koSearch as any).empty.all)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FAILED_TITLE)).not.toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 통합검색 카테고리 실패 표면화 — 브라우저 렌더 검증 (공개 이슈 #103)
|
||||
*
|
||||
* 배경:
|
||||
* - 카테고리 검색이 서버에서 예외로 실패하면 종전에는 HTTP 200 + "검색 결과가 없습니다"
|
||||
* 로 위장됐다. 수정 후에는 응답의 `categories_failed`/`search_failed` 를 근거로
|
||||
* 화면이 "검색 중 오류" 안내를 그려야 한다.
|
||||
* - 라이브 DB 에 임의 예외를 만들지 않는다 — `page.route` 로 실패 응답을 주입한다.
|
||||
* (인덱스 부재 축은 LIKE 폴백으로 흡수되므로 Chrome MCP 라이브 시나리오가 담당)
|
||||
*
|
||||
* 축 마킹은 아래 태그에 `키=값, 키=값` 한 줄로 적는다 — 매니페스트 대조기가 읽는
|
||||
* 형식이 그것뿐이라, 다른 이름의 태그에 적으면 조합이 조용히 0건으로 집계된다.
|
||||
* 설명문에도 그 태그 이름을 리터럴로 쓰지 않는다(docblock 의 첫 매치를 가로챈다).
|
||||
* 아래 파일 단위 마킹이 기본축이고, 개별 test 의 라인 마킹이 나머지 조합을 채운다.
|
||||
*
|
||||
* @scenario category=posts, scope=all_tab, failure=single
|
||||
* @effects failed_flag_in_response,
|
||||
* error_notice_instead_of_empty,
|
||||
* unaffected_category_renders_normally
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/** 실패 안내 문구 (ko/en — 사이트 로케일에 무관하게 매칭) */
|
||||
const FAILED_TITLE = /검색 중 오류가 발생했습니다|An error occurred while searching/;
|
||||
|
||||
/** "결과 없음" 문구 (ko/en) */
|
||||
const EMPTY_ALL = /^검색 결과가 없습니다\.$|^No results found\.$/;
|
||||
|
||||
/**
|
||||
* 검색 응답 데이터 골격을 만듭니다 (buildResponse 가 조립하는 실제 키 집합의 부분집합).
|
||||
*
|
||||
* @param overrides 덮어쓸 필드
|
||||
* @returns 응답 data 값
|
||||
*/
|
||||
function searchData(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
q: '문의',
|
||||
total: 0,
|
||||
all_count: 0,
|
||||
all_count_is_exact: true,
|
||||
total_relation: 'exact',
|
||||
total_is_exact: true,
|
||||
result_cap: 10000,
|
||||
posts_count: 0,
|
||||
products_count: 0,
|
||||
pages_count: 0,
|
||||
posts: { items: [], available_boards: [] },
|
||||
products: [],
|
||||
pages: { items: [] },
|
||||
counts_are_exact: { posts: true, products: true, pages: true },
|
||||
categories_failed: { posts: false, products: false, pages: false },
|
||||
search_failed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/search` 응답을 fixture 로 대체합니다.
|
||||
*
|
||||
* @param page Playwright 페이지
|
||||
* @param data 응답 data 값
|
||||
*/
|
||||
async function mockSearchResponse(page: Page, data: Record<string, unknown>): Promise<void> {
|
||||
await page.route('**/api/search**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true, message: 'ok', data }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('통합검색 카테고리 실패 표면화 (#103)', () => {
|
||||
test('posts 만 실패한 전체 탭 — posts 섹션은 오류 안내, 다른 카테고리는 정상 렌더', async ({ page }) => {
|
||||
await mockSearchResponse(page, searchData({
|
||||
total: 1,
|
||||
all_count: 1,
|
||||
pages_count: 1,
|
||||
pages: {
|
||||
items: [{
|
||||
id: 1,
|
||||
title: '이용약관',
|
||||
title_highlighted: '이용약관',
|
||||
content_preview: '본 약관은 문의 안내를 포함합니다',
|
||||
content_preview_highlighted: '본 약관은 문의 안내를 포함합니다',
|
||||
published_at: '2026-02-01',
|
||||
url: '/page/terms',
|
||||
}],
|
||||
},
|
||||
counts_are_exact: { posts: false, products: true, pages: true },
|
||||
categories_failed: { posts: true, products: false, pages: false },
|
||||
search_failed: true,
|
||||
}));
|
||||
|
||||
await page.goto('/search?q=문의');
|
||||
|
||||
// posts 섹션: 오류 안내가 렌더된다 ("결과 없음" 이 아니라)
|
||||
await expect(page.getByText(FAILED_TITLE).first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText(/게시글 검색 결과가 없습니다|No posts found/)).toHaveCount(0);
|
||||
|
||||
// 실패하지 않은 pages 카테고리는 결과를 정상 렌더한다
|
||||
// ('이용약관' 은 사이트 네비의 페이지 링크와도 매칭되므로 고유한 미리보기 문구로 단언)
|
||||
await expect(page.getByText('본 약관은 문의 안내를 포함합니다').first()).toBeVisible();
|
||||
});
|
||||
|
||||
// @scenario category=posts, scope=category_tab, failure=single
|
||||
// @effects failed_flag_in_response, error_notice_instead_of_empty
|
||||
test('posts 탭 실패 — 탭 화면이 "결과 없음" 대신 오류 안내를 그린다', async ({ page }) => {
|
||||
await mockSearchResponse(page, searchData({
|
||||
counts_are_exact: { posts: false, products: true, pages: true },
|
||||
categories_failed: { posts: true, products: false, pages: false },
|
||||
search_failed: true,
|
||||
}));
|
||||
|
||||
await page.goto('/search?q=문의&type=posts');
|
||||
|
||||
await expect(page.getByText(FAILED_TITLE).first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText(/게시글 검색 결과가 없습니다|No posts found/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// 이 한 케이스가 세 카테고리 동시 실패를 덮으므로 카테고리 축을 각각 마킹한다.
|
||||
// @scenario category=posts, scope=all_tab, failure=all
|
||||
// @effects failed_flag_in_response, error_notice_instead_of_empty
|
||||
// @scenario category=products, scope=all_tab, failure=all
|
||||
// @effects failed_flag_in_response, error_notice_instead_of_empty
|
||||
// @scenario category=pages, scope=all_tab, failure=all
|
||||
// @effects failed_flag_in_response, error_notice_instead_of_empty
|
||||
test('전 카테고리 실패한 전체 탭 — 전역 오류 안내를 그린다', async ({ page }) => {
|
||||
await mockSearchResponse(page, searchData({
|
||||
counts_are_exact: { posts: false, products: false, pages: false },
|
||||
categories_failed: { posts: true, products: true, pages: true },
|
||||
search_failed: true,
|
||||
}));
|
||||
|
||||
await page.goto('/search?q=문의');
|
||||
|
||||
await expect(page.getByText(FAILED_TITLE).first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText(EMPTY_ALL)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('실패 없는 0건 응답 — 종전 "결과 없음" 렌더가 유지된다 (회귀 가드)', async ({ page }) => {
|
||||
await mockSearchResponse(page, searchData());
|
||||
|
||||
await page.goto('/search?q=문의');
|
||||
|
||||
await expect(page.getByText(EMPTY_ALL).first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText(FAILED_TITLE)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
feature: 통합검색 카테고리 실패 표면화 (공개 이슈 #103)
|
||||
|
||||
description: |
|
||||
카테고리 검색이 서버 예외로 실패하면 종전에는 리스너 catch 가 결과 키를 설정하지
|
||||
않은 채 삼켜 HTTP 200 + "검색 결과가 없습니다" 로 위장됐다. 수정 후 계약:
|
||||
|
||||
- 서버: 실패 카테고리는 `SearchCategoryPayload::failed()` 페이로드(failed=true,
|
||||
total_is_exact=false)로 내보내고, 코어(PublicSearchController)가
|
||||
`categories_failed`/`search_failed` 를 counts_are_exact 와 동형으로 일괄 조립한다.
|
||||
예외 스택은 Log::error context 로 남는다.
|
||||
- FULLTEXT 인덱스 부재는 실패가 아니다 — 드라이버가 지원해도 대상 테이블·컬럼 조합을
|
||||
커버하는 인덱스가 없으면 MATCH 대신 LIKE 폴백으로 내려가고(1191 예방), 그 사실을
|
||||
테이블+컬럼 조합당 프로세스 1회 warning 으로 남긴다.
|
||||
- 화면(sirsoft-basic): `categories_failed`/`search_failed` 를 근거로 "검색 결과가
|
||||
없습니다" 와 구분되는 "검색 중 오류" 안내를 그린다. 키 부재(구버전 코어) 조합에서는
|
||||
`?? false` 기본값으로 종전 렌더를 유지한다.
|
||||
|
||||
axes:
|
||||
category: [posts, products, pages] # 실패 카테고리
|
||||
scope: [all_tab, category_tab] # 전체 탭 섹션 / 카테고리 탭 전면
|
||||
failure: [single, all] # 단일 카테고리 실패 / 전 카테고리 실패
|
||||
|
||||
effects:
|
||||
- failed_flag_in_response # 응답에 categories_failed / search_failed / 카테고리 failed 키
|
||||
- error_notice_instead_of_empty # "결과 없음" 대신 오류 안내 렌더
|
||||
- unaffected_category_renders_normally # 실패하지 않은 카테고리는 종전 렌더 유지
|
||||
- exception_stack_logged # 리스너 catch 가 exception 인스턴스를 Log::error context 로 남김
|
||||
- like_fallback_on_missing_index # 인덱스 부재 시 MATCH 대신 LIKE 폴백 (1191 예방)
|
||||
- missing_index_fallback_logged_once # 인덱스 부재 폴백 warning 이 조합당 1회
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Search/DatabaseFulltextEngineIndexGateTest.php
|
||||
- tests/Feature/Api/Public/PublicSearchControllerFailureFlagTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php
|
||||
- modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php
|
||||
- templates/_bundled/sirsoft-basic/src/__tests__/layouts/search-failed-state.test.tsx
|
||||
- templates/_bundled/sirsoft-basic/tests/Playwright/specs/search-category-failure.spec.ts
|
||||
|
||||
validation:
|
||||
audit_rule: test-scenario-coverage
|
||||
note: |
|
||||
axes cross product(3×2×2=12) 중 실제로 화면이 갈라지는 조합만 브라우저 계층이
|
||||
담당한다 — 카테고리 축의 실패 렌더 분기 구분은 Vitest(search-failed-state)가 3분기
|
||||
× (탭/섹션) 를 전수 커버하고, Playwright 는 (posts,all_tab,single) /
|
||||
(posts,category_tab,single) / (all_tab,all) + 회귀 가드(무실패)를 라이브 렌더로
|
||||
고정한다. products/pages 의 category_tab 실패 렌더는 같은 레이아웃 분기 구조의
|
||||
복제이므로 Vitest 계층이 등가 커버한다.
|
||||
|
||||
like_fallback_on_missing_index / missing_index_fallback_logged_once 는 DB 인덱스
|
||||
상태가 전제라 브라우저 fixture 로 재현하지 않는다 — PHPUnit(IndexGateTest) +
|
||||
Chrome MCP 라이브 매트릭스(인덱스 제거/복원 T4-a/T6)가 담당한다.
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Public;
|
||||
|
||||
use App\Enums\TotalRelation;
|
||||
use App\Extension\HookManager;
|
||||
use App\Search\SearchCategoryPayload;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 통합 검색 실패 표면화 계약 테스트 (공개 이슈 #103)
|
||||
*
|
||||
* 카테고리 검색이 예외로 실패하면 화면은 "검색 결과 없음" 과 구분되는 오류 안내를
|
||||
* 그려야 한다. 그 판정 근거인 `categories_failed` / `search_failed` 를 코어가
|
||||
* `counts_are_exact` 와 동형으로 **일괄 조립**하는 계약을 고정한다 — 모듈별 복사에
|
||||
* 맡기면 빠지는 카테고리가 생긴다.
|
||||
*
|
||||
* @scenario case=search_failure_flags
|
||||
*
|
||||
* @effects failed_flag_in_response,
|
||||
* failure_flags_default_to_false
|
||||
*/
|
||||
class PublicSearchControllerFailureFlagTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
HookManager::clearFilter('core.search.results');
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패한 카테고리가 categories_failed / search_failed 로 표면화되는지 확인
|
||||
*
|
||||
* @effects failed_flag_in_response
|
||||
*/
|
||||
public function test_failed_category_surfaces_in_response_flags(): void
|
||||
{
|
||||
// Given: 한 카테고리는 실패, 다른 하나는 정상인 검색 결과
|
||||
HookManager::addFilter('core.search.results', function (array $results): array {
|
||||
$results['alpha'] = SearchCategoryPayload::failed();
|
||||
$results['beta'] = [
|
||||
'total' => 3,
|
||||
'total_relation' => TotalRelation::Exact->value,
|
||||
'total_is_exact' => true,
|
||||
'items' => [],
|
||||
];
|
||||
|
||||
return $results;
|
||||
}, 5);
|
||||
|
||||
// When: 통합 검색을 호출
|
||||
$response = $this->getJson('/api/search?q=테스트');
|
||||
|
||||
// Then: HTTP 200 이되 실패가 응답 키로 구분된다
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data.categories_failed.alpha', true);
|
||||
$response->assertJsonPath('data.categories_failed.beta', false);
|
||||
$response->assertJsonPath('data.search_failed', true);
|
||||
// 실패한 0건은 "정확한 0건" 이 아니다
|
||||
$response->assertJsonPath('data.counts_are_exact.alpha', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패가 없으면 두 키 모두 false 로 존재하는지 확인
|
||||
*
|
||||
* 키 자체가 없으면 화면이 "실패 정보 없음(구버전)" 과 "실패 없음" 을 구분하지 못한다.
|
||||
*
|
||||
* @effects failure_flags_default_to_false
|
||||
*/
|
||||
public function test_flags_default_to_false_when_no_failure(): void
|
||||
{
|
||||
// Given: 정상 카테고리만 있는 검색 결과
|
||||
HookManager::addFilter('core.search.results', function (array $results): array {
|
||||
$results['alpha'] = [
|
||||
'total' => 7,
|
||||
'total_relation' => TotalRelation::Exact->value,
|
||||
'total_is_exact' => true,
|
||||
'items' => [],
|
||||
];
|
||||
|
||||
return $results;
|
||||
}, 5);
|
||||
|
||||
// When: 통합 검색을 호출
|
||||
$response = $this->getJson('/api/search?q=테스트');
|
||||
|
||||
// Then: 실패 키는 존재하되 전부 false
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data.categories_failed.alpha', false);
|
||||
$response->assertJsonPath('data.search_failed', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Search;
|
||||
|
||||
use App\Search\Contracts\FulltextSearchable;
|
||||
use App\Search\Engines\DatabaseFulltextEngine;
|
||||
use App\Search\KeywordSearch;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Laravel\Scout\Builder;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* FULLTEXT 인덱스 부재 게이트 검증 (공개 이슈 #103)
|
||||
*
|
||||
* 회귀 배경: `applyFulltextPredicate()` / `performSearch()` 의 게이트가 드라이버명만
|
||||
* 검사해, MySQL 인데 대상 테이블에 FULLTEXT 인덱스가 없는 설치(부분 실패 설치 등)에서
|
||||
* `1191 Can't find FULLTEXT index` 가 발생했다. 그 오류는 리스너의 catch 에 삼켜져
|
||||
* 화면에는 "검색 결과 0건" 으로만 나타났다.
|
||||
*
|
||||
* 수정 후 계약: 드라이버가 지원해도 대상 테이블·컬럼 조합을 커버하는 FULLTEXT 인덱스가
|
||||
* 없으면 LIKE 로 내려가고, 그 사실을 조합당 프로세스 1회 경고로 남긴다.
|
||||
*/
|
||||
class DatabaseFulltextEngineIndexGateTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 프리픽스 미포함 테이블명 — FULLTEXT 인덱스를 만들지 않는 probe 테이블.
|
||||
*/
|
||||
private const TABLE = 'ft_gate_fixtures';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
if (! DatabaseFulltextEngine::supportsFulltext()) {
|
||||
$this->markTestSkipped('FULLTEXT 를 제공하지 않는 DBMS 입니다.');
|
||||
}
|
||||
|
||||
DatabaseFulltextEngine::forgetFulltextIndexCatalog();
|
||||
|
||||
Schema::dropIfExists(self::TABLE);
|
||||
Schema::create(self::TABLE, function ($table): void {
|
||||
$table->id();
|
||||
$table->string('title');
|
||||
$table->text('content');
|
||||
});
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Schema::dropIfExists(self::TABLE);
|
||||
DatabaseFulltextEngine::forgetFulltextIndexCatalog();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* T1-a: 인덱스가 없는 테이블은 MATCH 대신 LIKE 폴백으로 조립되어야 한다.
|
||||
*
|
||||
* 수정 전에는 드라이버만 검사해 MATCH 가 조립됐다 (실행 시 1191).
|
||||
*
|
||||
* @effects like_fallback_on_missing_index
|
||||
*/
|
||||
public function test_like_fallback_when_table_has_no_fulltext_index(): void
|
||||
{
|
||||
$query = FulltextGateFixture::query();
|
||||
|
||||
KeywordSearch::apply($query, ['title', 'content'], '문의');
|
||||
|
||||
$sql = strtolower($query->toSql());
|
||||
|
||||
$this->assertStringNotContainsString('match(', $sql, '인덱스 없는 테이블에 MATCH 가 조립되면 실행 시 1191 이 됩니다.');
|
||||
$this->assertStringContainsString('like', $sql, 'LIKE 폴백이 조립되어야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* T1-b: 복합 인덱스는 단일 컬럼 MATCH 를 커버하지 못한다 (집합 동등 비교, 순서 무관).
|
||||
*/
|
||||
public function test_composite_index_does_not_cover_single_column(): void
|
||||
{
|
||||
DatabaseFulltextEngine::primeFulltextIndexCatalog([
|
||||
'board_posts' => [['title', 'content']],
|
||||
]);
|
||||
|
||||
$this->assertFalse(
|
||||
DatabaseFulltextEngine::fulltextIndexCoversColumns('board_posts', ['title']),
|
||||
'복합 (title,content) 인덱스는 MATCH(title) 단독을 커버하지 못합니다.'
|
||||
);
|
||||
$this->assertTrue(
|
||||
DatabaseFulltextEngine::fulltextIndexCoversColumns('board_posts', ['title', 'content']),
|
||||
'컬럼 집합이 정확히 일치하면 커버합니다.'
|
||||
);
|
||||
$this->assertTrue(
|
||||
DatabaseFulltextEngine::fulltextIndexCoversColumns('board_posts', ['content', 'title']),
|
||||
'집합 비교는 순서 무관이어야 합니다 — 깨지면 정상 경로가 LIKE 로 강등됩니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* T1-c: Scout 경로(performSearch)는 컬럼마다 단일 MATCH 를 만들므로,
|
||||
* 각 컬럼이 개별 인덱스로 커버되지 않으면 LIKE 로 내려가야 한다.
|
||||
*/
|
||||
public function test_scout_search_falls_back_when_columns_lack_individual_indexes(): void
|
||||
{
|
||||
// 복합 인덱스만 존재하는 상황을 시드 — per-column MATCH 는 커버되지 않는다.
|
||||
DatabaseFulltextEngine::primeFulltextIndexCatalog([
|
||||
self::TABLE => [['title', 'content']],
|
||||
]);
|
||||
|
||||
$engine = new DatabaseFulltextEngine;
|
||||
$results = $engine->paginate(new Builder(new FulltextGateFixture, '문의'), 10, 1);
|
||||
|
||||
$this->assertNotNull($results['query']);
|
||||
|
||||
$sql = strtolower($results['query']->toSql());
|
||||
|
||||
$this->assertStringNotContainsString('match(', $sql, 'per-column MATCH 는 복합 인덱스로 커버되지 않으므로 조립되면 안 됩니다.');
|
||||
$this->assertStringContainsString('like', $sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* T1-f: 두 게이트의 LIKE 폴백이 같은 술어를 만든다 — 검색어의 `%`/`_` 는
|
||||
* 와일드카드가 아니라 글자로 취급되어야 한다.
|
||||
*
|
||||
* 게이트 ①(applyFulltextPredicate)은 `KeywordSearch::applyLikeMatch()` 로 내려가
|
||||
* escape 를 거치는데, 게이트 ②(performSearch)가 LIKE 를 손으로 조립하면 같은
|
||||
* "부분일치" 인데도 `100%` 같은 검색어에서 결과가 갈린다. 게이트 ② 신설로
|
||||
* MySQL 트래픽이 처음 이 경로에 유입되므로 두 경로의 술어를 여기서 고정한다.
|
||||
*
|
||||
* @effects like_fallback_on_missing_index
|
||||
*/
|
||||
public function test_scout_like_fallback_escapes_wildcards_like_the_shared_path(): void
|
||||
{
|
||||
$keyword = '100%_할인';
|
||||
$expected = '%'.KeywordSearch::escapeLikeWildcards($keyword).'%';
|
||||
|
||||
// 게이트 ① — 공용 경로. 이 경로가 escape 된 패턴을 만든다는 것을 먼저 확정한다.
|
||||
$shared = FulltextGateFixture::query();
|
||||
KeywordSearch::apply($shared, ['title', 'content'], $keyword);
|
||||
|
||||
$this->assertContains(
|
||||
$expected,
|
||||
$shared->getBindings(),
|
||||
'공용 폴백 경로가 escape 된 패턴을 만들지 않으면 이 테스트의 기준값이 무의미합니다.'
|
||||
);
|
||||
|
||||
// 게이트 ② — Scout 경로. 복합 인덱스만 시드해 per-column MATCH 를 미커버로 만든다.
|
||||
DatabaseFulltextEngine::primeFulltextIndexCatalog([
|
||||
self::TABLE => [['title', 'content']],
|
||||
]);
|
||||
|
||||
$engine = new DatabaseFulltextEngine;
|
||||
$results = $engine->paginate(new Builder(new FulltextGateFixture, $keyword), 10, 1);
|
||||
|
||||
$this->assertNotNull($results['query']);
|
||||
|
||||
$this->assertContains(
|
||||
$expected,
|
||||
$results['query']->getBindings(),
|
||||
'Scout 폴백이 % 와 _ 를 escape 하지 않으면 검색어가 와일드카드로 해석되어 공용 경로와 결과가 갈립니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* T1-d: 인덱스 부재 폴백 경고는 테이블+컬럼 조합당 프로세스 1회만 남는다.
|
||||
*
|
||||
* @effects missing_index_fallback_logged_once
|
||||
*/
|
||||
public function test_missing_index_fallback_is_logged_once_per_table_and_columns(): void
|
||||
{
|
||||
Log::spy();
|
||||
|
||||
$apply = function (array $columns): void {
|
||||
$query = FulltextGateFixture::query();
|
||||
KeywordSearch::apply($query, $columns, '문의');
|
||||
};
|
||||
|
||||
$apply(['title', 'content']);
|
||||
$apply(['title', 'content']);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message) => str_contains($message, 'FULLTEXT'))
|
||||
->once();
|
||||
|
||||
$apply(['title']);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message) => str_contains($message, 'FULLTEXT'))
|
||||
->twice();
|
||||
}
|
||||
|
||||
/**
|
||||
* T1-e: addFulltextIndex 성공 직후 카탈로그가 무효화되어, 새 인덱스가
|
||||
* forget 호출 없이도 다음 판정에 반영되어야 한다.
|
||||
*/
|
||||
public function test_add_fulltext_index_invalidates_catalog(): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
DatabaseFulltextEngine::fulltextIndexCoversColumns(self::TABLE, ['title']),
|
||||
'probe 테이블은 인덱스 없이 생성되므로 커버되지 않아야 합니다 (카탈로그 적재 확인).'
|
||||
);
|
||||
|
||||
DatabaseFulltextEngine::addFulltextIndex(self::TABLE, 'ft_'.self::TABLE.'_title', 'title');
|
||||
|
||||
$this->assertTrue(
|
||||
DatabaseFulltextEngine::fulltextIndexCoversColumns(self::TABLE, ['title']),
|
||||
'DDL 성공 직후 카탈로그가 무효화되어 새 인덱스가 반영되어야 합니다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 게이트 테스트 전용 픽스처 모델 (FULLTEXT 인덱스 없는 테이블).
|
||||
*/
|
||||
class FulltextGateFixture extends Model implements FulltextSearchable
|
||||
{
|
||||
use Searchable;
|
||||
|
||||
protected $table = 'ft_gate_fixtures';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function searchableColumns(): array
|
||||
{
|
||||
return ['title', 'content'];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function searchableWeights(): array
|
||||
{
|
||||
return ['title' => 3.0, 'content' => 1.0];
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,20 @@ class DatabaseFulltextEngineTest extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
$this->engine = new DatabaseFulltextEngine;
|
||||
|
||||
// 픽스처 테이블(test_table)은 실존하지 않으므로 인덱스 카탈로그를 시드해
|
||||
// MATCH 조립 경로를 검증한다 — 시드하지 않으면 인덱스 부재 게이트(#103)가
|
||||
// LIKE 로 내려 보내 조립 단언이 무의미해진다.
|
||||
DatabaseFulltextEngine::primeFulltextIndexCatalog([
|
||||
'test_table' => [['name']],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
DatabaseFulltextEngine::forgetFulltextIndexCatalog();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
|
||||
use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 언어팩 업데이트 확인의 순회 범위 계약 테스트 (공개 이슈 #102 동형)
|
||||
*
|
||||
* 종전에는 `repository->paginate([], 1000)` 로 전량 순회를 의도했는데, page 인자를
|
||||
* 지정하지 않아 HTTP `page` 파라미터가 암묵 해석됐다. `?page=2` 상태로 업데이트 확인을
|
||||
* 호출하면 offset=1000 이 적용되어 `checked: 0` 의 조용한 오답이 됐다.
|
||||
*
|
||||
* 수정 후 계약: 업데이트 확인은 요청의 page 파라미터와 무관하게 설치된 전체 팩을 순회한다.
|
||||
*
|
||||
* @scenario case=update_check_scope
|
||||
*
|
||||
* @effects update_check_ignores_request_page
|
||||
*/
|
||||
class LanguagePackServiceUpdateCheckScopeTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* 요청에 page=2 가 실려 있어도 순회 대상이 설치된 전체 팩인지 확인
|
||||
*
|
||||
* 순회 계층에서 건수를 정확히 단언한다 — 서비스 `checked` 는 GitHub URL 도 번들
|
||||
* manifest 도 없는 팩을 설계상 건너뛰므로 설치본 수와 같지 않고, 그 판정을 테스트가
|
||||
* 재구현하면 규칙이 두 벌이 되어 한쪽만 바뀐다. 결함이 있던 자리는 순회 그 자체다.
|
||||
*/
|
||||
public function test_update_check_traverses_all_packs_regardless_of_request_page(): void
|
||||
{
|
||||
// Given: bundled manifest 가 실존하는 설치 팩 1건 (GitHub 미설정 → bundled 폴백, 네트워크 없음)
|
||||
LanguagePack::query()->firstOrCreate(
|
||||
['identifier' => 'g7-core-ja'],
|
||||
[
|
||||
'vendor' => 'g7',
|
||||
'scope' => 'core',
|
||||
'target_identifier' => 'core',
|
||||
'locale' => 'ja',
|
||||
'locale_name' => 'Japanese',
|
||||
'locale_native_name' => '日本語',
|
||||
'text_direction' => 'ltr',
|
||||
'version' => '0.0.1',
|
||||
'license' => 'MIT',
|
||||
'status' => 'active',
|
||||
'source_type' => 'bundled',
|
||||
'manifest' => ['identifier' => 'g7-core-ja', 'version' => '0.0.1'],
|
||||
]
|
||||
);
|
||||
|
||||
$installed = LanguagePack::query()->count();
|
||||
$this->assertGreaterThan(0, $installed, '설치된 팩이 없어 순회 범위를 측정할 수 없습니다.');
|
||||
|
||||
// 페이지네이터의 현재 페이지 암묵 해석이 읽는 자리 — #102 재현 조건
|
||||
request()->merge(['page' => 2]);
|
||||
|
||||
$traversed = app(LanguagePackRepositoryInterface::class)->allForUpdateCheck();
|
||||
|
||||
$this->assertCount(
|
||||
$installed,
|
||||
$traversed,
|
||||
"page=2 요청 상태에서 순회 대상이 {$traversed->count()} 건이었습니다 (설치 {$installed} 건) — 페이지네이션 암묵 page 해석(#102 동형)입니다."
|
||||
);
|
||||
|
||||
// 서비스 경로도 같은 순회를 타는지 — 종단에서 0건 오답이 재발하지 않음을 고정
|
||||
$result = app(LanguagePackService::class)->checkUpdates();
|
||||
|
||||
$this->assertGreaterThan(
|
||||
0,
|
||||
$result['checked'],
|
||||
'업데이트 확인이 0건 순회했습니다 — 순회는 정상인데 서비스가 다른 경로를 쓰고 있습니다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Upgrades\Data\V7_0_6\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
@@ -20,20 +21,29 @@ use Illuminate\Support\Facades\Schema;
|
||||
* 대기합니다. 새 색인을 먼저 만들고 기존 색인을 나중에 지우므로, 중간에 중단되어도 조회가
|
||||
* 색인 없이 남는 구간은 없습니다.
|
||||
*
|
||||
* idempotent: 이미 교체된 대상은 건너뜁니다. V-1 안전: Facades\Schema 만 사용합니다.
|
||||
* idempotent: 이미 교체된 대상은 건너뜁니다. V-1 안전: Facades\Schema / Facades\DB 만 사용합니다.
|
||||
*/
|
||||
class AddTiebreakToCoreListIndexes implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 대상 [테이블 => [신규 색인명, 컬럼, 교체 대상 기존 색인명(없으면 null)]]
|
||||
* 대상을 반환합니다: [테이블 => [신규 색인명, 컬럼, 교체 대상 기존 색인명(없으면 null)]]
|
||||
*
|
||||
* @var array<string, array{0: string, 1: array<int, string>, 2: string|null}>
|
||||
* activity_logs 의 기존 색인은 Laravel 자동 색인명(접두사 포함 테이블명에서 파생)이라
|
||||
* 리터럴 `g7_` 을 박으면 다른 접두사 설치에서 조용한 no-op(중복 색인 잔존)이 된다 —
|
||||
* `DB::getTablePrefix()` 로 동적 조립한다.
|
||||
*
|
||||
* @return array<string, array{0: string, 1: array<int, string>, 2: string|null}>
|
||||
*/
|
||||
private const TARGETS = [
|
||||
'activity_logs' => ['idx_activity_logs_created_id', ['created_at', 'id'], 'g7_activity_logs_created_at_index'],
|
||||
'notification_logs' => ['idx_notification_logs_created_id', ['created_at', 'id'], null],
|
||||
'users' => ['idx_users_created_id', ['created_at', 'id'], 'idx_users_created_at'],
|
||||
];
|
||||
private function targets(): array
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
return [
|
||||
'activity_logs' => ['idx_activity_logs_created_id', ['created_at', 'id'], $prefix.'activity_logs_created_at_index'],
|
||||
'notification_logs' => ['idx_notification_logs_created_id', ['created_at', 'id'], null],
|
||||
'users' => ['idx_users_created_id', ['created_at', 'id'], 'idx_users_created_at'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자를 반환합니다.
|
||||
@@ -52,7 +62,7 @@ class AddTiebreakToCoreListIndexes implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
foreach (self::TARGETS as $table => [$newIndex, $columns, $oldIndex]) {
|
||||
foreach ($this->targets() as $table => [$newIndex, $columns, $oldIndex]) {
|
||||
if (! Schema::hasTable($table)) {
|
||||
$context->logger->info("[core:7.0.6] 테이블 부재 — 색인 교체 스킵: {$table}");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user