Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.2/migrations/PruneEmptyShippingCountryNameLocales.php
T
HeuJung 85e266d361 fix(core,extensions): 확장 쓰기 경로 공통화 · HTMLPurifier 정의 캐시 storage 이전
https://github.com/gnuboard/g7/issues/125 — 상품 상세설명을 HTML 로 저장할 때
HTMLPurifier 가 모듈 vendor 폴더 안에 정의 캐시를 만들려다 실패해 저장이 매번 500 으로
끝나던 문제를 고친다. vendor 를 읽기 전용으로 두는 표준 배포에서 그 쓰기는 예외가 아니라
PHP 경고로 나오고 Laravel 이 이를 ErrorException 으로 승격시킨다. 캐시는 설정 해시당 1회만
기록되므로 캐시가 영영 생기지 않아 재시도해도 같은 결과였다.

캐시 경로를 storage 아래로 옮기고, 그 경로마저 확보하지 못하면 캐시만 끄고 정화는 그대로
수행한다 — 캐시는 성능 장치이고 정화는 보안 장치라, 전자의 실패가 후자를 건너뛰게 만들면
안 된다. 저장은 성공하므로 운영자에게 도달하는 흔적이 로그 하나뿐이라 error 수준으로 남긴다
(출하 기본 로그 수준이 error 라 warning 은 기본 설치 상태에서 파일에 남지 않는다).

그 과정에서 갈라져 있던 두 축을 코어 한 곳으로 모은다.

- 쓰기 디렉토리 확보: 억제 생성·chmod·setgid·소유권 상속·쓰기 판정 절차가 정적 게시와
 정의 캐시 두 곳에 서로 다른 하드닝으로 복제돼 있었다(억제 mkdir·setgid·clearstatcache 가
 사본마다 한쪽씩 빠져 있었다). FilePermissionHelper 의 ensureWritableDirectory 와
 hardenDirectory 로 통합하고, 실패 사유는 out 파라미터로 올려 정책(조용한 성능 저하 대
 시끄러운 실패)은 호출부가 정하게 둔다.

- 확장 저장 경로: storage_path('app/modules/…') 손조립이 30곳에 흩어져 있어 테스트 격리
 분기를 넣으려면 사본마다 복제해야 했고, 한 곳만 빠뜨려도 그 확장의 테스트가 운영 설정
 파일을 덮어쓴다. 디스크 root 를 단일 출처로 읽는 ExtensionStoragePath 로 전환하고 테스트
 분기는 config/filesystems.php 한 줄에서 끝낸다.

함께 고친 것

- 테스트가 운영 라우트 캐시로 부팅해 확장 allowlist 가 라우트 축에서 통째로 무력화되던
 문제. 삭제가 아니라 경로를 돌린다 — 라우트 캐시는 확장 작업 전까지 재생성되지 않아,
 삭제하면 운영 사이트가 그때까지 라우트 파일 스캔 경로로 떨어진다.
- PHPUnit 프로세스가 확장 vendor 의 제3자 composer 패키지를 오토로드하지 않아 그 패키지를
 쓰는 코드 경로가 통째로 테스트 불가였던 문제. 확장 자신의 오토로더를 그대로 쓰면 활성
 디렉토리가 _bundled 를 이기고 base path 유추까지 깨지므로, 생성된 맵에서 제3자 항목만
 골라 별도 로더로 등록한다.
- 게시 폴더가 setgid 를 갖지 않아, 명령줄과 웹이 번갈아 만든 하위 폴더를 다른 쪽이 쓰지
 못하던 문제.
- 관리자 템플릿이 HTML 정화 라이브러리를 직접 지정하지 않아 전이 의존으로 딸려온 구버전이
 쓰이던 문제.

동반 산출물

- 규정 표(·AGENTS.md) 6행 + storage-driver/service-repository/testing-guide 문서
- audit 룰 2종 + coverage 6항목. 저장소가 이미 전량 전환돼 전수 실행이 공허 통과하므로
 판정식은 픽스처 36건이 잠근다
- INSTALL.md 에 설치 후 파일 권한 절 추가 (vendor 쓰기 권한 불요를 명시)
2026-08-28 15:22:22 +09:00

137 lines
5.1 KiB
PHP

<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_2\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use App\Support\ExtensionStoragePath;
use Illuminate\Support\Facades\File;
/**
* 배송가능 국가명(available_countries[].name)의 빈 로케일 키를 제거.
*
* 구 국가 추가 폼은 한국어/영문 두 입력칸만 렌더했고, 운영자가 한쪽을 비운 채 저장하면
* `{"ko":"프랑스","en":""}` 처럼 빈 문자열이 저장본에 박혔다. 빈 문자열은 "값이 있음" 도
* "부재" 도 아닌 어중간한 상태다.
*
* 이 시스템의 계약은 "부재 로케일은 비워 둔다" 이다 — 기본 국가는 언어팩
* (`sirsoft-ecommerce::settings.countries.{code}.name`)이 읽기 시점에 보강하고
* (EcommerceSettingsService::getAllSettings), 운영자가 직접 추가한 국가는 운영자가 채운다.
* 따라서 빈 문자열 키를 제거해 "부재" 로 정규화한다.
*
* 이름을 새로 채우지는 않는다 — 언어팩 보강이 그 역할을 하며, 저장본에 값을 박으면
* 운영자 편집값으로 승격되어 이후 언어팩 갱신이 반영되지 않는다.
*
* name 이 배열이 아닌 구 스키마 잔재(문자열)는 이 마이그레이션의 책임이 아니라 건드리지 않는다.
*
* idempotent: 빈 키가 없고 모든 값이 trim 상태면 no-op (파일 쓰기 없음).
*
* V-1 안전: Illuminate\Support\Facades\File + 로컬 헬퍼만 사용.
*/
class PruneEmptyShippingCountryNameLocales implements DataMigration
{
private const MODULE_IDENTIFIER = 'sirsoft-ecommerce';
public function name(): string
{
return 'PruneEmptyShippingCountryNameLocales';
}
public function run(UpgradeContext $context): void
{
$path = $this->settingsFilePath();
if (! File::exists($path)) {
$context->logger->info('[ecommerce:1.0.2] shipping.json 미존재 — 국가명 빈 로케일 청소 스킵');
return;
}
$settings = json_decode((string) File::get($path), true);
if (! is_array($settings) || ! isset($settings['available_countries']) || ! is_array($settings['available_countries'])) {
$context->logger->info('[ecommerce:1.0.2] shipping.json 에 배송가능 국가 목록 없음 — 국가명 빈 로케일 청소 스킵');
return;
}
$countries = $settings['available_countries'];
$prunedKeys = 0;
foreach ($countries as $idx => $country) {
$name = $country['name'] ?? null;
// 구 스키마 잔재(문자열 name)는 대상 아님
if (! is_array($name)) {
continue;
}
$cleaned = $this->pruneEmptyLocales($name);
if ($cleaned !== $name) {
$prunedKeys += count($name) - count($cleaned);
$countries[$idx]['name'] = $cleaned;
}
}
if ($countries === $settings['available_countries']) {
$context->logger->info('[ecommerce:1.0.2] 국가명에 빈 로케일 키 없음 — 변경 없음 (idempotent)');
return;
}
$settings['available_countries'] = $countries;
// 인코딩 플래그는 EcommerceSettingsService 의 저장부와 동일해야 한다.
// 다르면 이 스텝이 국가명과 무관한 값(URL 등)의 이스케이프까지 조용히 바꿔 쓴다.
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$context->logger->info(sprintf(
'[ecommerce:1.0.2] 국가명 빈 로케일 키 %d개 제거 완료 (국가 %d개 검사)',
$prunedKeys,
count($countries),
));
}
/**
* 다국어 이름 맵에서 빈 값(공백 포함) 로케일 키를 제거하고 남은 값을 trim 합니다.
*
* 모든 로케일이 비어 있으면 빈 배열을 반환합니다 — null 이 아니라 빈 배열이어야
* 백엔드 검증(`shipping.available_countries.*.name` => array)이 깨지지 않습니다.
*
* @param array<string, mixed> $name 로케일 => 국가명 맵
* @return array<string, string> 빈 로케일이 제거되고 trim 된 맵
*/
private function pruneEmptyLocales(array $name): array
{
$cleaned = [];
foreach ($name as $locale => $value) {
if (! is_string($value)) {
continue;
}
$trimmed = trim($value);
if ($trimmed === '') {
continue;
}
$cleaned[$locale] = $trimmed;
}
return $cleaned;
}
/**
* shipping.json 의 저장 경로를 반환합니다.
*
* 테스트 환경에서는 운영 storage 오염을 막기 위해 framework/testing 경로를 사용합니다
* (EcommerceSettingsService 의 저장 경로 분기와 동일).
*
* @return string 설정 파일 절대 경로
*/
private function settingsFilePath(): string
{
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings').'/shipping.json';
}
}