diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a800287..ab93fc0c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,9 @@
### Fixed
+- FULLTEXT 인덱스가 없는 테이블을 검색하면 오류 대신 부분일치 검색으로 자동 전환되고, 그 사실이 기록으로 남습니다. 이전에는 오류가 화면에 드러나지 않아 "검색 결과 0건" 으로만 보였습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 통합검색에서 일부 항목의 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록, 항목별 실패 여부를 응답에 실어 내립니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 언어팩 업데이트 확인이 특정 조건(요청에 페이지 이동 파라미터가 실려 있는 경우)에서 아무 팩도 확인하지 않고 끝나던 문제를 수정했습니다. (#102 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 검색엔진 플러그인을 선택해 저장한 뒤 그 플러그인을 삭제하면 사이트 검색이 오류로 멈추던 문제를 수정했습니다. 이제 다른 드라이버 설정과 동일하게 기본 검색엔진으로 자동 복귀합니다.
- 설정을 항목 단위로 저장할 때 전체 저장과 다른 처리를 거치던 문제를 수정했습니다. 자산 주소 방식을 바꾸면 SEO 미리 생성 캐시가, 드라이버를 바꾸면 백그라운드 작업이 각각 갱신되지 않았습니다. SEO 설정도 항목 단위 저장에서 캐시가 정리되지 않아 검색엔진에 예전 정보가 남았습니다. (#114 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 모듈 환경설정(쇼핑몰·게시판)을 저장해도 SEO 미리 생성 캐시가 갱신되지 않던 문제를 수정했습니다. 함께, 모듈 설정 변경이 활동 로그에 기록됩니다.
diff --git a/README.ko.md b/README.ko.md
index 7ed5fe4c..06a43295 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -514,8 +514,8 @@ cp .env.example .env
-
+
diff --git a/README.md b/README.md
index 1f9e8479..3410c40b 100644
--- a/README.md
+++ b/README.md
@@ -528,8 +528,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
-
+
diff --git a/app/Contracts/Repositories/LanguagePackRepositoryInterface.php b/app/Contracts/Repositories/LanguagePackRepositoryInterface.php
index accb74c2..76ed8893 100644
--- a/app/Contracts/Repositories/LanguagePackRepositoryInterface.php
+++ b/app/Contracts/Repositories/LanguagePackRepositoryInterface.php
@@ -72,13 +72,24 @@ interface LanguagePackRepositoryInterface
public function getActiveCoreLocales(): array;
/**
- * 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
+ * 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
+ *
+ * 전량 순회 용도로는 쓰지 않는다 — page 를 생략하면 HTTP `page` 파라미터가 암묵
+ * 해석되어, 무관한 요청 파라미터가 순회 범위를 바꾼다({@see self::allForUpdateCheck()}).
*
* @param array $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 설치된 전체 언어팩
+ */
+ public function allForUpdateCheck(): Collection;
/**
* 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
diff --git a/app/Http/Controllers/Api/Public/PublicSearchController.php b/app/Http/Controllers/Api/Public/PublicSearchController.php
index 5bcf8cce..ab9d6835 100644
--- a/app/Http/Controllers/Api/Public/PublicSearchController.php
+++ b/app/Http/Controllers/Api/Public/PublicSearchController.php
@@ -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 카테고리 => 실패 여부
+ */
+ private function resolveCategoryFailures(array $results): array
+ {
+ $failures = [];
+
+ foreach ($results as $category => $categoryData) {
+ $failures[$category] = ($categoryData['failed'] ?? false) === true;
+ }
+
+ return $failures;
+ }
+
/**
* 응답 전체의 총 건수 정확도를 정합니다.
*
diff --git a/app/Repositories/LanguagePackRepository.php b/app/Repositories/LanguagePackRepository.php
index 426408b1..81ae1879 100644
--- a/app/Repositories/LanguagePackRepository.php
+++ b/app/Repositories/LanguagePackRepository.php
@@ -125,16 +125,33 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
}
/**
- * 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
+ * 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
*
* @param array $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 설치된 전체 언어팩
+ */
+ public function allForUpdateCheck(): Collection
+ {
+ // audit:allow query-unbounded-get reason: 언어팩은 운영자가 설치한 팩 수만큼만 존재하는
+ // 설정성 테이블이다 (사용량과 무관) — CLAUDE.md pagination 예외 조항
+ return LanguagePack::query()->orderBy('identifier')->get();
}
/**
diff --git a/app/Search/Engines/DatabaseFulltextEngine.php b/app/Search/Engines/DatabaseFulltextEngine.php
index 77f7da54..de02d2a5 100644
--- a/app/Search/Engines/DatabaseFulltextEngine.php
+++ b/app/Search/Engines/DatabaseFulltextEngine.php
@@ -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>>|null
+ */
+ private static ?array $fulltextIndexCatalog = null;
+
+ /**
+ * 인덱스 부재 폴백 경고를 조합당 한 번만 남기기 위한 기록
+ *
+ * @var array
+ */
+ 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 $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>>|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 $columns 컬럼명 목록 (`posts.title` 형태 허용)
+ * @return array 정규화된 컬럼 집합
+ */
+ 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 소문자 조회 키 후보
+ */
+ 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>> $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 $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);
diff --git a/app/Search/FulltextIndexInspector.php b/app/Search/FulltextIndexInspector.php
index b62a48ad..c1ba571d 100644
--- a/app/Search/FulltextIndexInspector.php
+++ b/app/Search/FulltextIndexInspector.php
@@ -93,6 +93,48 @@ class FulltextIndexInspector
return $definitions;
}
+ /**
+ * 현재 스키마의 FULLTEXT 인덱스 컬럼 집합을 테이블별로 수집합니다.
+ *
+ * `discover()` 와 같은 INFORMATION_SCHEMA 조회를 쓰되, 인덱스마다 `SHOW CREATE TABLE`
+ * 을 도는 파서 해석은 하지 않는 경량판입니다 — 커버 판정에는 컬럼 집합만 필요합니다.
+ *
+ * 반환 키는 프리픽스를 제거한 테이블명입니다. `Model::getTable()` 이 프리픽스 미포함
+ * 이름을 돌려주므로, 판정하는 쪽이 그 이름으로 바로 조회할 수 있게 맞춥니다.
+ *
+ * @return array>> 테이블명 => 인덱스별 컬럼 집합 목록
+ */
+ 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;
+ }
+
/**
* 인덱스 하나의 건강도를 판정합니다.
*
diff --git a/app/Search/SearchCategoryPayload.php b/app/Search/SearchCategoryPayload.php
index 2a72fc16..4a877025 100644
--- a/app/Search/SearchCategoryPayload.php
+++ b/app/Search/SearchCategoryPayload.php
@@ -80,6 +80,33 @@ final class SearchCategoryPayload
], $extra);
}
+ /**
+ * 카테고리 검색이 예외로 실패했을 때의 페이로드를 만듭니다.
+ *
+ * 실패한 0건을 "정확한 0건" 으로 말하지 않는다 — `total_is_exact=false` 로 내보내
+ * 배지가 정확한 값처럼 그려지는 것을 막고, `failed` 플래그로 화면이 "결과 없음" 과
+ * 구분되는 오류 안내를 그릴 수 있게 한다. 키 집합은 다른 팩토리와 동일하게 유지해
+ * 화면이 분기 없이 읽게 한다.
+ *
+ * @param array $extra 도메인 고유 필드 (available_boards 등)
+ * @return array 카테고리 페이로드
+ */
+ 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);
+ }
+
/**
* 목록 없이 건수만 필요한 자리(비활성 탭 배지)의 페이로드를 만듭니다.
*
diff --git a/app/Services/LanguagePackService.php b/app/Services/LanguagePackService.php
index 6a267f58..9b87bba2 100644
--- a/app/Services/LanguagePackService.php
+++ b/app/Services/LanguagePackService.php
@@ -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)) {
diff --git a/app/Support/Query/BoundedPaginator.php b/app/Support/Query/BoundedPaginator.php
index 229966ea..e310e247 100644
--- a/app/Support/Query/BoundedPaginator.php
+++ b/app/Support/Query/BoundedPaginator.php
@@ -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
diff --git a/docs/backend/api/search.md b/docs/backend/api/search.md
index c3666f8a..d8bc09d6 100644
--- a/docs/backend/api/search.md
+++ b/docs/backend/api/search.md
@@ -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)
**응답 예시**
diff --git a/docs/backend/search-system.md b/docs/backend/search-system.md
index b9f0c8ed..f3b7611f 100644
--- a/docs/backend/search-system.md
+++ b/docs/backend/search-system.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`에서 드라이버를 변경하면 즉시 적용됩니다:
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md
index 02659028..6971151b 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md
@@ -9,6 +9,9 @@
### Added
- 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다.
+- 결제 취소에 필요한 서버 IP 등록 안내의 일본어 번역을 추가했습니다 — 등록이 필요한 이유, 상점 관리자에서의 등록 경로, 등록할 IP 확인·복사 안내가 일본어로 표시됩니다.
+- 관리자 결제 상세의 결제 상태·취소 금액·취소 일시·환불 상태·환불 번호 항목명과, 취소 완료·부분 취소 상태 표시의 일본어 번역을 추가했습니다.
+- 가상계좌 입금 통보 주소의 등록 경로 안내와 간편결제 표시 설정 항목명의 일본어 번역을 추가했습니다.
## [1.0.2] - 2026-08-10
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json
index 80e17850..7e1e38c4 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json
@@ -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": "簡便決済"
diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md
index ad368308..89af5dde 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md
@@ -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
diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/search.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/search.json
index c4e9132a..d99323af 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/search.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/search.json
@@ -38,6 +38,10 @@
"pages_in_all": "ページの検索結果がありません",
"suggestion": "別の検索キーワードで試してみてください"
},
+ "failed": {
+ "title": "検索中にエラーが発生しました",
+ "suggestion": "しばらくしてからもう一度お試しください"
+ },
"page_published_at": "公開日:{{date}}",
"view_more": "さらに表示",
"view_all": "すべて表示",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json
index 450ac498..70dc6acc 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json
@@ -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",
diff --git a/modules/_bundled/gnuboard7-hello_module/tests/ModuleTestCase.php b/modules/_bundled/gnuboard7-hello_module/tests/ModuleTestCase.php
index a969f0b7..b04e4557 100644
--- a/modules/_bundled/gnuboard7-hello_module/tests/ModuleTestCase.php
+++ b/modules/_bundled/gnuboard7-hello_module/tests/ModuleTestCase.php
@@ -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);
}
}
}
diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 5b718344..a3c7174b 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -26,6 +26,8 @@
### Fixed
+- 통합검색에서 게시글 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 샘플 게시글 생성 도구가 데이터베이스 접두어를 기본값이 아닌 값으로 설치한 사이트에서 오류가 나던 문제를 수정했습니다. (#101 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 관리자 게시판 설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 정보가 계속 노출되던 문제를 수정했습니다. 환경설정 기본값 일괄 적용에도 같은 처리가 적용됩니다.
- 일부 설정 저장 경로가 다른 저장 경로와 다른 처리를 거쳐, 저장 직후 조회가 저장 전 값을 돌려주거나 값의 형식이 정리되지 않은 채 남던 문제를 수정했습니다.
- 게시판 유형 삭제가 실패했을 때 내부 오류 원문이 그대로 표시되던 문제를 수정했습니다. 이제 사용 중인 유형·기본 유형이라 삭제할 수 없는 경우에는 그 사유가 안내되고, 그 밖의 오류는 서버 오류로 구분됩니다.
diff --git a/modules/_bundled/sirsoft-board/database/seeders/Sample/PostSampleSeeder.php b/modules/_bundled/sirsoft-board/database/seeders/Sample/PostSampleSeeder.php
index df014f21..d95db723 100644
--- a/modules/_bundled/sirsoft-board/database/seeders/Sample/PostSampleSeeder.php
+++ b/modules/_bundled/sirsoft-board/database/seeders/Sample/PostSampleSeeder.php
@@ -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[] = [
diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php
index d0400174..0c2574b3 100644
--- a/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php
+++ b/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php
@@ -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;
diff --git a/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php b/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php
index 6c6890a0..d3eb3485 100644
--- a/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php
+++ b/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php
@@ -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;
}
});
}
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php
index 35e3724c..9b242f06 100644
--- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php
@@ -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 스텁 생성
*
diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
index 03d6203b..b7f7c879 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -31,6 +31,11 @@
- 카테고리를 삭제해도 그 카테고리의 이미지 파일이 서버에 그대로 남던 문제를 수정했습니다. 상품 삭제와 동일하게 파일까지 함께 삭제됩니다.
- 카테고리 등록·수정 화면에서 이미지를 목록에서 빼도 화면에 그대로 남아 저장되던 문제를 수정했습니다.
+#### 통합검색·상품 문의
+
+- 통합검색에서 상품 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 상품 문의 목록이 문의가 많은 상품에서 목록 전체를 읽고 나서 잘라내던 것을, 현재 페이지 분량만 읽도록 조회 방식을 개선했습니다. 화면에 보이는 내용은 이전과 동일합니다.
+
#### 삭제된 PG·현금영수증 발급사
- 결제수단에 지정한 PG 사의 플러그인을 삭제해도 그 결제수단이 주문서에 계속 노출되던 문제를 수정했습니다. 이전에는 구매자가 그 수단을 고르면 결제창이 뜨지 않은 채 주문완료로 넘어갔습니다. 이제 해당 결제수단은 주문서에서 제외되고, 관리자 주문설정 화면에는 「지정 PG 삭제됨」 표시가 붙습니다. 살아 있는 PG 로 다시 지정하면 즉시 복구되며, 주문서를 거치지 않고 직접 주문을 시도하는 경우에도 안내와 함께 거절됩니다.
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php
index fcea15f8..df948064 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php
@@ -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;
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductInquiryRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductInquiryRepositoryInterface.php
index 47a1f7a0..bfcf6cdb 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductInquiryRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductInquiryRepositoryInterface.php
@@ -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로 문의 조회 (단일)
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php
index 37bd4fd0..f1e3ad82 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php
@@ -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);
}
/**
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php
index 581f6cba..acf08e81 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php
@@ -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 $ids 게시글 ID 목록
+ * @param string $boardSlug 문의 게시판 슬러그
+ * @return array> 게시글 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;
+ }
+
/**
* 기본 게시판 설정값 반환
*
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/PublicProductInquiryControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/PublicProductInquiryControllerTest.php
index 8e7466e9..9d45050d 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/PublicProductInquiryControllerTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/PublicProductInquiryControllerTest.php
@@ -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
{
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/ModuleTestCase.php b/modules/_bundled/sirsoft-ecommerce/tests/ModuleTestCase.php
index 871ca28d..a1e19d38 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/ModuleTestCase.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/ModuleTestCase.php
@@ -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;
}
});
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php
index e8f6ee80..b41d02a5 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php
@@ -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()가 올바른 훅 목록을 반환하는지 확인
*/
diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md
index 230962cb..6f175b92 100644
--- a/modules/_bundled/sirsoft-page/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-page/CHANGELOG.md
@@ -18,9 +18,11 @@
### Changed
- 첨부 개수 상한 초과 안내 문구를 만드는 시점이 응답을 만드는 시점과 어긋나 있던 것을 맞췄습니다. 표시되는 문구와 상태는 종전과 동일합니다.
+- 코어 최소 요구 버전을 7.0.7 로 상향했습니다.
### Fixed
+- 통합검색에서 페이지 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 페이지 첨부 파일의 최대 개수·용량·허용 형식 설정이 저장해도 반영되지 않던 문제를 수정했습니다. 설정값을 읽어 오는 경로 자체가 없어 항상 내장 기본값이 쓰였습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 페이지 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 발행·발행 취소 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
diff --git a/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php b/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php
index 8a1e632b..b06f2801 100644
--- a/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php
+++ b/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php
@@ -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;
diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Repositories/PageRepositorySearchTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Repositories/PageRepositorySearchTest.php
index 850dbf09..1e6fc0b8 100644
--- a/modules/_bundled/sirsoft-page/tests/Feature/Repositories/PageRepositorySearchTest.php
+++ b/modules/_bundled/sirsoft-page/tests/Feature/Repositories/PageRepositorySearchTest.php
@@ -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;
}
});
}
diff --git a/modules/_bundled/sirsoft-page/tests/ModuleTestCase.php b/modules/_bundled/sirsoft-page/tests/ModuleTestCase.php
index 807f3353..46a00035 100644
--- a/modules/_bundled/sirsoft-page/tests/ModuleTestCase.php
+++ b/modules/_bundled/sirsoft-page/tests/ModuleTestCase.php
@@ -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;
}
});
}
diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php
index 9ab8c001..72792d53 100644
--- a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php
+++ b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php
@@ -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() ───────────────────────────
diff --git a/plugins/_bundled/gnuboard7-hello_plugin/tests/PluginTestCase.php b/plugins/_bundled/gnuboard7-hello_plugin/tests/PluginTestCase.php
index cdb06623..d62baee2 100644
--- a/plugins/_bundled/gnuboard7-hello_plugin/tests/PluginTestCase.php
+++ b/plugins/_bundled/gnuboard7-hello_plugin/tests/PluginTestCase.php
@@ -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') {
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
index 33a960c5..469670fa 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
@@ -6,6 +6,10 @@
## [1.1.1] - 2026-08-14
+### Changed
+
+- 주문·결제 정보 조회가 데이터베이스 표 이름을 직접 쓰지 않고 쇼핑몰 모듈의 정의를 따르도록 내부 정리했습니다. 표시되는 내용과 동작은 종전과 동일합니다.
+
### Fixed
- 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다.
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php
index 4cc89ca4..aca998c4 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php
@@ -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)
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php
index 0b53e30f..0bd31969 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php
@@ -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)
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php
index 31c238c9..344ee7f6 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php
@@ -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'])
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php
index a45e372a..06dae17d 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php
@@ -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,
];
}
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserEscrowConfirmController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserEscrowConfirmController.php
index 6a1cdfbf..5ec68448 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserEscrowConfirmController.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserEscrowConfirmController.php
@@ -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 = <<
@@ -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 = <<
@@ -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')
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserReceiptController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserReceiptController.php
index a0ffe146..4ee6a598 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserReceiptController.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserReceiptController.php
@@ -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.')';
}
}
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php
index 4a095fd2..783de01c 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php
@@ -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);
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
index 004a7539..03b572c8 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
@@ -6,6 +6,10 @@
## [1.0.2] - 2026-08-14
+### Changed
+
+- 주문·결제 정보 조회가 데이터베이스 표 이름을 직접 쓰지 않고 쇼핑몰 모듈의 정의를 따르도록 내부 정리했습니다. 표시되는 내용과 동작은 종전과 동일합니다.
+
### Fixed
- 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다.
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php
index 18b02039..21a2b155 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php
@@ -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)
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php
index b63d60ea..fe3ea4bd 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php
@@ -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([
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php
index 16101073..0f5eba16 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php
@@ -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([
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php
index 723e5336..dfe725d3 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php
@@ -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'],
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php
index 0880eef3..5b0f9a4c 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php
@@ -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;
}
});
}
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
index d11f610f..50dc3160 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
@@ -6,6 +6,10 @@
## [1.0.2] - 2026-08-14
+### Changed
+
+- 주문·결제 정보 조회가 데이터베이스 표 이름을 직접 쓰지 않고 쇼핑몰 모듈의 정의를 따르도록 내부 정리했습니다. 표시되는 내용과 동작은 종전과 동일합니다.
+
### Fixed
- 관리자 주문 상세에서 에스크로 배송 등록이 실패하면 내부 오류 원문이 그대로 안내 문구 자리에 표시되던 문제를 수정했습니다. 이제 상황에 맞는 안내가 표시되고, 원인은 서버 로그에만 기록됩니다.
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php
index 950154a1..f2510501 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php
@@ -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) => [
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php
index 66aadda9..490ce3fe 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php
@@ -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([
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php
index 09f099da..8c157653 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php
@@ -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);
}
}
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/UserReceiptController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/UserReceiptController.php
index c4037c4d..d92f7026 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/UserReceiptController.php
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/UserReceiptController.php
@@ -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);
}
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php
index b82a5ecb..bc46e2df 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php
@@ -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;
}
});
}
diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-tosspayments/tests/PluginTestCase.php
index 04535bad..28cd46d3 100644
--- a/plugins/_bundled/sirsoft-tosspayments/tests/PluginTestCase.php
+++ b/plugins/_bundled/sirsoft-tosspayments/tests/PluginTestCase.php
@@ -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);
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-verification_kginicis/tests/PluginTestCase.php
index 0c1db028..1e56f27a 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/tests/PluginTestCase.php
+++ b/plugins/_bundled/sirsoft-verification_kginicis/tests/PluginTestCase.php
@@ -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;
}
});
}
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/PluginTestCase.php
index 23b3d4e2..a5eb7d9a 100644
--- a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/PluginTestCase.php
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/PluginTestCase.php
@@ -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;
}
diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md
index bd3042f6..8c9b1bf7 100644
--- a/templates/_bundled/sirsoft-basic/CHANGELOG.md
+++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md
@@ -8,6 +8,7 @@
### Added
+- 통합검색에서 검색이 실패했을 때 "검색 결과가 없습니다" 와 구분되는 "검색 중 오류" 안내를 표시합니다. 실패한 항목만 오류로 표시되고 나머지 항목의 결과는 정상 표시됩니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 공개 자산 스토리지(직접 URL/CDN)를 사용하는 사이트에서도 첨부 이미지 미리보기와 다운로드가 그 주소를 그대로 사용해 정상 동작합니다.
### Fixed
diff --git a/templates/_bundled/sirsoft-basic/lang/partial/en/search.json b/templates/_bundled/sirsoft-basic/lang/partial/en/search.json
index d1120e4b..234de360 100644
--- a/templates/_bundled/sirsoft-basic/lang/partial/en/search.json
+++ b/templates/_bundled/sirsoft-basic/lang/partial/en/search.json
@@ -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",
diff --git a/templates/_bundled/sirsoft-basic/lang/partial/ko/search.json b/templates/_bundled/sirsoft-basic/lang/partial/ko/search.json
index cb9264cc..60beafce 100644
--- a/templates/_bundled/sirsoft-basic/lang/partial/ko/search.json
+++ b/templates/_bundled/sirsoft-basic/lang/partial/ko/search.json
@@ -39,6 +39,10 @@
"pages_in_all": "페이지 검색 결과가 없습니다",
"suggestion": "다른 검색어로 시도해 보세요"
},
+ "failed": {
+ "title": "검색 중 오류가 발생했습니다",
+ "suggestion": "잠시 후 다시 시도해 주세요"
+ },
"page_published_at": "발행일: {{date}}",
"view_more": "더보기",
"view_all": "전체보기",
diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/search/_search_results.json b/templates/_bundled/sirsoft-basic/layouts/partials/search/_search_results.json
index 33831d0c..d55243bf 100644
--- a/templates/_bundled/sirsoft-basic/layouts/partials/search/_search_results.json
+++ b/templates/_bundled/sirsoft-basic/layouts/partials/search/_search_results.json
@@ -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" }
+ ]
}
]
}
\ No newline at end of file
diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/search/_search_states.json b/templates/_bundled/sirsoft-basic/layouts/partials/search/_search_states.json
index 9ff893bf..449b043a 100644
--- a/templates/_bundled/sirsoft-basic/layouts/partials/search/_search_states.json
+++ b/templates/_bundled/sirsoft-basic/layouts/partials/search/_search_states.json
@@ -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": [
{
diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/search/pages/_section.json b/templates/_bundled/sirsoft-basic/layouts/partials/search/pages/_section.json
index cffea99c..b51733b3 100644
--- a/templates/_bundled/sirsoft-basic/layouts/partials/search/pages/_section.json
+++ b/templates/_bundled/sirsoft-basic/layouts/partials/search/pages/_section.json
@@ -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" }
+ ]
}
]
}
diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/search/posts/_section.json b/templates/_bundled/sirsoft-basic/layouts/partials/search/posts/_section.json
index 6404fc97..28ad2188 100644
--- a/templates/_bundled/sirsoft-basic/layouts/partials/search/posts/_section.json
+++ b/templates/_bundled/sirsoft-basic/layouts/partials/search/posts/_section.json
@@ -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" }
+ ]
}
]
}
\ No newline at end of file
diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_section.json b/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_section.json
index 04108f78..d5179964 100644
--- a/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_section.json
+++ b/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_section.json
@@ -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" }
+ ]
}
]
}
\ No newline at end of file
diff --git a/templates/_bundled/sirsoft-basic/src/__tests__/layouts/search-failed-state.test.tsx b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/search-failed-state.test.tsx
new file mode 100644
index 00000000..a667c00c
--- /dev/null
+++ b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/search-failed-state.test.tsx
@@ -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 }) => (
+