Merge branch 'develop' of github-gnuboard:gnuboard/dev-g7 into thisgun/pay_develop
This commit is contained in:
+7
-1
@@ -4,12 +4,18 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.1] - 2026-07-01
|
||||
## [7.0.1] - 2026-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.
|
||||
- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.
|
||||
- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 "알 수 없음"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
|
||||
## [7.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -806,6 +806,7 @@ class CoreUpdateCommand extends Command
|
||||
// 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용).
|
||||
$handoffPayload = null;
|
||||
$stepsExecuted = null;
|
||||
$stepsDiscovered = null;
|
||||
while (! feof($pipes[1])) {
|
||||
$line = fgets($pipes[1]);
|
||||
if ($line !== false) {
|
||||
@@ -837,7 +838,13 @@ class CoreUpdateCommand extends Command
|
||||
$decoded = json_decode($json, true);
|
||||
if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) {
|
||||
$stepsExecuted = $decoded['count'];
|
||||
$log('[spawn] 실행된 step 수: '.$stepsExecuted);
|
||||
// discovered 는 신버전 자식만 발행하는 필드. 구버전 자식(필드 부재)은
|
||||
// null 로 남아 handleSpawnExit 가 기존(레거시) 판정 경로를 탄다.
|
||||
if (isset($decoded['discovered']) && is_int($decoded['discovered']) && $decoded['discovered'] >= 0) {
|
||||
$stepsDiscovered = $decoded['discovered'];
|
||||
}
|
||||
$log('[spawn] 실행된 step 수: '.$stepsExecuted
|
||||
.($stepsDiscovered !== null ? " (발견 {$stepsDiscovered})" : ''));
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -852,6 +859,50 @@ class CoreUpdateCommand extends Command
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
return $this->handleSpawnExit(
|
||||
$exitCode,
|
||||
$handoffPayload,
|
||||
$stepsExecuted,
|
||||
$stepsDiscovered,
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
$log,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* spawn 자식 종료 후 exit code · 신호를 해석해 성공/실패/핸드오프를 판정합니다.
|
||||
*
|
||||
* 판정 규칙 (exit=0 경우):
|
||||
* - `[STEPS_EXECUTED]` 신호 미수신 (`$stepsExecuted === null`) → fail-fast.
|
||||
* 이전 버전 자식 (신호 미발행) 또는 silent skip 의심.
|
||||
* - executed=0 인데 **discovered>0** → fail-fast. 범위 내에 스텝 파일이 실제로
|
||||
* 존재하는데 자식이 하나도 실행하지 못함 (gnuboard/g7#28 silent skip — 케이스 A).
|
||||
* - executed=0 이고 **discovered=0** → 정상 통과. 해당 from~to 범위에 스텝 파일이
|
||||
* 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). from<to 여도 실패 아님 (케이스 B).
|
||||
* - discovered 신호 부재 (`null`, 구버전 자식) + executed=0 + from<to → 레거시 판정 유지
|
||||
* (fail-fast). 신버전 자식은 항상 discovered 를 발행하므로 이 경로는 구버전 자식 한정.
|
||||
*
|
||||
* @param int $exitCode 자식 프로세스 종료 코드
|
||||
* @param array|null $handoffPayload 파싱된 [HANDOFF] 페이로드 (없으면 null)
|
||||
* @param int|null $stepsExecuted 실행된 스텝 수 ([STEPS_EXECUTED] 미수신 시 null)
|
||||
* @param int|null $stepsDiscovered 범위 내 발견된 스텝 파일 수 (구버전 자식은 null)
|
||||
* @param string $fromVersion 업그레이드 시작 버전
|
||||
* @param string $toVersion 업그레이드 대상 버전
|
||||
* @param \Closure $log 로그 수집 콜백
|
||||
* @return bool spawn 성공 여부 (mode=fallback 실패 시 false — 호출자 in-process fallback)
|
||||
*
|
||||
* @throws UpgradeHandoffException 핸드오프 수신 또는 mode=abort 실패 시
|
||||
*/
|
||||
private function handleSpawnExit(
|
||||
int $exitCode,
|
||||
?array $handoffPayload,
|
||||
?int $stepsExecuted,
|
||||
?int $stepsDiscovered,
|
||||
string $fromVersion,
|
||||
string $toVersion,
|
||||
\Closure $log,
|
||||
): bool {
|
||||
if ($exitCode === UpgradeHandoffException::EXIT_CODE && $handoffPayload !== null) {
|
||||
$log("spawn 핸드오프 종료 (exit={$exitCode})");
|
||||
|
||||
@@ -866,9 +917,7 @@ class CoreUpdateCommand extends Command
|
||||
}
|
||||
|
||||
if ($exitCode === 0) {
|
||||
// silent skip 가드 — 자식이 [STEPS_EXECUTED] 신호를 발행하지 않거나, step 0건
|
||||
// 실행한 채 exit=0 으로 종료한 경우. 이전 버전 자식 (beta.5 이전 디스크) 또는
|
||||
// 자식이 비정상 종료 직전 silent skip 한 상태로 추정. fail-fast 모드 가드 적용.
|
||||
// 신호 미수신 — 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. fail-fast.
|
||||
if ($stepsExecuted === null) {
|
||||
return $this->failSpawnWithMode(
|
||||
'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심',
|
||||
@@ -878,9 +927,27 @@ class CoreUpdateCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
if ($stepsExecuted === 0 && version_compare($fromVersion, $toVersion, '<')) {
|
||||
// executed=0 + discovered=0 → 범위 내 스텝 파일 부재. 정상 통과 (케이스 B).
|
||||
// 스텝이 필요 없는 릴리즈(데이터/설정 변경 없음)를 실패로 오판하지 않는다.
|
||||
if ($stepsExecuted === 0 && $stepsDiscovered === 0) {
|
||||
$log("spawn 완료 (exit=0, 실행할 스텝 없음 — 범위 내 스텝 파일 부재, from={$fromVersion} to={$toVersion})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// executed=0 + (discovered>0 또는 discovered 신호 부재) + from<to → fail-fast.
|
||||
// discovered>0: 스텝 파일이 있는데 자식이 실행 못함 (gnuboard/g7#28 케이스 A).
|
||||
// discovered=null: 구버전 자식 (discovered 미발행) — 레거시 판정 유지.
|
||||
if ($stepsExecuted === 0
|
||||
&& $stepsDiscovered !== 0
|
||||
&& version_compare($fromVersion, $toVersion, '<')) {
|
||||
return $this->failSpawnWithMode(
|
||||
sprintf('spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s)', $fromVersion, $toVersion),
|
||||
sprintf(
|
||||
'spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s%s)',
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
$stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '',
|
||||
),
|
||||
$log,
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
|
||||
@@ -164,6 +164,7 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
}
|
||||
|
||||
$stepsExecuted = 0;
|
||||
$stepsDiscovered = 0;
|
||||
|
||||
try {
|
||||
$service->runUpgradeSteps(
|
||||
@@ -174,6 +175,9 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
$stepsExecuted++;
|
||||
},
|
||||
$force,
|
||||
function (int $discovered) use (&$stepsDiscovered): void {
|
||||
$stepsDiscovered = $discovered;
|
||||
},
|
||||
);
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다.
|
||||
@@ -221,6 +225,7 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
// mode=fallback 시 in-process 진행 (`spawn_failure_mode`).
|
||||
$this->line('[STEPS_EXECUTED] '.json_encode([
|
||||
'count' => $stepsExecuted,
|
||||
'discovered' => $stepsDiscovered,
|
||||
'fromVersion' => $from,
|
||||
'toVersion' => $to,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
@@ -53,15 +53,25 @@ class FilePermissionHelper
|
||||
|
||||
$destPath = $destination.DIRECTORY_SEPARATOR.$itemName;
|
||||
|
||||
// symlink 자체 보존: target 추적 없이 동일 symlink 재생성.
|
||||
// symlink / Windows junction 자체 보존: target 추적 없이 동일 링크 재생성.
|
||||
// SplFileInfo::isDir() 가 symlink 를 추적하므로 검사 순서가 isDir() 보다 먼저여야 한다.
|
||||
// 미적용 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
|
||||
if ($item->isLink()) {
|
||||
if (static::copySymlink($item->getPathname(), $destPath)) {
|
||||
// 미적용 시 `public/storage` 등 링크가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
|
||||
//
|
||||
// Windows JUNCTION(`mklink /J`)은 reparse point 지만 PHP `isLink()`/`isDir()` 이 모두
|
||||
// false 를 반환한다(`storage:link` 가 Windows 에서 생성하는 형태). isLink() 만 검사하면
|
||||
// junction 이 파일로 오판되어 copyFile → `copy(디렉토리)` 로 코어 업데이트가 실패한다.
|
||||
// isReparsePoint() 로 junction 도 이 분기에 포함시킨다.
|
||||
if ($item->isLink() || static::isReparsePoint($item->getPathname())) {
|
||||
if (static::copyLink($item->getPathname(), $destPath)) {
|
||||
continue;
|
||||
}
|
||||
// 복원 실패 (Windows SeCreateSymbolicLink 권한 부족 등) — 일반 복사로 fall-through.
|
||||
// 단, junction 은 isDir() 이 false 라 아래 else 분기의 copyFile 로 새어 파일 복사가
|
||||
// 다시 실패하므로, fall-through 대신 skip 하여 손상 없이 넘어간다.
|
||||
// 운영자가 추후 `php artisan storage:link` 수동 실행으로 회복 가능.
|
||||
if (! $item->isLink()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($item->isDir()) {
|
||||
@@ -79,25 +89,63 @@ class FilePermissionHelper
|
||||
}
|
||||
|
||||
/**
|
||||
* symlink 자체를 보존 복사합니다 (target 미추적).
|
||||
* 경로가 Windows JUNCTION 등 PHP `isLink()` 가 인식하지 못하는 reparse point 인지 판정합니다.
|
||||
*
|
||||
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 symlink 재생성.
|
||||
* readlink / symlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
|
||||
* Windows JUNCTION(`mklink /J`)은 디렉토리 reparse point 로, `is_link()` / `is_file()` /
|
||||
* `is_dir()` 이 **모두** false 를 반환한다(그러나 경로 항목으로는 존재하며 `readlink()` 로
|
||||
* target 을 얻을 수 있다). `php artisan storage:link` 가 Windows 에서 생성하는
|
||||
* `public/storage` 가 이 형태다.
|
||||
*
|
||||
* Windows 환경에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하며 일반 사용자는
|
||||
* 권한이 없을 가능성이 높다. 실패 시 warning 로그 후 false 반환하여 호출자가 일반 복사로
|
||||
* fall-through 하도록 한다. 운영자는 업그레이드 후 `php artisan storage:link` 등 수동
|
||||
* 명령으로 symlink 를 회복할 수 있다.
|
||||
* 주의: Windows 의 `readlink()` 는 일반 파일/디렉토리에도 자기 경로를 반환하므로 junction
|
||||
* 판별 기준이 될 수 없다. 따라서 "존재하는 경로인데 link/file/dir 어디에도 해당하지 않음"
|
||||
* 을 판정 기준으로 삼는다. 여기에 추가로 `readlink()` 성공(!== false)을 요구해 존재하지
|
||||
* 않는 경로를 배제한다.
|
||||
*
|
||||
* @param string $source 소스 symlink 경로
|
||||
* @param string $destination 대상 symlink 경로
|
||||
* @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
|
||||
* 일반 symlink(`is_link()` true)는 호출부가 별도로 처리하므로 여기서는 false 를 반환한다.
|
||||
* junction 은 Windows 전용이므로 비-Windows 에서는 항상 false.
|
||||
*
|
||||
* @param string $path 검사할 경로
|
||||
* @return bool junction 등 isLink() 미인식 reparse point 여부
|
||||
*/
|
||||
protected static function copySymlink(string $source, string $destination): bool
|
||||
protected static function isReparsePoint(string $path): bool
|
||||
{
|
||||
if (PHP_OS_FAMILY !== 'Windows') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 일반 symlink / 파일 / 디렉토리는 각 검사가 true → junction 아님.
|
||||
if (is_link($path) || is_file($path) || is_dir($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 위 셋에 모두 해당하지 않으면서 readlink 가 target 을 반환하면 junction(reparse point).
|
||||
return @readlink($path) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* symlink 또는 Windows junction 자체를 보존 복사합니다 (target 미추적).
|
||||
*
|
||||
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 링크 재생성.
|
||||
* readlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
|
||||
*
|
||||
* 재생성 전략:
|
||||
* - 일반 symlink: `symlink()`
|
||||
* - target 이 디렉토리인 경우(Windows junction 포함): `symlink()` 실패 시 `mklink /J` 로 폴백.
|
||||
* Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction 은
|
||||
* 권한 없이 생성 가능하므로, junction 복원의 실질 경로가 된다.
|
||||
*
|
||||
* 모든 재생성 시도 실패 시 warning 로그 후 false 반환. 운영자는 업그레이드 후
|
||||
* `php artisan storage:link` 등 수동 명령으로 링크를 회복할 수 있다.
|
||||
*
|
||||
* @param string $source 소스 링크(symlink/junction) 경로
|
||||
* @param string $destination 대상 링크 경로
|
||||
* @return bool 링크 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
|
||||
*/
|
||||
protected static function copyLink(string $source, string $destination): bool
|
||||
{
|
||||
$target = @readlink($source);
|
||||
if ($target === false) {
|
||||
Log::warning('copySymlink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
|
||||
Log::warning('copyLink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -109,17 +157,29 @@ class FilePermissionHelper
|
||||
File::deleteDirectory($destination);
|
||||
}
|
||||
|
||||
if (! @symlink($target, $destination)) {
|
||||
Log::warning('copySymlink: symlink 생성 실패 — 일반 복사로 폴백', [
|
||||
'source' => $source,
|
||||
'destination' => $destination,
|
||||
'target' => $target,
|
||||
]);
|
||||
|
||||
return false;
|
||||
if (@symlink($target, $destination)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
// symlink 실패 + target 이 디렉토리 → Windows junction 으로 폴백 재생성.
|
||||
if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) {
|
||||
exec(
|
||||
'cmd /c mklink /J '.escapeshellarg($destination).' '.escapeshellarg($target).' 2>&1',
|
||||
$out,
|
||||
$code
|
||||
);
|
||||
if ($code === 0 && is_dir($destination)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('copyLink: 링크 생성 실패 — 일반 복사로 폴백', [
|
||||
'source' => $source,
|
||||
'destination' => $destination,
|
||||
'target' => $target,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,11 +217,19 @@ class FilePermissionHelper
|
||||
|
||||
// 소스에 존재하지 않는 항목만 삭제
|
||||
if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) {
|
||||
// symlink 는 항상 unlink — File::deleteDirectory 는 is_dir() 추적 검사 후
|
||||
// 재귀 삭제하므로 symlink-to-dir 인 경우 target 의 모든 파일을 삭제하는 사고
|
||||
// 발생 가능. is_link() 가 isDir() 보다 먼저 평가되어야 한다.
|
||||
// symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir()
|
||||
// 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는
|
||||
// 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다.
|
||||
//
|
||||
// junction 은 is_link() 가 false 지만 isReparsePoint() 가 감지한다. junction 링크
|
||||
// 제거는 파일 대상 unlink 가 아닌 rmdir() 로 수행해야 target 내용을 보존한다.
|
||||
if (is_link($destItem->getPathname())) {
|
||||
@unlink($destItem->getPathname());
|
||||
} elseif (static::isReparsePoint($destItem->getPathname())) {
|
||||
// junction: rmdir 로 링크만 제거 (target 미추적). 폴백으로 unlink 시도.
|
||||
if (! @rmdir($destItem->getPathname())) {
|
||||
@unlink($destItem->getPathname());
|
||||
}
|
||||
} elseif ($destItem->isDir()) {
|
||||
File::deleteDirectory($destItem->getPathname());
|
||||
} else {
|
||||
|
||||
@@ -187,7 +187,7 @@ class AdminTemplateAssetController extends AdminBaseController
|
||||
/**
|
||||
* 편집기 프리뷰 전용 CSS 서빙 — 다크 셀렉터를 프리뷰 마커로 치환.
|
||||
*
|
||||
* PO admin 환경이 다크 테마면 `<html class="dark">` 가 조상으로 남아, 프리뷰
|
||||
* 관리자 admin 환경이 다크 테마면 `<html class="dark">` 가 조상으로 남아, 프리뷰
|
||||
* 프레임에서 `.dark` 를 빼도(라이트 토글) Tailwind `.dark &` 가 활성돼 프리뷰가
|
||||
* 라이트로 격리되지 않는다. 이를 해소하기 위해, 편집기 진입 시에만 components.css 의
|
||||
* 다크 조상 셀렉터(`.dark`)를 프리뷰 전용 마커(`.g7le-preview-dark`)로 치환해 서빙한다.
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\Settings\RegenerateAppKeyRequest;
|
||||
use App\Http\Requests\Settings\RestoreSettingsRequest;
|
||||
use App\Http\Requests\Settings\SaveSettingsRequest;
|
||||
use App\Http\Requests\Settings\TestDriverConnectionRequest;
|
||||
use App\Http\Requests\Settings\TestMailRequest;
|
||||
@@ -13,7 +14,7 @@ use App\Services\DriverConnectionTester;
|
||||
use App\Services\DriverRegistryService;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
@@ -137,7 +138,9 @@ class SettingsController extends AdminBaseController
|
||||
$systemInfo = $this->settingsService->getSystemInfo();
|
||||
|
||||
return $this->success('common.success', $systemInfo);
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('시스템 정보 조회 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('common.error_occurred', 500, $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -264,16 +267,13 @@ class SettingsController extends AdminBaseController
|
||||
/**
|
||||
* 백업에서 설정을 복원합니다.
|
||||
*
|
||||
* @param RestoreSettingsRequest $request 복원 요청 데이터 (백업 경로)
|
||||
* @return JsonResponse 복원 결과 JSON 응답
|
||||
*/
|
||||
public function restore(Request $request): JsonResponse
|
||||
public function restore(RestoreSettingsRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$backupPath = $request->input('backup_path');
|
||||
|
||||
if (empty($backupPath)) {
|
||||
return $this->error('settings.backup_path_required', 422);
|
||||
}
|
||||
$backupPath = $request->validated('backup_path');
|
||||
|
||||
$result = $this->settingsService->restoreSettings($backupPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 설정 복원 요청 FormRequest
|
||||
*
|
||||
* 백업에서 설정을 복원할 때 백업 경로를 검증합니다.
|
||||
* 권한은 라우트의 permission 미들웨어(core.settings.update)가 담당합니다.
|
||||
*/
|
||||
class RestoreSettingsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 요청 권한을 확인합니다.
|
||||
*
|
||||
* 권한 검사는 permission 미들웨어 체인에 위임하므로 항상 true 를 반환합니다.
|
||||
*
|
||||
* @return bool 항상 true
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙을 반환합니다.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'backup_path' => 'required|string',
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.settings.restore_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 오류 메시지를 반환합니다.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'backup_path.required' => __('settings.backup_path_required'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)을 표현한다. 파일 자체는
|
||||
* 코어 StorageInterface 로 저장되고 본 모델은 저장 위치(disk/path)와 메타데이터를
|
||||
* 보관한다. 템플릿 삭제 시 행은 cascadeOnDelete 로 삭제되나, 스토리지 파일
|
||||
* 실삭제는 Service 가 명시적으로 수행한다(CLAUDE.md "DB CASCADE 의존 삭제 금지").
|
||||
* 실삭제는 Service 가 명시적으로 수행한다(코어 규정: DB CASCADE 의존 삭제 금지).
|
||||
*
|
||||
* @property int $id 첨부 파일 ID
|
||||
* @property int $template_id 소속 템플릿 ID
|
||||
|
||||
@@ -11,7 +11,7 @@ use App\Contracts\Extension\PluginManagerInterface;
|
||||
* 데이터소스 종류=websocket 편집 시 채널을 "구독 가능한 목록 기준 검색 드롭다운"으로
|
||||
* 제시하기 위해, "지금 이 설치본에서 구독 가능한 채널"을 런타임 수집한다. 코어/확장이
|
||||
* `getChannels()` 오버라이드로 등록한 채널명을 출처(core/module/plugin)와 함께 모은다
|
||||
* (CLAUDE.md 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인
|
||||
* (코어 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인
|
||||
* `plugin.{id}.*`).
|
||||
*
|
||||
* 이벤트는 `HookManager::broadcast($channel, $eventName, $payload)` 호출 시점에 이름이
|
||||
|
||||
@@ -1558,8 +1558,9 @@ class CoreUpdateService
|
||||
* @param string $toVersion 종료 버전
|
||||
* @param \Closure|null $onStep 각 스텝 실행 시 콜백 (버전 문자열 전달)
|
||||
* @param bool $force true 시 fromVersion == toVersion이면 해당 버전 스텝도 포함
|
||||
* @param \Closure|null $onDiscovered 범위 내 발견된 스텝 파일 수를 실행 전 1회 통지 (int 전달)
|
||||
*/
|
||||
public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false): void
|
||||
public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false, ?\Closure $onDiscovered = null): void
|
||||
{
|
||||
// 부모 in-process fallback 진입 시 stale 메모리 가드.
|
||||
//
|
||||
@@ -1597,6 +1598,9 @@ class CoreUpdateService
|
||||
$upgradesPath = base_path('upgrades');
|
||||
|
||||
if (! File::isDirectory($upgradesPath)) {
|
||||
// upgrades 디렉토리 자체가 없으면 발견된 스텝 0건 — 정상 통지 후 종료.
|
||||
$onDiscovered?->__invoke(0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1656,6 +1660,12 @@ class CoreUpdateService
|
||||
|
||||
uksort($steps, 'version_compare');
|
||||
|
||||
// 범위 내에서 발견된 스텝 파일 수(discovered)를 먼저 통지한다.
|
||||
// 호출자(spawn 자식)는 이 값으로 "스텝 파일이 애초에 없어 0건(정상)" 과 "스텝 파일은
|
||||
// 있는데 실행이 0건(비정상 — gnuboard/g7#28 silent skip)" 을 구분한다. onStep 은 실제 실행
|
||||
// 건마다 호출되므로 executed 수만 세며, discovered 는 실행 전에 1회 통지한다.
|
||||
$onDiscovered?->__invoke(count($steps));
|
||||
|
||||
$context = new UpgradeContext($fromVersion, $toVersion);
|
||||
|
||||
foreach ($steps as $version => $step) {
|
||||
|
||||
@@ -822,30 +822,74 @@ class SettingsService
|
||||
private function buildSystemInfo(): array
|
||||
{
|
||||
return [
|
||||
'os_info' => php_uname('s').' '.php_uname('r'),
|
||||
// php_uname / ini_get 은 disable_functions 로 차단될 수 있어 개별 격리한다.
|
||||
'os_info' => $this->safeSystemProbe('os_info', fn () => php_uname('s').' '.php_uname('r'), __('common.unknown')),
|
||||
'web_server' => $_SERVER['SERVER_SOFTWARE'] ?? __('common.unknown'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'mysql_version' => $this->getDatabaseInfo(),
|
||||
'mysql_version' => $this->safeSystemProbe('mysql_version', fn () => $this->getDatabaseInfo(), __('common.unknown')),
|
||||
'g7_version' => config('app.version', '1.0.0'),
|
||||
'g7_release_year' => config('app.release_year', '2026'),
|
||||
'laravel_version' => app()->version(),
|
||||
'environment' => app()->environment(),
|
||||
'cpu_info' => $this->getCpuInfo(),
|
||||
'memory_usage' => $this->getMemoryUsage(),
|
||||
'disk_usage' => $this->getDiskUsage(),
|
||||
'php_memory_limit' => ini_get('memory_limit'),
|
||||
'max_execution_time' => ini_get('max_execution_time').__('settings.seconds'),
|
||||
'upload_max_filesize' => ini_get('upload_max_filesize'),
|
||||
'cpu_info' => $this->safeSystemProbe('cpu_info', fn () => $this->getCpuInfo(), __('common.unknown')),
|
||||
'memory_usage' => $this->safeSystemProbe('memory_usage', fn () => $this->getMemoryUsage(), $this->unknownUsage()),
|
||||
'disk_usage' => $this->safeSystemProbe('disk_usage', fn () => $this->getDiskUsage(), $this->unknownUsage()),
|
||||
'php_memory_limit' => $this->safeSystemProbe('php_memory_limit', fn () => ini_get('memory_limit'), __('common.unknown')),
|
||||
'max_execution_time' => $this->safeSystemProbe('max_execution_time', fn () => ini_get('max_execution_time').__('settings.seconds'), __('common.unknown')),
|
||||
'upload_max_filesize' => $this->safeSystemProbe('upload_max_filesize', fn () => ini_get('upload_max_filesize'), __('common.unknown')),
|
||||
'install_path' => base_path(),
|
||||
'config_path' => storage_path('app/settings'),
|
||||
'log_path' => storage_path('logs'),
|
||||
'upload_path' => storage_path('app/public'),
|
||||
'php_extensions' => $this->getPhpExtensions(),
|
||||
'database_config' => $this->getDatabaseConfig(),
|
||||
'php_extensions' => $this->safeSystemProbe('php_extensions', fn () => $this->getPhpExtensions(), ['required' => [], 'optional' => []]),
|
||||
'database_config' => $this->safeSystemProbe('database_config', fn () => $this->getDatabaseConfig(), ['has_read_write_split' => false, 'write' => [], 'read' => []]),
|
||||
'timezone' => config('app.timezone'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 정보 probe 를 안전하게 실행합니다.
|
||||
*
|
||||
* probe 가 예외(ErrorException·Error·disable_functions 로 인한 Error 포함)를
|
||||
* 던지면 전파하지 않고 폴백값을 반환하고 경고 로그를 남깁니다. 개별 항목 수집
|
||||
* 실패가 system-info API 전체를 500 으로 만들지 않도록 격리합니다.
|
||||
*
|
||||
* @param string $label 실패 로그 식별용 항목명 (예: 'cpu_info')
|
||||
* @param callable $cb 실행할 probe 콜백
|
||||
* @param mixed $fallback 실패 시 반환할 폴백 값
|
||||
* @return mixed probe 결과 또는 폴백
|
||||
*/
|
||||
private function safeSystemProbe(string $label, callable $cb, mixed $fallback): mixed
|
||||
{
|
||||
try {
|
||||
return $cb();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('시스템 정보 수집 실패', [
|
||||
'item' => $label,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모리/디스크 사용량 조회 실패 시 사용할 폴백 배열을 반환합니다.
|
||||
*
|
||||
* total/used/free/percentage 형식을 memory_usage·disk_usage 와 동일하게 유지합니다.
|
||||
*
|
||||
* @return array 사용량 폴백 배열
|
||||
*/
|
||||
private function unknownUsage(): array
|
||||
{
|
||||
return [
|
||||
'total' => __('common.unknown'),
|
||||
'used' => __('common.unknown'),
|
||||
'free' => __('common.unknown'),
|
||||
'percentage' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 캐시를 정리합니다.
|
||||
*
|
||||
@@ -949,11 +993,16 @@ class SettingsService
|
||||
try {
|
||||
$connection = DB::connection();
|
||||
$driver = $connection->getDriverName();
|
||||
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? 'Unknown';
|
||||
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? __('common.unknown');
|
||||
|
||||
return ucfirst($driver).' '.$version;
|
||||
} catch (\Exception $e) {
|
||||
return 'Unknown';
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('시스템 정보 수집 실패', [
|
||||
'item' => 'mysql_version',
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return __('common.unknown');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -999,12 +1048,7 @@ class SettingsService
|
||||
}
|
||||
|
||||
if ($total <= 0) {
|
||||
return [
|
||||
'total' => __('common.unknown'),
|
||||
'used' => __('common.unknown'),
|
||||
'free' => __('common.unknown'),
|
||||
'percentage' => 0,
|
||||
];
|
||||
return $this->unknownUsage();
|
||||
}
|
||||
|
||||
$used = max(0, $total - $free);
|
||||
@@ -1034,12 +1078,7 @@ class SettingsService
|
||||
$free = is_numeric($freeRaw) ? (int) $freeRaw : 0;
|
||||
|
||||
if ($total <= 0) {
|
||||
return [
|
||||
'total' => __('common.unknown'),
|
||||
'used' => __('common.unknown'),
|
||||
'free' => __('common.unknown'),
|
||||
'percentage' => 0,
|
||||
];
|
||||
return $this->unknownUsage();
|
||||
}
|
||||
|
||||
$used = max(0, $total - $free);
|
||||
@@ -1077,7 +1116,7 @@ class SettingsService
|
||||
*
|
||||
* @return string CPU 정보 문자열
|
||||
*/
|
||||
private function getCpuInfo(): string
|
||||
protected function getCpuInfo(): string
|
||||
{
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1');
|
||||
|
||||
@@ -18,7 +18,7 @@ use Illuminate\Support\Str;
|
||||
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)의 업로드·조회·삭제를 처리한다.
|
||||
* 파일 저장은 코어 StorageInterface(CoreStorageDriver)를 통해서만 수행하고,
|
||||
* 저장 위치(disk/path)와 메타데이터를 template_layout_attachments 에 기록한다.
|
||||
* (Storage::disk() 직접 호출 금지 — CLAUDE.md 스토리지 규칙)
|
||||
* (Storage::disk() 직접 호출 금지 — 코어 스토리지 규칙)
|
||||
*/
|
||||
class TemplateLayoutAttachmentService
|
||||
{
|
||||
@@ -114,7 +114,7 @@ class TemplateLayoutAttachmentService
|
||||
* 첨부 파일을 삭제합니다 — 스토리지 파일 실삭제 후 DB 행 삭제.
|
||||
*
|
||||
* DB CASCADE 에 의존하지 않고 스토리지 파일을 명시적으로 삭제한다
|
||||
* (CLAUDE.md "DB CASCADE 의존 삭제 금지" — 파일 정리 보장).
|
||||
* (코어 규정: DB CASCADE 의존 삭제 금지 — 파일 정리 보장).
|
||||
*
|
||||
* @param TemplateLayoutAttachment $attachment 삭제할 첨부 파일
|
||||
* @return bool 삭제 성공 여부
|
||||
|
||||
@@ -136,7 +136,7 @@ editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄
|
||||
|
||||
### actionRecipes — 친화 명칭 → 핸들러 JSON
|
||||
|
||||
`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 CLAUDE.md CRITICAL RULES 의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다.
|
||||
`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 코어 핸들러 규칙의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다.
|
||||
|
||||
`build.handler` 는 리터럴 핸들러명 대신 `{{paramKey}}` **placeholder** 도 가능하다 — 호출할 핸들러를 응답값 등 데이터로 결정하는 provider-agnostic 액션(예: ecommerce 모듈 `requestPgPayment` "결제 진입" — 핸들러를 `{{response.data.pg_payment_handler}}` 데이터 칩으로 연결)에 쓴다. 결제 같은 **도메인 동작은 코어가 아니라 그 도메인 확장(모듈/플러그인/템플릿)이 자기 editor-spec `actionRecipes` 에 소유**한다 — 코어는 PG/도메인을 모른다. 확장 레시피는 로더가 `__source` 메타를 붙여 〔확장식별자〕 배지·'extension' 그룹으로 자동 분류한다. 라벨은 그 확장의 lang 네임스페이스를 명시한다(예: `$t:sirsoft-ecommerce.editor.action.request_pg_payment.label`).
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ test('unauthenticated_401 × no_token', async ({ page }) => {
|
||||
|
||||
`audit-branch.cjs` 의 `test-scenario-coverage` 룰이 매니페스트 axes ↔ docblock 매칭을 자동 검증.
|
||||
|
||||
## §8. 회귀 테스트 4단계 (CLAUDE.md 의무 절차 준수)
|
||||
## §8. 회귀 테스트 4단계
|
||||
|
||||
버그 수정 시 다음 4단계를 스킵 불가:
|
||||
|
||||
@@ -236,7 +236,7 @@ E2E spec 작성 중 발견한 무관 에러는 같은 세션에서 처리:
|
||||
|
||||
- **테스트 stale** (logic 정상, 테스트가 오래됨) → 테스트 수정
|
||||
- **로직 회귀** (테스트 정상, 로직이 의도와 어긋남) → 코드 수정
|
||||
- **데이터 구조 불일치** (양쪽 모두 오래됨) → PO 보고 + 보류
|
||||
- **데이터 구조 불일치** (양쪽 모두 오래됨) → 보고 + 보류
|
||||
|
||||
상세: `testing-guide.md` "무관 에러 처리 분기".
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-02
|
||||
|
||||
### Added
|
||||
|
||||
- 설정 복원 관련 안내 메시지 일본어 번역 추가 (`settings.backup_path_required`·`restore_success`·`restore_failed`·`restore_error`) — 백업 경로 미입력 안내와 설정 복원 성공/실패 결과가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -145,4 +145,8 @@ return [
|
||||
'source_vendor_missing' => 'ソースディレクトリにvendorがありません。composer installが実行されていない可能性があります。',
|
||||
'composer_failed_with_output' => 'composer installの実行に失敗しました。:output',
|
||||
],
|
||||
'backup_path_required' => 'バックアップパスを入力してください。',
|
||||
'restore_success' => '設定が正常に復元されました。',
|
||||
'restore_failed' => '設定の復元に失敗しました。',
|
||||
'restore_error' => '設定の復元中にエラーが発生しました。',
|
||||
];
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 core Japanese language pack (bundled)",
|
||||
"ja": "G7 コア 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
|
||||
@@ -21,6 +21,10 @@ return [
|
||||
'backup_success' => 'Database backup started successfully.',
|
||||
'backup_failed' => 'Failed to start database backup.',
|
||||
'backup_error' => 'An error occurred while backing up database.',
|
||||
'backup_path_required' => 'Please enter a backup path.',
|
||||
'restore_success' => 'Settings restored successfully.',
|
||||
'restore_failed' => 'Failed to restore settings.',
|
||||
'restore_error' => 'An error occurred while restoring settings.',
|
||||
'save_individual_failed' => 'Settings save failed: :error',
|
||||
|
||||
// App key related messages
|
||||
|
||||
@@ -21,6 +21,10 @@ return [
|
||||
'backup_success' => '데이터베이스 백업이 성공적으로 시작되었습니다.',
|
||||
'backup_failed' => '데이터베이스 백업에 실패했습니다.',
|
||||
'backup_error' => '데이터베이스 백업 중 오류가 발생했습니다.',
|
||||
'backup_path_required' => '백업 경로를 입력해주세요.',
|
||||
'restore_success' => '설정이 성공적으로 복원되었습니다.',
|
||||
'restore_failed' => '설정 복원에 실패했습니다.',
|
||||
'restore_error' => '설정 복원 중 오류가 발생했습니다.',
|
||||
'save_individual_failed' => '설정 저장에 실패했습니다: :error',
|
||||
|
||||
// 앱 키 관련 메시지
|
||||
|
||||
+2
-2
@@ -968,7 +968,7 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
|
||||
it('admin-card 안 4 섹션 description 이 .card-description 시맨틱이고 card-title 의 직계 sibling 이다 (#408)', () => {
|
||||
// _tab_report_policy.json 의 4 섹션 (자동 숨김, abuse_prevention, permissions, notification)
|
||||
// description 이 stack 안에 묻혀있으면 .card-title:has(+ .card-description) 셀렉터 불발 →
|
||||
// card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (PO 보고 #408)
|
||||
// card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (보고 #408)
|
||||
//
|
||||
// 기대: admin-card 직계 자식 순서가 [card-title, card-description, stack, ...] 이고,
|
||||
// card-description 의 className 에 'card-description' 시맨틱 자산이 포함되어야 함.
|
||||
@@ -1045,7 +1045,7 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
|
||||
});
|
||||
|
||||
it('board layout 의 모든 .card-description 은 Div 태그여야 한다 — Span/P 금지 (#408)', () => {
|
||||
// PO 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML
|
||||
// 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML
|
||||
// → div.card-description 만 허용.
|
||||
const layouts: Array<[string, any]> = [
|
||||
['mainLayout', mainLayout],
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
* 게시글 삭제로 함께 숨겨진(cascade) 댓글이 사용자 직접 삭제 댓글과 동일하게
|
||||
* "삭제된 댓글입니다" 로 마스킹되던 회귀의 방지.
|
||||
*
|
||||
* 정책(PO 확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다.
|
||||
* 정책(확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다.
|
||||
* 백엔드(CommentResource)가 is_cascade_deleted 플래그를 내려주고,
|
||||
* 레이아웃은 그 플래그로 분기를 결정한다.
|
||||
*
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => {
|
||||
// 카드는 Pending/Review 신고만 표시한다 (ReportRepository::getPendingAcrossBoards).
|
||||
// 배지는 신고 대상(게시글/댓글)의 상태가 아니라 신고 건 자체의 처리상태(status)를 보여준다.
|
||||
// 색상은 신고현황 페이지(admin_board_reports_index 의 status 컬럼) 와 동일한 매핑을 사용한다.
|
||||
// PO 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로".
|
||||
// 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로".
|
||||
const statusBadge = findById(communityExt as any, 'report_status_badge');
|
||||
expect(statusBadge).not.toBeNull();
|
||||
expect(statusBadge.if).toBe('{{!!report?.status_label}}');
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* @file board-copy-modal-info.test.tsx
|
||||
* @description 게시판 복사 확인 모달 - 안내 박스(배지 박스 + 불릿 3항목) 레이아웃 JSON 구조 검증
|
||||
*
|
||||
* 이슈 #413-16 후속(PO 요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 /
|
||||
* 이슈 #413-16 후속(요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 /
|
||||
* 새 슬러그 확인" 3항목으로 구조화. 안내 박스가 테두리+배경 컨테이너 안에 불릿 3개를
|
||||
* 포함하는지 JSON 트리로 검증한다 (DOM 비의존 구조 검증).
|
||||
*/
|
||||
|
||||
@@ -216,7 +216,7 @@ class CommentRepository implements CommentRepositoryInterface
|
||||
* 재귀적으로 모든 하위 자손 수를 계산합니다.
|
||||
*
|
||||
* tombstone(삭제) 댓글은 트리 구조 유지를 위해 노드로는 남기지만,
|
||||
* "답글 N" 집계 모수에서는 제외한다(PO 확정). 단, 삭제 댓글의
|
||||
* "답글 N" 집계 모수에서는 제외한다(확정 정책). 단, 삭제 댓글의
|
||||
* 살아있는 하위 자손은 계속 카운트해 누락되지 않게 한다.
|
||||
*
|
||||
* @param Collection $grouped parent_id로 그룹화된 댓글 컬렉션
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
* 증상2 — cascade 로 함께 숨겨진 댓글이 user 선삭제 댓글과 동일하게
|
||||
* "삭제된 댓글입니다" 로 마스킹됨 (cascade/user 구분 신호 부재)
|
||||
*
|
||||
* 정책(PO 확정):
|
||||
* 정책(확정):
|
||||
* - 첨부: 관리 권한자에게는 댓글과 동일하게 cascade 삭제분도 노출
|
||||
* - cascade 댓글: 관리 권한자에게는 마스킹 없이 원문 노출 (is_cascade_deleted 신호 제공)
|
||||
* - user 선삭제분: 기존대로 마스킹 유지
|
||||
|
||||
@@ -132,7 +132,7 @@ class PostDeleteCascadeTest extends BoardTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 복원 시 cascade 항목만 복원되고, 사용자가 먼저 지운 것은 trashed 로 유지된다 (PO 우려 차단).
|
||||
* 게시글 복원 시 cascade 항목만 복원되고, 사용자가 먼저 지운 것은 trashed 로 유지된다 (우려 차단).
|
||||
*
|
||||
* @effects cascaded_comment_restored_on_post_restore,cascaded_attachment_restored_on_post_restore,user_pre_deleted_comment_stays_trashed,user_pre_deleted_attachment_stays_trashed
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
*
|
||||
* 게시판 내부 검색은 별도 엔드포인트가 아니라 목록 조회(index)에 search 필터를 얹은 것이다.
|
||||
* 비밀글(is_secret)·블라인드(status=blinded) 게시글은 목록/검색 응답에서 본문 요약이
|
||||
* 권한과 무관하게 빈 문자열로 차단되어야 한다 (PO 확정: 목록 미리보기는 권한자도 숨김).
|
||||
* 권한과 무관하게 빈 문자열로 차단되어야 한다 (확정 정책: 목록 미리보기는 권한자도 숨김).
|
||||
*
|
||||
* 제목은 현행 유지(노출), 차단 대상은 content_preview 뿐.
|
||||
* 상세 본문(content) 권한자 노출은 기존 정책(PostBlindedAccessControlTest)이 보장 — 본 테스트 범위 밖.
|
||||
@@ -237,7 +237,7 @@ class PostSearchSecretPreviewTest extends BoardTestCase
|
||||
|
||||
/**
|
||||
* 비밀글 미리보기는 작성자 본인에게도 목록에서는 차단된다(빈 문자열).
|
||||
* (PO 확정: 목록 미리보기는 권한자도 숨김 — 본문은 상세에서만 노출)
|
||||
* (확정 정책: 목록 미리보기는 권한자도 숨김 — 본문은 상세에서만 노출)
|
||||
*
|
||||
* @scenario viewer=author
|
||||
*
|
||||
|
||||
@@ -183,7 +183,7 @@ class BoardDashboardServiceTest extends ModuleTestCase
|
||||
// FormatsBoardDate 표준형 규칙:
|
||||
// - 24시간 이상 차이: 올해는 'MM-DD', 작년 이전은 'YY-MM-DD'
|
||||
// 캡션이 게시판 날짜 표시 규칙과 일관되어야 한다는 회귀 가드.
|
||||
// (게시글 created_at 표시와 동일 컨벤션 — PO 시각 검증 피드백)
|
||||
// (게시글 created_at 표시와 동일 컨벤션 — 시각 검증 피드백)
|
||||
$today = CarbonImmutable::today();
|
||||
// 어제 자정에 마지막 스케줄러가 돈 상황을 시뮬레이션
|
||||
$yesterdayBoundary = $today->subDay()->setTime(0, 0, 0);
|
||||
@@ -223,7 +223,7 @@ class BoardDashboardServiceTest extends ModuleTestCase
|
||||
public function test_get_recent_posts_excludes_replies(): void
|
||||
{
|
||||
// 대시보드 "최신 게시글" 카드는 본문 글만 보여줘야 한다 (답글 Re: 제외).
|
||||
// PO 시각 검증 피드백: "최신 게시글이 최신순으로 안 나오는 것 같다" — 원인은 답글이 섞여 표시된 것.
|
||||
// 시각 검증 피드백: "최신 게시글이 최신순으로 안 나오는 것 같다" — 원인은 답글이 섞여 표시된 것.
|
||||
// PostRepository::getRecentAcrossBoards 가 whereNull('parent_id') 누락했던 회귀 가드.
|
||||
$parent = $this->makePost('원글');
|
||||
$reply = $this->makePost('Re: 원글');
|
||||
|
||||
@@ -17,7 +17,7 @@ description: |
|
||||
(composite/ImageGallery.tsx 의 downloadAuthenticatedFile 과 동일 패턴).
|
||||
· 레이아웃 카드 3곳 전환: 사용자 _post_attachments(권한 분기 보존),
|
||||
관리자 _post_card_content / _reply_card_content(무분기) 의 <A href> → Div + click 핸들러.
|
||||
- PO 정책 확정:
|
||||
- 정책 확정:
|
||||
· 범위 = 게시판만 3곳 (페이지 첨부는 별도). 비회원 = NULL 유지(현행). 성공 토스트 없음.
|
||||
· 서버(라우트/컨트롤러/서비스) 무변경 — optional.sanctum 유지가 비회원 NULL 정책과 정합.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ description: |
|
||||
canViewBlindedContent(관리자 OR 작성자 본인) 게이트 추가, 권한 없으면 null
|
||||
· CommentResource::getFilteredContent — 동일 결함 → 동일 게이트 추가
|
||||
· PostResource::toListArray — 블라인드 글 content_preview 를 빈 문자열로 차단
|
||||
- PO 정책 확정 (일반 관례): 원문 = 관리자 + 작성자 본인. 안내/사유/제목은 전원 노출.
|
||||
- 정책 확정 (일반 관례): 원문 = 관리자 + 작성자 본인. 안내/사유/제목은 전원 노출.
|
||||
- 비밀글(canViewSecretContent)과 달리 비밀번호 검증·posts.read-secret 은 인정하지 않음
|
||||
(블라인드는 운영 제재이므로 비밀번호로 해제되지 않음).
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ description: |
|
||||
identifier 는 stripBoardScopeRoles 로 제거 (attachRoles 의 전역 whereIn 으로 인한 교차 누수 차단)
|
||||
· 새 게시판 manager/step 역할은 생성 시 injectBoardRolesToPermissions 가 자동 주입
|
||||
· 비-스코프 역할(member 등) 은 그대로 보존
|
||||
- PO 정책 확정: 복제는 원본 manager/step 명단을 승계 (순수 생성 모드는 본인 단독)
|
||||
- 정책 확정: 복제는 원본 manager/step 명단을 승계 (순수 생성 모드는 본인 단독)
|
||||
- role 조회 로직은 BoardResource::getBoardRoleData 를 public static 으로 공유 (SSoT)
|
||||
|
||||
axes:
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ description: |
|
||||
고아 자식의 조상 체인을 따라 누락된 삭제 부모를 일괄 IN 조회(N+1 회피)로
|
||||
tombstone 노드 복구. 자식 없는 삭제 부모는 고아 자식이 없어 자연 제외.
|
||||
· countDescendants() 보정 — tombstone(삭제) 댓글은 "답글 N" 집계 모수에서 제외
|
||||
(트리 노드로는 유지, 살아있는 자손은 계속 카운트). PO 확정.
|
||||
(트리 노드로는 유지, 살아있는 자손은 계속 카운트). 확정 정책.
|
||||
· content 권한·표시(tombstone 안내 + 관리자 원문 토글)는 #413-50 기구현 재사용.
|
||||
· withTrashed=true(manager del_cmt 토글) 경로는 이미 전체 노출 → 고아 복구 비적용.
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ description: |
|
||||
(b) 저장 경로는 slug 기준({slug}/...)인데 삭제는 board_id 기준 디렉토리를
|
||||
지우려 해 경로 불일치로 아무 파일도 안 지워짐.
|
||||
|
||||
조치 (PO 결정 B안):
|
||||
조치 (결정 B안):
|
||||
· Repository 3종(Post/Comment/Attachment)에 forceDeleteByBoardId 신설
|
||||
(기존 softDeleteByBoardId 의 delete → forceDelete). 인터페이스 + 구현.
|
||||
· BoardService::deleteBoard() 의 자식 정리를 force 로 교체.
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ description: |
|
||||
미처리 → 비로그인·제3자도 게시판 내부 검색/목록 응답에서 비밀글 본문 요약 열람 가능 (보안 결함).
|
||||
- 수정: PostResource::getMaskedContentPreviewForList 신설 — status=Blinded 또는 is_secret 이면
|
||||
권한 무관 빈 문자열. toListArray 가 이 헬퍼를 사용.
|
||||
- PO 정책 확정: 목록 미리보기는 권한자(작성자/관리자)에게도 숨김(상세에서만 본문 노출).
|
||||
- 정책 확정: 목록 미리보기는 권한자(작성자/관리자)에게도 숨김(상세에서만 본문 노출).
|
||||
DB select 심층방어는 미포함(응답 계층 차단만 — 노출 경계 차단 관례).
|
||||
- 통합검색(/api/search)은 쿼리에서 published+non-secret 만 검색하므로 누수 없음(무관).
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: |
|
||||
정리되지 않아, 게시글만 trashed 되고 댓글·첨부는 살아남아 목록/상세에서 고아로
|
||||
노출되던 문제를 수정한다. 동시에 게시글 복원 시 cascade 로 지워진 항목만 선택
|
||||
복원하여, 삭제 이전에 사용자가 직접 지운 항목이 게시글 복원과 함께 부활하는
|
||||
문제(PO 우려)를 차단한다.
|
||||
문제(우려)를 차단한다.
|
||||
|
||||
근본 원인:
|
||||
· PostService::deletePost() 가 게시글 자신만 soft delete 하고 댓글·첨부는
|
||||
@@ -14,7 +14,7 @@ description: |
|
||||
· 첨부 테이블(board_attachments)에는 cascade 와 사용자 삭제를 구분할
|
||||
trigger_type 컬럼 자체가 없었다.
|
||||
|
||||
조치 (PO 결정 방법1: 연쇄 soft delete + cascade 플래그 기반 선별 복원):
|
||||
조치 (결정 방법1: 연쇄 soft delete + cascade 플래그 기반 선별 복원):
|
||||
· board_comments.trigger_type enum 에 'cascade' 추가 (마이그레이션).
|
||||
· board_attachments 에 trigger_type enum 컬럼 신설 (+ board_id/post_id/trigger_type
|
||||
복합 인덱스).
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// e2e:allow MP13 — i18n/UX 소품 묶음(라벨 표시·링크 분기·모달 조건부 필드·placeholder 치환). 핵심 회귀는 Vitest(모듈 22 + 템플릿 13)로 커버, 라이브 검수는 계획서 §14 Chrome MCP 매트릭스(PO 환경)로 수행.
|
||||
// e2e:allow MP13 — i18n/UX 소품 묶음(라벨 표시·링크 분기·모달 조건부 필드·placeholder 치환). 핵심 회귀는 Vitest(모듈 22 + 템플릿 13)로 커버, 라이브 검수는 계획서 §14 Chrome MCP 매트릭스(검수 환경)로 수행.
|
||||
/**
|
||||
* bulkUpdateHandlers 테스트
|
||||
*
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
* 배경:
|
||||
* - partial_cancelled 주문상태 제거 후, 일부 취소 사실은 파생 플래그 is_partially_cancelled 로 표시한다.
|
||||
* - DataGrid order_status 컬럼 cellChildren 에 StatusBadge + 보조 배지를 평면 형제로 두면
|
||||
* 목록 셀과 모바일 카드(responsiveBreakpoint)에서 한 줄로 나란히 떠 깨진다(PO 발견).
|
||||
* 목록 셀과 모바일 카드(responsiveBreakpoint)에서 한 줄로 나란히 떠 깨진다(검수 발견).
|
||||
* → 둘을 flex-col 묶음 Div 로 감싸 상태 배지 아래(두 줄)에 보조 배지를 배치한다.
|
||||
* DataGrid 는 동일 columns 정의를 목록·카드 양쪽에 재사용하므로 한 곳 수정으로 둘 다 적용된다.
|
||||
*
|
||||
|
||||
+1
-1
@@ -406,7 +406,7 @@ describe('DataGrid (_transactions_table.json)', () => {
|
||||
expect(edit).toBeTruthy();
|
||||
// 비적립계 또는 권한없음이면 비활성 — Resource 가 can_edit = can_manage && isEarning 으로 계산
|
||||
expect(edit.disabledField).toBe('abilities.can_edit');
|
||||
// 원장 불변: 삭제 액션은 존재하지 않는다 (PO 확정)
|
||||
// 원장 불변: 삭제 액션은 존재하지 않는다 (정책 확정)
|
||||
const ids = grid.props.rowActions.map((a: any) => a.id);
|
||||
expect(ids).not.toContain('delete');
|
||||
expect(ids).not.toContain('manual');
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* @description
|
||||
* - 마일리지 설정은 이커머스 환경설정(admin_ecommerce_settings.json)의 서브탭으로 통합됨
|
||||
* (별도 독립 화면 아님 — PO 합의사항)
|
||||
* (별도 독립 화면 아님 — 합의사항)
|
||||
* - 환경설정 메인: mileage 탭 등록 + 탭 콘텐츠 partial(_tab_mileage.json) 참조 + 탭 if 가드
|
||||
* - 탭 콘텐츠 partial 5종(탭/기본/통화테이블/통화카드/유효기간/소멸알림) 구조 검증
|
||||
* - 카드 4종(기본/통화/유효기간/소멸알림), 토글 3종, 적립시점 기본=구매확정
|
||||
@@ -180,7 +180,7 @@ describe('마일리지 탭 콘텐츠 (_tab_mileage.json)', () => {
|
||||
});
|
||||
|
||||
it('마일리지 내역 보기 버튼이 기본 설정 카드 헤더에 있고 내역 화면으로 이동한다', () => {
|
||||
// 공간 절약 위해 별도 헤더 행 대신 기본 설정 카드 우측 상단으로 이동 (PO 요청)
|
||||
// 공간 절약 위해 별도 헤더 행 대신 기본 설정 카드 우측 상단으로 이동 (요청)
|
||||
const btn = findById(basicCard, 'view_transactions_button');
|
||||
expect(btn, '내역 보기 버튼이 기본 설정 카드에 없다').toBeTruthy();
|
||||
expect(btn.actions[0].handler).toBe('navigate');
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ describe('마이페이지 프로필 내 마일리지 카드 주입 (mypage-profi
|
||||
it('사용자 화면(마이페이지) 카드는 ExtensionBadge 를 사용하지 않는다 (회귀 차단)', () => {
|
||||
// ExtensionBadge 는 관리자 템플릿(sirsoft-admin_basic)에만 존재하는 컴포넌트로,
|
||||
// 사용자 템플릿(sirsoft-basic)에는 미등록이라 사용자 화면에 주입 시 "컴포넌트를 찾을 수 없습니다" 렌더 실패.
|
||||
// PO 정책: 사용자 화면에서 ExtensionBadge 사용 금지.
|
||||
// 정책: 사용자 화면에서 ExtensionBadge 사용 금지.
|
||||
const json = jsonOf(mypageCard);
|
||||
expect(json).not.toContain('ExtensionBadge');
|
||||
});
|
||||
|
||||
+2
-2
@@ -258,7 +258,7 @@ describe('productFormLayouts', () => {
|
||||
* "Failed to execute action: navigate" 오류 토스트가 추가로 발생하고
|
||||
* 목록 페이지로 이동하지 않음 (성공 토스트 + 오류 토스트 2건 동시 노출).
|
||||
* 원인: onSuccess 시퀀스의 navigate 핸들러가 params.url 로 경로를 전달했으나,
|
||||
* G7 navigate 핸들러는 params.path 만 인식한다 (CLAUDE.md "navigate path 필수").
|
||||
* G7 navigate 핸들러는 params.path 만 인식한다 (코어 규정: navigate path 필수).
|
||||
* url 키는 무시되어 finalPath=undefined → navigate throw → 오류 토스트.
|
||||
* 동일 모듈의 다른 navigate(주문 상세/브랜드 목록 등)는 모두 path 사용.
|
||||
* 해결: onSuccess navigate 의 params.url → params.path 로 정정.
|
||||
@@ -527,7 +527,7 @@ describe('productFormLayouts', () => {
|
||||
findNodeById(otherInfoPartial, 'field_max_purchase_qty'),
|
||||
]);
|
||||
// $event.target.value 는 허용하되, parseInt($event)/Number($event) 같은
|
||||
// 이벤트 객체 직접 숫자화 패턴은 금지 (CLAUDE.md 데이터 바인딩 규정)
|
||||
// 이벤트 객체 직접 숫자화 패턴은 금지 (코어 데이터 바인딩 규정)
|
||||
expect(serialized).not.toMatch(/parseInt\(\$event\)/);
|
||||
expect(serialized).not.toMatch(/Number\(\$event\)/);
|
||||
});
|
||||
|
||||
+2
-2
@@ -95,7 +95,7 @@ describe('header-currency-selector-user.json — 헤더 공용 셀렉터 2섹션
|
||||
});
|
||||
|
||||
it('루트는 if(마운트 게이트) 없이 항상 마운트 + className 으로 기본 hidden 토글 (깜빡임 방지)', () => {
|
||||
// PO 회귀(유저 헤더): if 마운트/언마운트 대신 항상 마운트 + className 기본 hidden(display:none) → 조건 충족 시 해제.
|
||||
// 회귀(유저 헤더): if 마운트/언마운트 대신 항상 마운트 + className 기본 hidden(display:none) → 조건 충족 시 해제.
|
||||
expect(root.if).toBeUndefined();
|
||||
const cls = root.props?.className ?? '';
|
||||
expect(cls).toContain("'hidden'");
|
||||
@@ -160,7 +160,7 @@ describe('header-currency-selector-admin.json — 관리자 헤더 2섹션', ()
|
||||
});
|
||||
|
||||
it('루트는 if(마운트 게이트) 없이 항상 마운트 + className 으로 기본 hidden 토글 (깜빡임 방지)', () => {
|
||||
// PO 회귀: if 로 마운트/언마운트하면 첫 페인트에 잠깐 보였다 사라진다.
|
||||
// 회귀: if 로 마운트/언마운트하면 첫 페인트에 잠깐 보였다 사라진다.
|
||||
// 항상 마운트하되 className 에 기본 hidden(display:none) 을 깔고 조건 충족 시에만 해제한다.
|
||||
expect(root.if).toBeUndefined();
|
||||
const cls = root.props?.className ?? '';
|
||||
|
||||
+1
-1
@@ -1201,7 +1201,7 @@ export function testShippingApiHandler(
|
||||
G7Core.state.set?.({ apiTestLoading: true, apiTestResult: null });
|
||||
|
||||
// apiCall 구조: URL 은 액션 top-level target, method/body 는 params,
|
||||
// onSuccess/onError 도 top-level (CLAUDE.md apiCall 규약). 백엔드 test-api-call
|
||||
// onSuccess/onError 도 top-level (코어 apiCall 규약). 백엔드 test-api-call
|
||||
// 엔드포인트가 사용자가 입력한 설정으로 외부 API 를 대신 호출하고 결과를 서빙한다.
|
||||
G7Core.dispatch({
|
||||
handler: 'apiCall',
|
||||
|
||||
+1
-1
@@ -421,7 +421,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, PO 2026-06-22).",
|
||||
"comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, 2026-06-22).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{order.data?.payment?.requires_pg_cancellation === true}}",
|
||||
|
||||
@@ -339,7 +339,7 @@ enum OrderStatusEnum: string
|
||||
* - 취소(CANCELLED) 진입은 이 목록에 포함하지 않는다(canTransitionTo 가 별도 통과 처리).
|
||||
* - 현재 상태가 CANCELLED 면 판매 반영 상태(salesEligibleStatuses)로만 복원 허용
|
||||
* (취소된 주문/옵션을 다시 판매 진행 상태로 되살리는 reactivation 운영 기능 — 재고 재차감 동반.
|
||||
* 결제 전 상태(pending_order/pending_payment)로의 복원은 차단. PO 2026-06-23).
|
||||
* 결제 전 상태(pending_order/pending_payment)로의 복원은 차단. 2026-06-23).
|
||||
*
|
||||
* @return array<self>
|
||||
*/
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class MileageTransactionResource extends BaseApiResource
|
||||
* 행 레벨 능력을 계산합니다.
|
||||
*
|
||||
* 표준 can_manage 에 더해, 행 편집 메뉴('사유·기간 변경') 게이팅용 can_edit 를 노출합니다.
|
||||
* can_edit = manage 권한 보유 && 적립계 거래 (비적립계 행은 메뉴 비활성 — PO 합의 C-2).
|
||||
* can_edit = manage 권한 보유 && 적립계 거래 (비적립계 행은 메뉴 비활성 — 합의 C-2).
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return array<string, bool> 능력 불리언 맵
|
||||
|
||||
@@ -175,7 +175,7 @@ class MileageTransactionListener implements HookListenerInterface
|
||||
|
||||
// 취소 전이: 기적립 건 회수.
|
||||
// 취소는 옵션 단위로 처리되므로 회수도 옵션(CANCELLED) 기준이다 — 부분취소든 전체취소든
|
||||
// 취소된 옵션마다 이 분기를 타 개별 회수된다(별도 partial_cancelled 상태 불요). PO 2026-06-22
|
||||
// 취소된 옵션마다 이 분기를 타 개별 회수된다(별도 partial_cancelled 상태 불요). 2026-06-22
|
||||
if ($newStatus === OrderStatusEnum::CANCELLED) {
|
||||
$this->mileageService->cancelEarnForOption($order, $option);
|
||||
}
|
||||
@@ -186,7 +186,7 @@ class MileageTransactionListener implements HookListenerInterface
|
||||
*
|
||||
* mileage.enabled 토글은 주문 당시 적립액 "계산"(OrderCalculationService) 단계에서만 작용한다.
|
||||
* 리스너는 이미 계산·저장된 subtotal_earned_points_amount 를 실행할 뿐이므로 enabled 를 재검사하지 않는다.
|
||||
* (저장값이 0 이면 Service 가 자연 no-op — PO 확정: 계산 완료된 적립은 토글로 막지 않음)
|
||||
* (저장값이 0 이면 Service 가 자연 no-op — 정책 확정: 계산 완료된 적립은 토글로 막지 않음)
|
||||
*
|
||||
* @param Order $order 주문
|
||||
* @param OrderOption $option 주문옵션
|
||||
|
||||
@@ -7,12 +7,12 @@ use App\Models\User;
|
||||
use Modules\Sirsoft\Ecommerce\Services\UserMileageService;
|
||||
|
||||
/**
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터 정리 리스너 (PO 확정 #8)
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터 정리 리스너 (정책 확정 #8)
|
||||
*
|
||||
* 코어 UserService 의 탈퇴(before_withdraw)·삭제(before_delete) 훅을 구독해,
|
||||
* 활성 lot 을 expired 거래로 소멸 기록(감사 흐름 보존)한 뒤 원장·잔액 캐시 행을 명시 삭제합니다.
|
||||
* 탈퇴는 soft 상태 변경이라 cascadeOnDelete 가 발화하지 않으므로 명시 정리가 필수입니다
|
||||
* (CLAUDE.md "DB CASCADE 의존 삭제 금지"). cascade 는 삭제 경로의 안전망으로만 둡니다.
|
||||
* (코어 규정: DB CASCADE 의존 삭제 금지). cascade 는 삭제 경로의 안전망으로만 둡니다.
|
||||
*/
|
||||
class UserMileageCleanupListener implements HookListenerInterface
|
||||
{
|
||||
|
||||
@@ -526,7 +526,7 @@ class Order extends Model
|
||||
*
|
||||
* 부분취소는 별도 주문 상태(partial_cancelled)가 아니라 "취소된 옵션이 있으면서
|
||||
* 잔여 활성 옵션도 존재"하는 파생 상태다. 주문 상태(order_status)는 잔여 활성 옵션 기준으로
|
||||
* 결정되고, "일부가 취소되었다"는 사실은 이 플래그로 노출한다 (PO 2026-06-22, partial_cancelled 제거).
|
||||
* 결정되고, "일부가 취소되었다"는 사실은 이 플래그로 노출한다 (2026-06-22, partial_cancelled 제거).
|
||||
*
|
||||
* @return bool 일부 옵션만 취소된 주문이면 true
|
||||
*/
|
||||
|
||||
@@ -664,7 +664,7 @@ class OrderAdjustmentService
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
// 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼도 재산출 (PO 확정)
|
||||
// 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼도 재산출 (정책 확정)
|
||||
$currencySnapshot = $order->currency_snapshot;
|
||||
|
||||
// 재계산 결과에서 옵션별 금액 반영
|
||||
|
||||
@@ -118,7 +118,7 @@ class OrderCalculationService
|
||||
// 쿠폰 조합 가능 여부 검증 (is_combinable) — 전체 슬롯 통합 1회 (A15/MP06)
|
||||
// 슬롯별 독립 검증은 "상품별(false) 1개 + 주문(true) 1개" 같은 슬롯 교차 조합을
|
||||
// 각 슬롯 count=1 이라 놓친다. 적용 대상 전체 쿠폰을 한 집합으로 모아 1회 검증한다.
|
||||
// 스냅샷 모드(환불 재계산)에서도 동일 검증한다(PO 2026-06-19 확정).
|
||||
// 스냅샷 모드(환불 재계산)에서도 동일 검증한다(2026-06-19 확정).
|
||||
$allAppliedCoupons = $this->collectAllCouponsForCombination($coupons, $itemCouponIssues);
|
||||
$combinationViolations = $this->findNonCombinableViolations($allAppliedCoupons);
|
||||
foreach ($combinationViolations as $violationIssue) {
|
||||
@@ -874,7 +874,7 @@ class OrderCalculationService
|
||||
|
||||
// 마일리지 기능 비활성 시 주문 시점 적립 "계산" 자체를 수행하지 않는다 (전부 0).
|
||||
// mileage_value/mileage_type 은 상품 옵션(상품 등록 당시) 속성이므로,
|
||||
// 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (PO 확정).
|
||||
// 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (정책 확정).
|
||||
if (! (bool) $this->settingsService->getSetting('mileage.enabled', false)) {
|
||||
$pointsPerItem[$optionId] = 0;
|
||||
|
||||
|
||||
@@ -683,7 +683,7 @@ class OrderCancellationService
|
||||
}
|
||||
// 부분취소는 별도 주문 상태(partial_cancelled)를 두지 않는다 — 옵션은 이미 CANCELLED 로 전이됐고,
|
||||
// 주문 상태는 아래 syncParentOrderStatus 가 "취소 제외 잔여 활성 옵션" 기준으로 파생 결정한다.
|
||||
// (잔여 옵션이 모두 취소된 경우 syncParentOrderStatus 가 CANCELLED 로 전이.) — PO 2026-06-22
|
||||
// (잔여 옵션이 모두 취소된 경우 syncParentOrderStatus 가 CANCELLED 로 전이.) — 2026-06-22
|
||||
|
||||
$order->update($updateData);
|
||||
|
||||
|
||||
@@ -603,7 +603,7 @@ class OrderOptionService
|
||||
*
|
||||
* 부분취소는 별도 주문 상태가 아니라 "취소 옵션 + 잔여 활성 옵션 공존" 파생 상태다.
|
||||
* 따라서 주문 상태는 항상 잔여 활성 옵션 기준으로 결정하고, 부분취소 여부는
|
||||
* Order::isPartiallyCancelled() 파생 플래그로 노출한다 (PO 2026-06-22, partial_cancelled 제거).
|
||||
* Order::isPartiallyCancelled() 파생 플래그로 노출한다 (2026-06-22, partial_cancelled 제거).
|
||||
*
|
||||
* @param int $orderId 동기화할 주문 ID
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
*
|
||||
* 유저 locale 을 환경설정의 통화별 언어(currencies[].locales) 매핑과 대조해 부여 통화를 결정한다.
|
||||
*
|
||||
* 매칭 우선순위(D-SIGNUP, PO 확정):
|
||||
* 매칭 우선순위(D-SIGNUP, 정책 확정):
|
||||
* 1) locale 이 매칭되는 통화가 1개 → 그 통화
|
||||
* 2) 여러 통화에 중복 매칭 → 매칭된 통화 중 is_default 통화 우선
|
||||
* 3) 매칭 없음/모호 → is_default 통화 → 그래도 없으면 default_currency 폴백
|
||||
|
||||
@@ -731,7 +731,7 @@ class UserMileageService
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — PO 확정 #8)
|
||||
* 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — 정책 확정 #8)
|
||||
*
|
||||
* 활성 lot 을 expired 거래로 소멸 기록(활동로그에 금액·통화·회원ID 잔존)합니다.
|
||||
* 거래/캐시 행은 이후 cascade 또는 명시 삭제로 함께 사라집니다.
|
||||
@@ -764,7 +764,7 @@ class UserMileageService
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (PO 확정 #8).
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (정책 확정 #8).
|
||||
*
|
||||
* 활성 lot 을 expired 거래로 소멸 기록(활동로그 보존)한 뒤, 원장·잔액 캐시 행을
|
||||
* 동일 트랜잭션에서 삭제합니다. CASCADE 는 안전망으로만 두고 명시 삭제합니다
|
||||
@@ -986,7 +986,7 @@ class UserMileageService
|
||||
* 마일리지 사용이 가능한지 여부를 반환합니다.
|
||||
*
|
||||
* 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면
|
||||
* 마일리지 사용을 허용하지 않는다(PO 정책). 마일리지는 base_currency 기준으로 사용/정산되므로
|
||||
* 마일리지 사용을 허용하지 않는다(정책). 마일리지는 base_currency 기준으로 사용/정산되므로
|
||||
* base 규칙이 없으면 사용 단위·최소 사용액·한도를 알 수 없어 사용 자체가 불가하다.
|
||||
*
|
||||
* @return bool 기본 통화 마일리지 사용 규칙이 설정되어 있으면 true
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ class CheckoutVerificationGuardTest extends ModuleTestCase
|
||||
*
|
||||
* 기존 G1(훅 발화)·코어 가드(enforce 예외)·리스너 구독 테스트는 각 조각만 따로 검증한다.
|
||||
* 이 테스트는 그 셋이 실제 HTTP 흐름에서 연결되어 최종적으로 428 이 나오는지를 통합 검증한다.
|
||||
* (PO 검수: 조각 테스트는 green 인데 실제로 본인인증이 발동하지 않던 결함의 회귀 가드)
|
||||
* (검수: 조각 테스트는 green 인데 실제로 본인인증이 발동하지 않던 결함의 회귀 가드)
|
||||
*
|
||||
* @scenario policy=checkout, enabled=on, actor=member, verified_state=unverified, path=http_order_create
|
||||
*
|
||||
|
||||
@@ -120,7 +120,7 @@ await page.route('https://api.tosspayments.com/v1/payments/confirm', route => ro
|
||||
```
|
||||
|
||||
분기별 가맹점 측 처리(주문 상태 전환, 재고 차감, 알림 발송 등) 가 정확한지 검증.
|
||||
실 카드 결제 자동화는 권장하지 않음 — PO 수동 검증.
|
||||
실 카드 결제 자동화는 권장하지 않음 — 수동 검증.
|
||||
|
||||
## 참고
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ class OrderStatusTransitionTest extends ModuleTestCase
|
||||
|
||||
/**
|
||||
* 취소 → 판매 반영 상태 복원(reactivation)은 허용, 결제 전 상태 복귀는 차단됨을 명시 단언합니다.
|
||||
* (취소 주문/옵션을 다시 판매 진행 상태로 되살리는 운영 기능 — 재고 재차감 동반. PO 2026-06-23)
|
||||
* (취소 주문/옵션을 다시 판매 진행 상태로 되살리는 운영 기능 — 재고 재차감 동반. 2026-06-23)
|
||||
*/
|
||||
public function test_cancelled_reactivation_to_sales_status_allowed_but_pre_payment_blocked(): void
|
||||
{
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInt
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* UserMileageCleanupListener 테스트 (회원 탈퇴/삭제 시 마일리지 정리 — PO 확정 #8)
|
||||
* UserMileageCleanupListener 테스트 (회원 탈퇴/삭제 시 마일리지 정리 — 정책 확정 #8)
|
||||
*/
|
||||
class UserMileageCleanupListenerTest extends ModuleTestCase
|
||||
{
|
||||
|
||||
@@ -482,7 +482,7 @@ class OrderRepositoryTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문대기(pending_order)는 통계에서 숨김 유지된다 (PO 확정, listHiddenValues).
|
||||
* 주문대기(pending_order)는 통계에서 숨김 유지된다 (정책 확정, listHiddenValues).
|
||||
*/
|
||||
public function test_user_statistics_hides_pending_order(): void
|
||||
{
|
||||
|
||||
@@ -367,7 +367,7 @@ class OrderResourceFieldsTest extends ModuleTestCase
|
||||
|
||||
/**
|
||||
* OrderResource 의 is_partially_cancelled 파생 플래그가 "취소 옵션 ∧ 잔여 활성 옵션" 일 때만 true 인지 확인.
|
||||
* (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — PO 2026-06-22.)
|
||||
* (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — 2026-06-22.)
|
||||
*/
|
||||
public function test_order_resource_exposes_is_partially_cancelled_flag(): void
|
||||
{
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
* 마일리지 기본 통화 사용 규칙 게이트 테스트 (M2)
|
||||
*
|
||||
* 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면
|
||||
* 마일리지 사용이 불가해야 한다(PO 정책).
|
||||
* 마일리지 사용이 불가해야 한다(정책).
|
||||
* - isMileageUsable(): base 규칙 존재 시 true, 없으면 false
|
||||
* - getMaxUsable(): base 규칙 없으면 0
|
||||
* - validateUsage(): base 규칙 없으면 MileageValidationException
|
||||
|
||||
+2
-2
@@ -6820,7 +6820,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (PO 확정).
|
||||
* §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (정책 확정).
|
||||
*
|
||||
* 결함: buildOptionUpdates 가 plain subtotal_* 만 갱신하고 mc_subtotal_* 는 부분취소 후 stale.
|
||||
*/
|
||||
@@ -6869,7 +6869,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (PO 추가 요구)
|
||||
// §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (추가 요구)
|
||||
// OrderCalculation 테스트 수준의 정확값 검증. 각 케이스는 적립 재안분값이
|
||||
// 환불 우선순위에 무관(우선순위는 refund split 만 변경)함을 함께 단언한다.
|
||||
// ================================================================
|
||||
|
||||
+1
-1
@@ -1208,7 +1208,7 @@ class OrderProcessingServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* PO 검증 4조합: base/order 통화 전 경우의 수.
|
||||
* 검증 4조합: base/order 통화 전 경우의 수.
|
||||
*
|
||||
* @return array<string, array{0:string,1:string,2:float,3:array,4:int}>
|
||||
*/
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ class OrderServiceStockRedeductTest extends ModuleTestCase
|
||||
public function test_update_to_non_sales_status_is_blocked_by_transition_rule(): void
|
||||
{
|
||||
// 전이 규칙 도입(A30) 이후: 취소 → 결제 전 상태(결제대기) 복귀는 전이 게이트가 차단한다.
|
||||
// (취소 복원은 판매 반영 상태로만 허용 — PO 2026-06-23.)
|
||||
// (취소 복원은 판매 반영 상태로만 허용 — 2026-06-23.)
|
||||
// 차단되므로 update 가 OrderProcessingException 을 던지고, 재차감은 물론 상태/재고 모두 불변.
|
||||
[$order, $option] = $this->createCancelledOrderWithRestoredStock(stock: 8, quantity: 3);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ description: |
|
||||
shouldConvertToFullCancel 이 담당(프론트 isFullCancel 휴리스틱 제거).
|
||||
- U20③: 취소 차단 메시지를 쿠폰 단정 없이 일반화.
|
||||
- U20④: cancelled_at native 컬럼에 전체·부분취소 모두 기록 + Resource 노출(cancelled_at_formatted).
|
||||
- 부분취소 모델 변경(PO 2026-06-22): partial_cancelled 주문 상태를 제거. 부분취소는 별도 상태가 아니라
|
||||
- 부분취소 모델 변경(2026-06-22): partial_cancelled 주문 상태를 제거. 부분취소는 별도 상태가 아니라
|
||||
"취소 옵션 + 잔여 활성 옵션 공존"이며, 주문 상태는 잔여 활성 옵션 기준으로 파생(syncParentOrderStatus).
|
||||
잔여 항목 재취소는 진행 상태(payment_complete 등)가 취소 가능 목록에 있어 자연 허용. 잔여 전량 취소 시 CANCELLED 전이.
|
||||
"일부 취소됨" 표시는 파생 플래그 is_partially_cancelled(취소옵션∧활성옵션)로 노출(목록·상세 보조 배지).
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ description: |
|
||||
(SHIPPING_HOLD↔PREPARING, SHIPPING→{SHIPPING_READY,PREPARING}, DELIVERED→SHIPPING)만 추가 허용.
|
||||
- CONFIRMED 되돌림 전부 금지.
|
||||
- CANCELLED 발 전이는 판매 반영 상태(salesEligibleStatuses)로의 복원(reactivation)만 허용
|
||||
(재고 재차감 동반 운영 기능, PO 2026-06-23). 결제 전 상태(pending_order/pending_payment) 복귀는 차단.
|
||||
(재고 재차감 동반 운영 기능, 2026-06-23). 결제 전 상태(pending_order/pending_payment) 복귀는 차단.
|
||||
- 취소(CANCELLED) 진입은 게이트가 통과 처리(OrderCancellationService 전담, 이중 관리 방지).
|
||||
- 3경로 모두 FormRequest withValidator 1차 차단(422 + 다국어) + Service 진입부 2차 도메인 불변식 가드.
|
||||
- 일괄은 all-or-nothing: 1건이라도 위반이면 전체 422, DB 무변경.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# base 통화 정책 axis 는 PHPUnit(OrderProcessingService/buildCurrencySnapshot/OrderAdjustmentService/
|
||||
# StoreEcommerceSettingsRequest)·Playwright 가 커버하며 green 이다.
|
||||
feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — PO 신규)
|
||||
feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — 신규)
|
||||
|
||||
description: |
|
||||
기본통화 = 모든 금액 테이블의 base(진실값). 비-base 선택 시 base 정수 × 스냅샷 환율로 PG 청구·통지·환불.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# 가입 통화 부여 axis 는 PHPUnit(AssignDefaultCurrencyOnRegisterListener/StoreEcommerceSettingsRequest
|
||||
# locales 검증)·Vitest(통화별 언어 Tag UI)가 커버하며 green 이다.
|
||||
feature: 가입 시 locale 기반 통화 부여 + 통화별 언어 다중 매핑 (A4 — PO 신규)
|
||||
feature: 가입 시 locale 기반 통화 부여 + 통화별 언어 다중 매핑 (A4 — 신규)
|
||||
|
||||
description: |
|
||||
가입/회원생성 시 브라우저/locale 기반 기본통화 부여(환경설정>언어/통화의 통화별 언어 Tag 매핑 기준).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# 유저별 영속 통화 axis 는 PHPUnit(EcommerceUserProfile/Repository/통화 저장 컨트롤러)·
|
||||
# Vitest(설정 UI)·Playwright(설치 게이트)가 커버하며 green 이다.
|
||||
feature: 유저별 영속 통화 — user-profile 확장 + 4곳 설정 (A3·A5 — PO 신규)
|
||||
feature: 유저별 영속 통화 — user-profile 확장 + 4곳 설정 (A3·A5 — 신규)
|
||||
|
||||
description: |
|
||||
유저별 통화 = 커머스 책임. 코어 users 미변경, 모듈 ecommerce_user_profiles 테이블 + layout_extensions.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Playwright 코어 E2E 설정 (G7 코어 영역)
|
||||
*
|
||||
* 위치 규약 (CLAUDE.md 코어/확장 분리 원칙):
|
||||
* 위치 규약 (코어/확장 분리 원칙):
|
||||
* - 코어 spec : tests/Playwright/specs/
|
||||
* - 모듈 spec : modules/_bundled/{id}/tests/Playwright/specs/ (모듈 자체 config)
|
||||
* - 플러그인 : plugins/_bundled/{id}/tests/Playwright/specs/ (플러그인 자체 config)
|
||||
|
||||
+4
-4
@@ -490,7 +490,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
||||
const modalText = JSON.stringify(publishModal);
|
||||
|
||||
it('발행 버튼은 isPublishing 동안 spinner + "발행 중..." 라벨 노출 + 양쪽 버튼 disabled', () => {
|
||||
// PO 피드백: 이전엔 closeModal 이 apiCall 앞에 있어 클릭 즉시 모달이 닫혀 사용자가
|
||||
// 피드백: 이전엔 closeModal 이 apiCall 앞에 있어 클릭 즉시 모달이 닫혀 사용자가
|
||||
// 발행 진행 상태를 알 수 없었음. _local.isPublishing 플래그로 발행 중 시각 피드백 +
|
||||
// 양쪽 버튼 disabled (취소 버튼도 발행 중엔 막아 도중 닫힘 방지).
|
||||
// JSON.stringify 기본 출력은 공백 없는 형식 (콜론 뒤 공백 없음).
|
||||
@@ -516,7 +516,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
||||
});
|
||||
});
|
||||
|
||||
it('정책 버전 가이드는 카드 hint 영역에 2줄 P 로 통합된다 (PO 피드백: 토글/박스 제거)', () => {
|
||||
it('정책 버전 가이드는 카드 hint 영역에 2줄 P 로 통합된다 (피드백: 토글/박스 제거)', () => {
|
||||
const box = findById(root, 'policy_version_guide_box');
|
||||
expect(box).not.toBeNull();
|
||||
const serialized = JSON.stringify(box);
|
||||
@@ -666,7 +666,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
||||
expect(layoutJson).toContain('partials/_shared/_policy_version_snapshot_modal.json');
|
||||
});
|
||||
|
||||
it('현재 버전 v배지 옆에 본문 보기 Button 이 노출된다 (PO 피드백 — 평문 + 명시 액션 분리)', () => {
|
||||
it('현재 버전 v배지 옆에 본문 보기 Button 이 노출된다 (피드백 — 평문 + 명시 액션 분리)', () => {
|
||||
// v배지는 평문 Span (text 'v...' 으로 항상 표시), 본문 보기 Button 은 if 분기 + sequence
|
||||
expect(layoutJson).toContain('"text":"v{{gdprPolicyVersionCurrent?.data?.data?.version ?? \'?\'}}"');
|
||||
expect(layoutJson).toContain('"if":"{{gdprPolicyVersionCurrent?.data?.data?.version}}"');
|
||||
@@ -683,7 +683,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
||||
expect(layoutJson).toContain('"viewingPolicyVersion":"{{row?.version}}"');
|
||||
});
|
||||
|
||||
it('policy_version_guide_box wrapper 는 라벨 + hint 2줄 구조의 단순 Div (PO 피드백: 토글/박스 제거)', () => {
|
||||
it('policy_version_guide_box wrapper 는 라벨 + hint 2줄 구조의 단순 Div (피드백: 토글/박스 제거)', () => {
|
||||
const box = findById(root, 'policy_version_guide_box');
|
||||
expect(box).not.toBeNull();
|
||||
// wrapper 는 className 없음 (라벨 + hint 두 줄을 단순 그룹핑)
|
||||
|
||||
+10
-10
@@ -84,13 +84,13 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
// 1. ResponseHelper::success($msg, $data) 응답은 데이터소스에서 그대로 보존되므로 단일 객체 응답은 `<id>.data.<key>` 한 단계 경로.
|
||||
// 과거 통합본에서 `?.data?.data?` 두 단계로 작성되어 항상 undefined → if false → 배너 미출력 사고.
|
||||
// 2. _global.gdprBannerDismissed 만으로는 새로고침 시 휘발 — 서버 조회 (gdprMyConsent.has_consented) 가 우선이며 클릭 직후 즉시 사라지는 효과만 글로벌 상태가 보장.
|
||||
// 3. needs_renewal=true 일 때도 배너만 노출 (모달 폐기 — PO 결정 모달/배너 중복 제거). 배너 본문에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
|
||||
// 3. needs_renewal=true 일 때도 배너만 노출 (모달 폐기 — 결정 모달/배너 중복 제거). 배너 본문에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
|
||||
expect(banner?.if).toBe(
|
||||
'{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('needs_renewal 강제 모달 (gdpr_needs_renewal_modal) 이 더 이상 존재하지 않는다 (PO 결정 — 배너 통합)', () => {
|
||||
it('needs_renewal 강제 모달 (gdpr_needs_renewal_modal) 이 더 이상 존재하지 않는다 (결정 — 배너 통합)', () => {
|
||||
// 모달 폐기 회귀 가드: 모달과 배너의 시각적 중복 + UX 혼란 + dark pattern 회피 위해
|
||||
// 모달 통째 제거하고 배너에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
|
||||
const modal = findById(root, 'gdpr_needs_renewal_modal');
|
||||
@@ -114,7 +114,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
expect(json).toContain('|renewed=');
|
||||
});
|
||||
|
||||
it('게스트 전용 갱신 버튼 (gdpr_keep_consent_button_guest) 은 더 이상 존재하지 않는다 (PO 결정 — 게스트는 status 미사용 + history 기반이라 이전 의사 자동 복원 인프라 부재 → "모두 동의" 그대로 사용)', () => {
|
||||
it('게스트 전용 갱신 버튼 (gdpr_keep_consent_button_guest) 은 더 이상 존재하지 않는다 (결정 — 게스트는 status 미사용 + history 기반이라 이전 의사 자동 복원 인프라 부재 → "모두 동의" 그대로 사용)', () => {
|
||||
const guestBtn = findById(root, 'gdpr_keep_consent_button_guest');
|
||||
expect(guestBtn).toBeNull();
|
||||
});
|
||||
@@ -143,12 +143,12 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
|
||||
it('기본(bottom_bar) 위치는 풀폭 팝업 카드 (bottom-4 left-4 right-4 rounded-lg)', () => {
|
||||
// 회귀 가드: 이전엔 화면 하단에 딱 붙는 띠(bottom-0)였지만,
|
||||
// PO 결정으로 화면에서 살짝 떠 있는 풀폭 카드 형태로 변경됨.
|
||||
// 설계 결정으로 화면에서 살짝 떠 있는 풀폭 카드 형태로 변경됨.
|
||||
expect(className).toContain('bottom-4 left-4 right-4 rounded-lg');
|
||||
});
|
||||
|
||||
it('top_bar 분기는 제거되어 있다', () => {
|
||||
// 회귀 가드: PO 결정으로 banner_position 옵션에서 top_bar 제거됨.
|
||||
// 회귀 가드: 설계 결정으로 banner_position 옵션에서 top_bar 제거됨.
|
||||
expect(className).not.toContain("'top_bar'");
|
||||
});
|
||||
|
||||
@@ -170,9 +170,9 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('centered_modal 에서 dim 오버레이를 사용하지 않는다 (PO 의도: 다른 팝업과 동일 외형)', () => {
|
||||
it('centered_modal 에서 dim 오버레이를 사용하지 않는다 (의도: 다른 팝업과 동일 외형)', () => {
|
||||
// 회귀 가드: 이전 구현은 외곽에 bg-black/40 dim 을 깔았으나 자식 박스가 dim 위에 떠야 하는
|
||||
// 구조 — 외곽이 bg-white 와 함께 정의되어 dim 의도가 깨졌었다. PO 가 dim 강제 모달이 아닌
|
||||
// 구조 — 외곽이 bg-white 와 함께 정의되어 dim 의도가 깨졌었다. dim 강제 모달이 아닌
|
||||
// 단순 중앙 배치만 원하므로 dim 클래스 자체를 제거.
|
||||
expect(className).not.toContain('bg-black/40');
|
||||
expect(className).not.toContain('bg-black/50');
|
||||
@@ -280,7 +280,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
});
|
||||
|
||||
it('배너 4버튼은 gdprBannerSubmittingAction 식별자 패턴으로 클릭한 버튼만 spinner + 다른 버튼은 원래 라벨 유지', () => {
|
||||
// 회귀 가드: PO 피드백 — 단일 boolean 플래그면 모든 버튼이 "동의 중..." 으로 동시 변경되어
|
||||
// 회귀 가드: 피드백 — 단일 boolean 플래그면 모든 버튼이 "동의 중..." 으로 동시 변경되어
|
||||
// 어느 버튼을 클릭했는지 시각적으로 알 수 없음. 식별자 string 으로 변경하여 클릭한 버튼만
|
||||
// 스피너 + 라벨 변경, 다른 버튼은 disabled + 원래 라벨 유지.
|
||||
// 4 action 식별자: accept_all / reject_all / save_selection / renew_consent
|
||||
@@ -365,7 +365,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
});
|
||||
|
||||
it('"자세히 보기" 토글 + 펼침 영역이 통째로 제거되었다 (카테고리 description 한 줄로 일원화)', () => {
|
||||
// PO 결정: 펼침 영역의 user_info description 이 카테고리 카드 자체의 description 과
|
||||
// 설계 결정: 펼침 영역의 user_info description 이 카테고리 카드 자체의 description 과
|
||||
// 의미상 중복 → "자세히 보기" 토글 자체를 없애고, 카테고리 description 을 사용자 친화
|
||||
// 표현으로 갈아끼움 (시더 plugin.php / CookieCategoryService 의 기본값 갱신).
|
||||
expect(text).not.toContain('banner.category_details_expand');
|
||||
@@ -398,7 +398,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
});
|
||||
|
||||
describe('카테고리 카드 — 임의값 회귀 가드 (#14 글씨 키우기)', () => {
|
||||
// PO 결정: 카테고리 카드 자체의 보조 텍스트 (필수 배지, 카테고리 설명) 도 너무 작아
|
||||
// 설계 결정: 카테고리 카드 자체의 보조 텍스트 (필수 배지, 카테고리 설명) 도 너무 작아
|
||||
// 가독성 저하. 임의값 text-[10px] 11건을 모두 표준 text-xs 로 통일.
|
||||
it('cookie_banner.json 전체에서 text-[10px] 임의값이 사용되지 않는다', () => {
|
||||
const fullText = serializeForSearch(extension);
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ describe('layouts/admin/gdpr_consent_log.json — #27 체크박스 즉시 적용
|
||||
|
||||
/**
|
||||
* 작업 2 (B-3 옵션 X) — policy_version 컬럼이 평문 Span + 본문 보기 Button 분리.
|
||||
* PO 피드백: v배지 클릭만으로는 직관성 부족 — 평문 + 명시 버튼 패턴으로 변경.
|
||||
* 피드백: v배지 클릭만으로는 직관성 부족 — 평문 + 명시 버튼 패턴으로 변경.
|
||||
*/
|
||||
describe('작업 2: policy_version 컬럼 → 평문 + 본문 보기 Button', () => {
|
||||
const layoutJson = JSON.stringify(layout);
|
||||
|
||||
+4
-4
@@ -79,7 +79,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매
|
||||
});
|
||||
|
||||
it('카드 헤더(타이틀 + 구분선) 다음 형제 위치에 배치 (헤더 내부 X)', () => {
|
||||
// PO UX 결정: 메타 라인은 H2 타이틀 + 구분선 다음에 자연어 문장으로 노출.
|
||||
// UX 결정: 메타 라인은 H2 타이틀 + 구분선 다음에 자연어 문장으로 노출.
|
||||
// 헤더 내부(border-b 위)에 두면 타이틀과 같은 영역으로 보여 시각적 혼잡 → 헤더 외부로 분리.
|
||||
const header = findById(card ?? null, 'gdpr_card_header');
|
||||
const headerChildren = (header?.children ?? []) as AnyNode[];
|
||||
@@ -129,7 +129,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매
|
||||
});
|
||||
|
||||
it('가운데 점(·) 구분자 미사용 (자연어 한 줄 패턴이라 구분자 불필요)', () => {
|
||||
// 회귀 가드: 이전 디자인은 가로 인라인 + · 구분자였으나, PO 결정으로 자연어 한 줄로 재설계.
|
||||
// 회귀 가드: 이전 디자인은 가로 인라인 + · 구분자였으나, 설계 결정으로 자연어 한 줄로 재설계.
|
||||
const children = (meta?.children ?? []) as AnyNode[];
|
||||
const separator = children.find((c) => String(c.text ?? '') === '·');
|
||||
expect(separator).toBeUndefined();
|
||||
@@ -223,7 +223,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매
|
||||
const modal = (root.modals ?? []).find((m) => m.id === 'revoke_confirm_modal');
|
||||
|
||||
it('모달 본문에 raw key (cookie_functional 등) 대신 사용자 친화 description 노출', () => {
|
||||
// PO 피드백 — raw key 는 사용자에게 무의미하여 모달 본문에서 제거.
|
||||
// 피드백 — raw key 는 사용자에게 무의미하여 모달 본문에서 제거.
|
||||
// 라벨 + description 2줄 구조로 변경됨.
|
||||
const modal = (root.modals ?? []).find((m) => m.id === 'revoke_confirm_modal');
|
||||
const text = serializeForSearch(modal);
|
||||
@@ -329,7 +329,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매
|
||||
});
|
||||
|
||||
describe('정책 갱신 amber 안내 박스 (마이페이지 카드 내 needs_renewal 안내)', () => {
|
||||
// PO 결정 — 마이페이지 카드 안에 amber 박스 (gdpr_needs_renewal_banner) 유지.
|
||||
// 설계 결정 — 마이페이지 카드 안에 amber 박스 (gdpr_needs_renewal_banner) 유지.
|
||||
// 조건: gdprMeConsents.data.needs_renewal === true 일 때만 노출.
|
||||
// 본문: 현 정책 버전 안내 (#23 informed consent) + 필수 쿠키 면제 hint
|
||||
// 액션: 「전체 항목 다시 동의」 (POST /consent/renew-all)
|
||||
|
||||
+1
-1
@@ -295,7 +295,7 @@ class GdprAdminSettingsControllerTest extends PluginTestCase
|
||||
* 노출하여 사용자가 어느 필드를 의미하는지 인지하기 어려움. FormRequest::attributes() 메서드로
|
||||
* 각 필드에 lang 의 사용자 친화 라벨을 매핑하여 메시지 안에 노출되도록 함.
|
||||
*
|
||||
* 검토 #10d — PO 가 사용자에게 영문 키 노출이 부적절하다는 피드백 후 추가.
|
||||
* 검토 #10d — 사용자에게 영문 키 노출이 부적절하다는 피드백 후 추가.
|
||||
*/
|
||||
public function test_update_validation_messages_use_friendly_attribute_names(): void
|
||||
{
|
||||
|
||||
@@ -75,7 +75,7 @@ class GdprAuthConsentListenerTest extends PluginTestCase
|
||||
*
|
||||
* 실제 훅 체인: HookManager::doAction('core.auth.record_consents', User, array, string $agreedAt, string $ip)
|
||||
* 를 발화했을 때 listener 가 TypeError 없이 받고 폼 동의를 status 에 반영해야 함.
|
||||
* 회귀 발견 시점: 2026-05-08 PO 가입 시 Argument #3 ($agreedAt) must be of type Carbon\Carbon, string given.
|
||||
* 회귀 발견 시점: 2026-05-08 관리자 가입 시 Argument #3 ($agreedAt) must be of type Carbon\Carbon, string given.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -139,7 +139,7 @@ class GdprAuthConsentListenerTest extends PluginTestCase
|
||||
* Listener 메서드는 현재 HTTP 요청 컨텍스트 (`request()->ip()`) + Carbon 인스턴스에
|
||||
* 의존한다. HookListenerRegistrar 의 기본 동작은 큐 디스패치인데, 큐 잡은 별도 컨텍스트라
|
||||
* 쿠키 소실 + Carbon → string 직렬화 mismatch 발생 → 폼 동의 기록 실패
|
||||
* (실제 회귀 — 2026-05-08 PO 가입 시도 시 발견).
|
||||
* (실제 회귀 — 2026-05-08 관리자 가입 시도 시 발견).
|
||||
*/
|
||||
public function test_subscribed_hook_forces_sync_to_preserve_request_context(): void
|
||||
{
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ class MobileCallbackRouteTest extends PluginTestCase
|
||||
/**
|
||||
* KG 이니시스 모바일 표준결제는 P_NEXT_URL 로 POST 콜백을 보낸다.
|
||||
* 라우트가 GET 만 허용하면 405 Method Not Allowed 회귀가 발생한다.
|
||||
* 가상계좌 결제 시 PO 운영 검증으로 발견된 회귀.
|
||||
* 가상계좌 결제 시 운영 검증으로 발견된 회귀.
|
||||
*/
|
||||
public function test_mobile_callback_route_accepts_post(): void
|
||||
{
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 본 회차에서 추가한 두 가지 회귀 차단 포인트:
|
||||
* 1. 모달 헤더 제목/설명에 `purpose === 'inicis.adult_verification'` 분기 두 쌍 존재
|
||||
* 2. if 표현식이 CLAUDE.md 규정 (`{{식}}` 한 쌍 안에 비교 식 전체) 부합
|
||||
* 2. if 표현식이 코어 규정 (`{{식}}` 한 쌍 안에 비교 식 전체) 부합
|
||||
*
|
||||
* 레이아웃 JSON 파일을 직접 읽어 구조를 단언하는 가벼운 단위 테스트.
|
||||
* 코어 DynamicRenderer 환경 셋업 없이도 회귀 차단 가능.
|
||||
@@ -128,7 +128,7 @@ describe('identity_provider_inicis Extension Point — 본 회차 회귀 차단'
|
||||
expect(texts).toContain('$t:sirsoft-verification_kginicis.modal.description');
|
||||
});
|
||||
|
||||
it('모든 purpose 분기 if 표현식이 CLAUDE.md 규정 (식 전체를 `{{}}` 한 쌍에) 부합', () => {
|
||||
it('모든 purpose 분기 if 표현식이 코어 규정 (식 전체를 `{{}}` 한 쌍에) 부합', () => {
|
||||
const purposeIfs: string[] = [];
|
||||
|
||||
for (const root of spec.components) {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
* 배경: onError 의 errors 바인딩이 `{{$error.errors}}` ($ 접두사) 였을 때 ActionDispatcher
|
||||
* 컨텍스트 변수명과 불일치하여 _local.errors 가 null 로 남고, validation_error 박스가
|
||||
* 렌더되지 않는 silent failure 회귀가 발생했다. 올바른 컨텍스트 변수명은 `{{error.errors}}`.
|
||||
* (CLAUDE.md 데이터 바인딩 규칙: onSuccess/onError 컨텍스트는 $ 접두사 없음)
|
||||
* (코어 데이터 바인딩 규칙: onSuccess/onError 컨텍스트는 $ 접두사 없음)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -110,7 +110,7 @@ design_rationale: |
|
||||
신규 가입자는 가입 시점에 record 생성되므로 시간 흐를수록 자연 수렴.
|
||||
|
||||
- 외국인 (userDi 빈값) 통과 정책 — KG이니시스가 외국인 본인확인 시 DI 발급 못 하는 케이스 대응.
|
||||
PO 결정에 따라 향후 차단 정책 전환 가능 (별도 이슈).
|
||||
정책 결정에 따라 향후 차단 정책 전환 가능 (별도 이슈).
|
||||
|
||||
- PII 미노출 — IdentityDuplicateException / verify failure 메시지에 이메일/이름 절대 포함하지 않음.
|
||||
heads-up 공격 방어. i18n 키로만 처리.
|
||||
|
||||
@@ -155,7 +155,7 @@ design_rationale: |
|
||||
주석 명시), 모달 닫힘은 명시적 closeModal 책임. 코어 모달 partial 의 verify 버튼이 이미
|
||||
동일 sequence 패턴 사용 — 코어 default 와 일관성.
|
||||
2. 사용자 안내 강화 — 기존 "본인인증 시작" 버튼만으로는 사용자가 "왜 이게 뜨지" 의문을 가질 수
|
||||
있어 (PO 피드백), 사전 안내 카드 (아이콘 + 제목 + 설명 + 3단계 절차 안내) 로 확장. Phase E
|
||||
있어 (사용자 피드백), 사전 안내 카드 (아이콘 + 제목 + 설명 + 3단계 절차 안내) 로 확장. Phase E
|
||||
폐기 사유 #1 의 "사용자 제스처 확보" 본질은 그대로 유지.
|
||||
3. 진행 중 상태 표시 — 팝업이 열린 후 모달이 정적 상태로 남아있어 사용자가 "지금 뭘 해야 하지"
|
||||
혼란을 느낄 수 있음. providerInProgress 플래그로 슬롯 if 분기하여 "팝업창에서 진행 중" 스피너
|
||||
@@ -164,7 +164,7 @@ design_rationale: |
|
||||
providerInProgress 만 false 로 set → 모달은 열린 채 시작 버튼이 재활성화되어 사용자가 다시
|
||||
시도 가능. 강제 cancelled 처리 시 사용자가 모달 처음부터 다시 열어야 하는 부담 회피.
|
||||
|
||||
옵션 비교 — Phase E′-Redesign 의사결정 (PO 와 검토):
|
||||
옵션 비교 — Phase E′-Redesign 의사결정 (검토):
|
||||
- 옵션 1 (사전 안내 모달 + redirect): 페이지 전환 1회 발생, 데스크톱 UX 무거움
|
||||
- 옵션 2 (모달 + 팝업 = 본 안): 모달 안에서 완결, 모바일 일반 브라우저 (Safari/Chrome) 도 동일 흐름
|
||||
으로 동작 (window.open 이 새 탭으로 열려도 opener 유지 → bridge postMessage 동작)
|
||||
@@ -178,7 +178,7 @@ design_rationale: |
|
||||
- 코어 무수정 + 템플릿 minimal 수정 (모달 partial 에 if 분기 1줄 + 액션 버튼 if 조건 보강)
|
||||
- 모달 닫힘 결함만 fix 하면 작업 완결
|
||||
|
||||
PO 피드백 반영 (sirsoft-basic 1.0.0-beta.6):
|
||||
피드백 반영 (sirsoft-basic 1.0.0-beta.6):
|
||||
1. 외부 plugin 흐름에서 코어 모달의 [코드 재전송] / [확인] 버튼 숨김 — 외부 plugin 은 자기
|
||||
흐름 안에서 verify 까지 책임지므로 코어 액션이 불필요. 코어 mail/SMS 등 코드 발송형
|
||||
흐름은 기존대로 노출 (회귀 없음).
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ test_files:
|
||||
- plugins/_bundled/sirsoft-verification_kginicis/tests/Feature/Identity/InicisInstallDefaultPurposeProviderTest.php
|
||||
|
||||
design_rationale: |
|
||||
PO 결정:
|
||||
설계 결정:
|
||||
- 설치 시 1회만 세팅(install 훅), 활성화/업그레이드 시 재적용하지 않음.
|
||||
- 운영자 변경 가능(초기값 성격) — 코어 settings 에 저장하므로 환경설정 UI
|
||||
(본인인증 > 프로바이더 탭, 목적별 매핑)에서 그대로 변경된다.
|
||||
|
||||
+2
-2
@@ -48,7 +48,7 @@ rules_layer_coverage:
|
||||
coverage: name/phone/birthday nullable 변경 마이그레이션에 down() (NOT NULL 복귀) + 한국어 comment 포함.
|
||||
|
||||
design_rationale: |
|
||||
PO 결정 (부분 데이터 처리 방향):
|
||||
설계 결정 (부분 데이터 처리 방향):
|
||||
- 차단 대상 = 신원 핵심값(이름·생년월일·휴대폰·설정 기준 식별값). Chrome MCP 실측(2026-06-23,
|
||||
아이핀 채널)에서 이름·휴대폰·생년월일·CI·DI 가 모두 응답에 채워짐을 확인 → 휴대폰도 항상 오는
|
||||
핵심값으로 포함. 법규(CI/DI 는 주민/외국인등록번호 기반 → 정상 본인확인이면 제공)·코드(식별값
|
||||
@@ -61,7 +61,7 @@ design_rationale: |
|
||||
휴대폰은 신원 핵심값이라 누락 시 차단 대상 (위 incomplete_identity 케이스에 포함).
|
||||
- 토큰 재발급 가드(isVerified enum 비교)는 1번 PR, verify 실패 toast 매핑은 2번 PR 에서 처리됨.
|
||||
본 항목은 3번의 신원 가드 + 정합 저장 + INCOMPLETE_IDENTITY toast 1줄 보강.
|
||||
- (저장 원자성 보강) 토큰 발급(log update)과 PII 저장을 한 단위로 묶는다 (PO 결정 A안). PII 저장이
|
||||
- (저장 원자성 보강) 토큰 발급(log update)과 PII 저장을 한 단위로 묶는다 (설계 결정 A안). PII 저장이
|
||||
실패하면 토큰 발급까지 롤백하고 STORAGE_FAILED 로 인증을 실패시켜 "인증 성공=PII 저장됨" 무결성을
|
||||
보장한다. 로그인 record upsert 는 DB 트랜잭션으로 묶고, 비로그인 cache stash 실패는 트랜잭션 콜백을
|
||||
예외 종료시켜 log update 를 롤백한다. 비로그인 가입 backfill(after_register listener)은 record 가
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.52.0] - 2026-07-01
|
||||
## [engine-v1.52.0] - 2026-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
- `devtools-entry.ts` + `vite.config.devtools.js`(신규) — DevTools 번들 엔트리/빌드. React external → `window.React` 단일 인스턴스, `G7DevToolsCore` 는 shim(`devtools/__runtime-shims/`)으로 메인 번들과 공유(재번들 0바이트, 싱글톤 동일성).
|
||||
- `core:build` — 엔진 + 편집기 + DevTools 3개 번들 순차 빌드. `--watch` 는 3개 `vite build --watch` 병렬.
|
||||
|
||||
## [engine-v1.51.0] - 2026-07-01
|
||||
## [engine-v1.51.0] - 2026-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
+1
-1
@@ -273,7 +273,7 @@ describe('insertChipInValue — 커서 위치 자리표시 삽입', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('타이핑 안전성 — 비제어 평문(글자 뒤섞임 결함 회귀, PO)', () => {
|
||||
describe('타이핑 안전성 — 비제어 평문(글자 뒤섞임 결함 회귀)', () => {
|
||||
it('평문 타이핑(연속 입력) 시 평문 span 을 React 가 덮어쓰지 않는다(비제어)', () => {
|
||||
// 결함 재현 방지: controlled 였다면 onChange→value 갱신→리렌더로 span textContent 가
|
||||
// 매 입력마다 재설정되어 커서가 튀고 글자 순서가 깨졌다("하나둘"→"퉁둘낭나한하").
|
||||
|
||||
+1
-1
@@ -278,7 +278,7 @@ describe('셀 테두리/텍스트', () => {
|
||||
colors: [{ value: 'red', swatch: '#ef4444', token: 'border-red-500' }],
|
||||
};
|
||||
const paramsCat = { ...params, cellBorder: catalog };
|
||||
// 셀에 두께(border)를 미리 부여 → 색 피커가 즉시 노출(앵커 셀 className 기반). 실제 PO
|
||||
// 셀에 두께(border)를 미리 부여 → 색 피커가 즉시 노출(앵커 셀 className 기반). 실제 운영자
|
||||
// 흐름(얇게 선택된 상태)과 동일. (단위 테스트의 onPatchNode mock 은 node prop 을
|
||||
// 되먹이지 않아 두께 클릭→리렌더로는 색 피커가 안 떠 별도 셀 className 으로 노출.)
|
||||
const bc = (text: string): EditorNode => ({ type: 'basic', name: 'Td', text, props: { className: 'border' } });
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* ConditionBuilder 가 combineConditions 로 만든 단일 `{{ }}` 식이 실제 런타임
|
||||
* `evaluateStringCondition`(→ optional chaining 식이라 `evaluateExpression` 진입,
|
||||
* CLAUDE.md `if` 평가 규칙)으로 샘플 상태에 따라 표시/숨김 토글되는지 검증한다.
|
||||
* 코어 `if` 평가 규칙)으로 샘플 상태에 따라 표시/숨김 토글되는지 검증한다.
|
||||
*
|
||||
* 단위 시뮬레이션이 아닌 실제 평가 엔진 라운드트립 — 합성식의 문법 정합성을 잠근다.
|
||||
*/
|
||||
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* actionRecipeEngine.test.ts — 친화 액션 레시피 → 핸들러 JSON DataProvider
|
||||
*
|
||||
* CLAUDE.md "CRITICAL RULES — API/핸들러 호출" 전수 회귀 가드 포함:
|
||||
* 코어 핸들러 규칙(API/핸들러 호출) 전수 회귀 가드 포함:
|
||||
* - apiCall(api 아님)/navigate(nav 아님)/setState+target/toast(showToast 아님)/
|
||||
* refetchDataSource.dataSourceId(id 아님).
|
||||
* - apiCall 의 target/onSuccess/onError 는 액션 top-level.
|
||||
@@ -70,7 +70,7 @@ describe('normalizeActionRecipes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAction — CLAUDE.md 핸들러 규칙 준수', () => {
|
||||
describe('buildAction — 코어 핸들러 규칙 준수', () => {
|
||||
const recipes = normalizeActionRecipes(RECIPES);
|
||||
const byId = (id: string) => recipes.find((r) => r.id === id)!;
|
||||
|
||||
@@ -273,7 +273,7 @@ describe('requestPgPayment — placeholder 핸들러 build/match 라운드트립
|
||||
|
||||
// 회귀 — "+동작 추가" 로 결제 진입을 고르면 ActionAddPicker 가 buildAction(recipe, {}) 로 빈 값
|
||||
// 빌드한다. placeholder 핸들러({{paymentHandler}})가 빈 값에서 undefined 로 떨어져 사라지면
|
||||
// handler 없는 액션이 되어 "알 수 없는 동작"(advanced)으로 강등된다(PO 제보). 빈 빌드에서도
|
||||
// handler 없는 액션이 되어 "알 수 없는 동작"(advanced)으로 강등된다(제보 회귀). 빈 빌드에서도
|
||||
// handler placeholder 는 보존돼야 추가 직후 친화 카드로 매칭되고 사용자가 칩으로 채울 수 있다.
|
||||
it('빈 값 빌드(+동작 추가) — placeholder 핸들러가 보존되어 handler 가 사라지지 않는다', () => {
|
||||
const recipe = recipes.find((r) => r.id === 'requestPgPayment')!;
|
||||
@@ -290,14 +290,14 @@ describe('requestPgPayment — placeholder 핸들러 build/match 라운드트립
|
||||
expect(m?.recipeId).toBe('requestPgPayment');
|
||||
});
|
||||
|
||||
// 회귀(PO 제보) — 핸들러 필드를 데이터 칩으로 바꾸고 결제 데이터는 비워두면, buildAction 이
|
||||
// 회귀(제보) — 핸들러 필드를 데이터 칩으로 바꾸고 결제 데이터는 비워두면, buildAction 이
|
||||
// 미입력 required pgPaymentData 를 떨궈 params 가 사라진다. 그러면 fingerprint(pgPaymentData)
|
||||
// 가 깨져 친화 카드가 [고급]으로 강등됐다(PO: "변경하니 고급으로 넘어간다"). required placeholder
|
||||
// 가 깨져 친화 카드가 [고급]으로 강등됐다("변경하니 고급으로 넘어간다"). required placeholder
|
||||
// param 은 빈 값이어도 토큰을 보존해 fingerprint 를 유지하고, 핸들러를 어떤 칩으로 바꿔도 친화
|
||||
// 카드가 유지돼야 한다(핸들러 필드 바인딩 가능성 ↔ greedy 가드 공존).
|
||||
it('핸들러를 임의 칩으로 바꾸고 데이터는 비워도 requestPgPayment 친화 카드 유지 (고급 강등 금지)', () => {
|
||||
const recipe = recipes.find((r) => r.id === 'requestPgPayment')!;
|
||||
// 핸들러만 데이터 칩으로 채우고 pgPaymentData 는 미입력(빈 값) — PO 시나리오.
|
||||
// 핸들러만 데이터 칩으로 채우고 pgPaymentData 는 미입력(빈 값) — 제보 시나리오.
|
||||
const action = buildAction(recipe, { paymentHandler: '{{checkoutData?.data?.temp_order_id}}' });
|
||||
// required pgPaymentData 가 토큰으로 보존돼 params 가 살아있어야 한다.
|
||||
expect(action.handler).toBe('{{checkoutData?.data?.temp_order_id}}');
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* conditionRecipeEngine.test.ts — 친화 조건 → if 식 합성 DataProvider
|
||||
*
|
||||
* CLAUDE.md `if` 규칙 회귀 가드:
|
||||
* 코어 `if` 규칙 회귀 가드:
|
||||
* - 최종 결과 전체가 단일 `{{ }}` 한 쌍 — 중첩 보간 `{{ {{x}} }}` 미발생.
|
||||
* - 옵셔널 체이닝(`?.`) / 배열 길이 `(arr?.length ?? 0)` 형태 보존.
|
||||
* - 로그인 판정 경로 보존(임의 식 금지).
|
||||
@@ -65,7 +65,7 @@ describe('buildClauseExpr — {paramKey} 치환', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineConditions — 단일 {{ }} 합성 (CLAUDE.md if 규칙)', () => {
|
||||
describe('combineConditions — 단일 {{ }} 합성 (코어 if 규칙)', () => {
|
||||
it('단일 조건 → {{ 식 }}', () => {
|
||||
const r = combineConditions([{ operator: 'isLoggedIn', params: {} }], 'and', ops);
|
||||
expect(r).toBe('{{ _global?.currentUser?.uuid }}');
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* 가드 항목:
|
||||
* 1. 신규 6개 admin composite 가 nesting.draggable 등재 + capability 보유.
|
||||
* 2. 그 6개 tsx 가 editorAttrs 를 시각 루트에 spread (선택/클릭 가능 — PO 가 직접
|
||||
* 2. 그 6개 tsx 가 editorAttrs 를 시각 루트에 spread (선택/클릭 가능 — 검수자가 직접
|
||||
* 발견한 AdminSidebar/AdminFooter 선택 불가 결함의 회귀 가드).
|
||||
* 3. SectionHeader 가 componentPalette 에 등재 (팔레트 추가 가능).
|
||||
* 4. ecommerce editor-spec 에 신규 admin 화면 5개 states 그룹 등록 (상태 토글 노출).
|
||||
@@ -83,7 +83,7 @@ describe('편집기 갭 보강 — 신규 admin composite 편집 가능성', ()
|
||||
|
||||
describe('편집기 갭 보강 — editorAttrs 시각 루트 spread (선택/클릭 가능)', () => {
|
||||
// base 골격/위젯이 편집기 주입 editorAttrs 를 시각 루트에 흘려야 클릭이 그 노드에 닿는다.
|
||||
// (PO 가 base 편집 모드에서 AdminSidebar/AdminFooter 클릭 시 부모 Div 만 선택되던 결함)
|
||||
// (검수자가 base 편집 모드에서 AdminSidebar/AdminFooter 클릭 시 부모 Div 만 선택되던 결함)
|
||||
const FILES: Record<string, string> = {
|
||||
AdminSidebar: `${ADMIN}/src/components/composite/AdminSidebar.tsx`,
|
||||
AdminFooter: `${ADMIN}/src/components/composite/AdminFooter.tsx`,
|
||||
|
||||
+3
-3
@@ -283,7 +283,7 @@ describe('디그레이드 — 손상 0 폴백', () => {
|
||||
});
|
||||
|
||||
describe('hasDecomposableExpressionSegment — 다중 세그먼트 + 표현식 조각', () => {
|
||||
// 양 템플릿 실측 9건(다중 세그먼트 + 진짜 삼항 분기 토큰) — PO board/form 제목 케이스 포함.
|
||||
// 양 템플릿 실측 9건(다중 세그먼트 + 진짜 삼항 분기 토큰) — 실측 board/form 제목 케이스 포함.
|
||||
const REAL_MULTISEGMENT = [
|
||||
"{{route.id ? '$t:board.edit_post' : '$t:board.new_post'}} - {{form_meta?.data?.board?.name || ''}}",
|
||||
"{{_local.collapsedReplies?.[comment?.id] === false ? '$t:board.hide_replies' : '$t:board.show_replies'}} ({{comment?.replies_count}})",
|
||||
@@ -324,7 +324,7 @@ describe('previewSegments — 접힌 미리보기 토큰("한 값만 해석")',
|
||||
expect(toks[1]).toEqual({ kind: 'ellipsis', text: '⋯' });
|
||||
});
|
||||
|
||||
it('PO board/form 다중 세그먼트 → 게시글 수정⋯ + 평문 + 데이터 칩', () => {
|
||||
it('실측 board/form 다중 세그먼트 → 게시글 수정⋯ + 평문 + 데이터 칩', () => {
|
||||
const toks = previewSegments(
|
||||
"{{route.id ? '$t:board.edit_post' : '$t:board.new_post'}} - {{form_meta?.data?.board?.name || ''}}",
|
||||
resolve,
|
||||
@@ -606,7 +606,7 @@ describe('extractFirstLeafText / reduceExpressionToPlain — 표현식 → 일
|
||||
|
||||
// og.title `{{route.id ? '$t:edit': product.title}}` 류에서 첫 결과(then)가
|
||||
// 데이터인 경우. 미리보기 "기본 값 → 🔗 product.title" 과 적용이 일치해야 한다.
|
||||
it('조건식 첫 분기가 데이터면 그 데이터 바인딩으로 복구(PO og.title 케이스)', () => {
|
||||
it('조건식 첫 분기가 데이터면 그 데이터 바인딩으로 복구(실측 og.title 케이스)', () => {
|
||||
expect(reduceExpressionToPlain("{{route.id ? product.title : '$t:shop.new'}}")).toBe('{{product.title}}');
|
||||
});
|
||||
|
||||
|
||||
@@ -1060,7 +1060,7 @@ export function PreviewCanvas(props: PreviewCanvasProps = {}): React.ReactElemen
|
||||
>
|
||||
<div
|
||||
// 프리뷰 색상 테마 — dark 면 프레임에 `.g7le-preview-dark` 마커 부여.
|
||||
// 종전 `.dark` 는 PO admin 환경의 조상 `html.dark` 와 충돌해 프리뷰 라이트 격리가
|
||||
// 종전 `.dark` 는 관리자 admin 환경의 조상 `html.dark` 와 충돌해 프리뷰 라이트 격리가
|
||||
// 깨졌다(조상 어디든 `.dark` 면 Tailwind `dark:` 활성). 코어 CSS 서빙 API(serveEditorCss)
|
||||
// 가 편집기용 CSS 의 `.dark` 셀렉터를 이 마커로 치환해 서빙하므로, 프리뷰 프레임의 이
|
||||
// 마커만으로 다크가 켜지고 조상 html.dark 와 독립된다(라이트 토글 시 확실히 라이트).
|
||||
|
||||
+1
-1
@@ -311,7 +311,7 @@ function DataSegmentPicker({
|
||||
candidates={candidates}
|
||||
t={t}
|
||||
onSelect={(c) => {
|
||||
// shape 안전 바인딩(`{{src?.path ?? ''}}`) — 데이터 미도착 시 런타임 에러 방지(CLAUDE.md
|
||||
// shape 안전 바인딩(`{{src?.path ?? ''}}`) — 데이터 미도착 시 런타임 에러 방지(코어
|
||||
// fallback 필수). buildBindingExpression 이 scalar 폴백(`?? ''`)을 붙인다.
|
||||
onPick(buildBindingExpression(c.sourceId, c.path, 'scalar'));
|
||||
}}
|
||||
|
||||
+2
-2
@@ -829,7 +829,7 @@ function PlainInsertDataButton({
|
||||
return (
|
||||
<div data-testid={`${testidPrefix}-plus-data`} style={{ display: 'inline-block' }}>
|
||||
{/* `+` 버튼은 항상 자리 유지(피커가 열려도 플로우 폭 불변). 피커는 공용 FloatingDropdown 으로
|
||||
버튼 아래 띄우되 위치 자동 보정(flip/clamp)으로 좁은 입력 행 어디서든 잘리지 않는다(PO
|
||||
버튼 아래 띄우되 위치 자동 보정(flip/clamp)으로 좁은 입력 행 어디서든 잘리지 않는다(검수
|
||||
2026-06-13 — 하드코딩 right:0 으로 좌측 쏠려 가려지던 결함 → 범용 위치 보정으로 통일). */}
|
||||
<button
|
||||
ref={plusRef}
|
||||
@@ -861,7 +861,7 @@ function PlainInsertDataButton({
|
||||
}
|
||||
|
||||
/**
|
||||
* 표현식 접힌 미리보기 — 읽기전용 한 줄(키 해석·데이터 칩) + [수정] 버튼 (PO
|
||||
* 표현식 접힌 미리보기 — 읽기전용 한 줄(키 해석·데이터 칩) + [수정] 버튼 (검수
|
||||
* "기본 접힌 미리보기 + [수정]" 2026-06-13). 진입 시 정보 과다 방지 — [수정] 클릭 시 분해 빌더 펼침.
|
||||
*
|
||||
* 미리보기 = `previewSegments`("한 값만 해석" — 조건은 참 분기 + ⋯, 키는 해석, 바인딩은 칩).
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ export interface InlineBindingScalarPickerProps {
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
/**
|
||||
* 펼침 시 검색창+결과를 **부유 드롭다운**(공용 `FloatingDropdown`)으로 띄울지 (PO
|
||||
* 펼침 시 검색창+결과를 **부유 드롭다운**(공용 `FloatingDropdown`)으로 띄울지 (검수
|
||||
* "돋보기 누르면 화면 50% 차지 — 부유 상태로 떠야" 2026-06-13 / "데이터 선택기가 부착된 모든 UI 를
|
||||
* 동일하게 부유 처리" 2026-06-14). **기본 true** — 어느 진입점이든 펼치면 토글 기준으로 떠서 행/패널을
|
||||
* 밀어내지 않는다(좁은 조건 행·데이터 조각·[속성]탭 폼 공통). 종전엔 기본 false(인라인)였으나, 새
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* 일부면 문자열 보간한다. `onSuccess`/`onError` 가 `action-list` 파라미터를 가리키면
|
||||
* 치환 결과는 **중첩 액션 배열**이다(12.2.3 — 문자열 보간 아님).
|
||||
*
|
||||
* 생성 JSON 은 CLAUDE.md "CRITICAL RULES — API/핸들러 호출" 을 위반하지 않는다.
|
||||
* 생성 JSON 은 코어 핸들러 규칙(API/핸들러 호출)을 위반하지 않는다.
|
||||
* `assertHandlerRules` 가 잘못된 핸들러명/위치를 dev 경고로 잡는다(레시피 작성자
|
||||
* 가드). 코어는 핸들러를 가정하지 않으므로(원칙 4.8) 레시피의 `build.handler` 를
|
||||
* 그대로 쓰되, 알려진 금지 패턴만 경고한다.
|
||||
@@ -34,7 +34,7 @@ export interface NormalizedActionRecipe {
|
||||
build: ActionBuildTemplate;
|
||||
}
|
||||
|
||||
/** CLAUDE.md 금지 → 올바른 핸들러 매핑 (레시피 작성 가드) */
|
||||
/** 코어 금지 → 올바른 핸들러 매핑 (레시피 작성 가드) */
|
||||
const HANDLER_ALIASES: Record<string, string> = {
|
||||
api: 'apiCall',
|
||||
nav: 'navigate',
|
||||
@@ -161,7 +161,7 @@ function substituteValue(value: unknown, values: Record<string, unknown>): unkno
|
||||
|
||||
/**
|
||||
* 알려진 핸들러 금지 패턴을 dev 경고로 잡고 올바른 핸들러로 교정.
|
||||
* (CLAUDE.md CRITICAL RULES — 레시피 작성자 가드. 코어는 핸들러를 강제하지 않되
|
||||
* (코어 핸들러 규칙 — 레시피 작성자 가드. 코어는 핸들러를 강제하지 않되
|
||||
* 명백한 금지 별칭만 교정한다.)
|
||||
*
|
||||
* @param action 빌드된 액션 1건
|
||||
@@ -173,7 +173,7 @@ export function assertHandlerRules(action: Record<string, unknown>): Record<stri
|
||||
const corrected = HANDLER_ALIASES[handler];
|
||||
if (corrected) {
|
||||
logger.warn(
|
||||
`action recipe used forbidden handler "${handler}" — corrected to "${corrected}" (CLAUDE.md CRITICAL RULES)`,
|
||||
`action recipe used forbidden handler "${handler}" — corrected to "${corrected}" (core handler rules)`,
|
||||
);
|
||||
return { ...action, handler: corrected };
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export function assertHandlerRules(action: Record<string, unknown>): Record<stri
|
||||
for (const k of ['target', 'onSuccess', 'onError']) {
|
||||
if (k in (params as Record<string, unknown>)) {
|
||||
logger.warn(
|
||||
`action recipe build placed "${k}" inside params for handler "${handler}" — should be top-level (CLAUDE.md)`,
|
||||
`action recipe build placed "${k}" inside params for handler "${handler}" — should be top-level (core handler rules)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,7 @@ export function buildAction(
|
||||
// required param 의 sole-binding 토큰이 미입력으로 떨어지면 build 틀의 토큰을 복원한다. 이런 recipe
|
||||
// (결제 진입)는 matchAction 의 구조 fingerprint(placeholderRecipeStructureMatches)가 params 의
|
||||
// 토큰 존재에 의존하므로, 핸들러 필드를 임의 데이터 칩으로 바꾸고 다른 required 값을 비워둬도 친화
|
||||
// 카드가 [고급]으로 강등되지 않게 토큰을 유지한다(PO 제보 회귀). 일반 recipe(리터럴 핸들러,
|
||||
// 카드가 [고급]으로 강등되지 않게 토큰을 유지한다(제보 회귀). 일반 recipe(리터럴 핸들러,
|
||||
// apiCall 등)는 미입력 required 를 그대로 떨궈 깔끔한 JSON 정책을 지킨다 — 그쪽은 핸들러 일치만으로
|
||||
// 매칭되므로 토큰 보존이 필요 없다.
|
||||
if (recipe.build.handler.includes('{{')) {
|
||||
|
||||
@@ -349,7 +349,7 @@ export interface ParsedBinding {
|
||||
optional?: boolean;
|
||||
/**
|
||||
* 원본의 널 병합 폴백 식 — `?? []` 의 `[]`, `?? ''` 의 `''` 등(있으면). 재기입 시 동일
|
||||
* 폴백을 유지해 데이터 미도착 시 런타임 에러를 막는다(CLAUDE.md fallback 필수 규칙).
|
||||
* 폴백을 유지해 데이터 미도착 시 런타임 에러를 막는다(코어 fallback 필수 규칙).
|
||||
*/
|
||||
fallback?: string;
|
||||
/**
|
||||
@@ -515,7 +515,7 @@ export function parseBindingExpression(value: unknown): ParsedBinding | null {
|
||||
* 검색 피커에서 후보를 선택하면 그 후보의 `expression`(단순 형태)을 그대로 쓰지 않고,
|
||||
* shape 에 맞는 안전 형태로 다시 만든다 — `?.` 체이닝 + shape 별 폴백(array `?? []`,
|
||||
* scalar `?? ''`). 이렇게 해야 데이터 미도착 시 런타임 에러(`undefined.map`/`.length`)를
|
||||
* 막는다(CLAUDE.md "fallback 필수" 규칙).
|
||||
* 막는다(코어 "fallback 필수" 규칙).
|
||||
*
|
||||
* SEO 단일-경로 추출 함수(`$localized`)로 래핑된 바인딩을 다시 쓸 때는 `localeFn` 을 넘긴다.
|
||||
* `$localized(<src>.<path>)` 형태로 만들고 폴백/옵셔널 체이닝은 붙이지 않는다 — `$localized` 는
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* evaluateExpression` 을 그대로 호출한다(운영 렌더와 동일 식 해석 경로,
|
||||
* [[feedback_editor_sample_verify_computed_body_not_just_state]]).
|
||||
*
|
||||
* 모든 생성 출력은 **`{{ 식 }}` 한 쌍**이다(CLAUDE.md `if`/computed 규칙 — 중첩 보간
|
||||
* 모든 생성 출력은 **`{{ 식 }}` 한 쌍**이다(코어 `if`/computed 규칙 — 중첩 보간
|
||||
* `{{ {{x}} }}` 미발생). 매칭/역해석은 식 원문을 변경하지 않는다(인식만).
|
||||
*
|
||||
* @since engine-v1.50.0
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* conditionRecipeEngine.ts — 친화 조건 레시피 ↔ `if` 표현식 변환
|
||||
*
|
||||
* 속성 모달 "표시조건" 탭이 친화 조건(12.4.3 A~H) + 파라미터로 `comp.if` 표현식을
|
||||
* 합성한다. 표현식 문법 규칙(12.4.2 / CLAUDE.md `if` 평가 규칙):
|
||||
* 합성한다. 표현식 문법 규칙(12.4.2 / 코어 `if` 평가 규칙):
|
||||
*
|
||||
* - 레시피 `expr` 은 **`{{ }}` 없이** 식 본문만 적고, 파라미터 자리는 레시피
|
||||
* 전용 플레이스홀더 `{paramKey}`(중괄호 **1개**)를 쓴다. 런타임 `{{}}` 와 구분.
|
||||
* - 여러 조건을 `&&`/`||` 로 결합한 뒤 **최종 결과 전체를 단일 `{{ }}` 한 쌍**
|
||||
* 으로 감싼다. 중첩 보간 `{{ {{x}} }}` 는 만들지 않는다(파서 모호성·CLAUDE.md).
|
||||
* 으로 감싼다. 중첩 보간 `{{ {{x}} }}` 는 만들지 않는다(파서 모호성·코어 규칙).
|
||||
* - 옵셔널 체이닝(`?.`) / 배열 길이 `(arr?.length ?? 0)` 형태는 레시피 작성자의
|
||||
* `expr` 책임 — 엔진은 치환·결합만 한다.
|
||||
*
|
||||
@@ -259,7 +259,7 @@ export function buildClauseExpr(
|
||||
* operator 는 제외. 유효 절이 없으면 빈 문자열(= if 제거).
|
||||
*
|
||||
* 중첩 보간 방지: 각 절 식 본문에는 `{{` 가 없으므로(레시피 `expr` 규칙), 최종
|
||||
* 한 번만 `{{ }}` 로 감싼다 → `{{ {{x}} }}` 미발생(CLAUDE.md `if` 규칙).
|
||||
* 한 번만 `{{ }}` 로 감싼다 → `{{ {{x}} }}` 미발생(코어 `if` 규칙).
|
||||
*
|
||||
* @param clauses 조건 절 목록
|
||||
* @param combinator 결합자 (and/or)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user