Merge pull request from gnuboard:HeuJung/issue276
HeuJung/issue276
This commit is contained in:
@@ -153,7 +153,7 @@
|
||||
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 |
|
||||
|
||||
|
||||
### 확장 API 레퍼런스 (11개 확장, 자동 스캔)
|
||||
### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
|
||||
|
||||
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
|
||||
|
||||
@@ -166,10 +166,12 @@
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
|
||||
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 |
|
||||
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
|
||||
|
||||
|
||||
<!-- AUTO-GENERATED-END: docs-quick-reference -->
|
||||
@@ -760,6 +762,8 @@ Controller → Request → Service → RepositoryInterface → Repository → Mo
|
||||
절대 금지: DB CASCADE에 의존한 삭제 → Service에서 명시적 삭제 (훅/파일/로깅 보장)
|
||||
절대 금지: 로케일 하드코딩 → config('app.supported_locales') 사용
|
||||
필수: 마이그레이션 한국어 comment 필수, down() 구현 필수
|
||||
필수: FK 컬럼의 ->comment() 는 ->constrained()/->references()/->on() 앞에 둔다 (뒤에 두면 comment 가 컬럼이 아닌 FK 정의에 부착되어 조용히 사라진다)
|
||||
필수: 소스 교정만으로는 기설치본이 낫지 않는다 — 마이그레이션은 재실행되지 않으므로 업그레이드 스텝 백필을 함께 작성
|
||||
주의: ResponseHelper::success($messageKey, $data) — 메시지가 첫 번째 인수
|
||||
```
|
||||
|
||||
@@ -806,6 +810,7 @@ BaseApiController (최상위)
|
||||
필수: 확장 코드 변경 시 manifest 버전 업 (미변경 시 업데이트 감지 불가)
|
||||
필수: 버전 업 시 CHANGELOG.md 기록 — Keep a Changelog 표준 (미기록 시 버전 업 불가)
|
||||
필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지)
|
||||
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
|
||||
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
|
||||
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
|
||||
필수: 모든 확장 작업은 Artisan 커맨드로 수행
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
|
||||
- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다.
|
||||
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다.
|
||||
- 같은 규칙에서 벗어나 있던 필터 이름 6개(플러그인 설정 저장, 비밀번호 재설정 토큰 확인, 비밀번호 확인, 변경사항 조회, 통합 검색, 프로필 사진 업로드)를 추가로 통일했습니다. 이번에는 예전 이름도 함께 계속 발행하므로 기존 확장은 수정 없이 그대로 동작하며, 예전 이름을 사용 중인 확장이 있으면 서버 기록에 한 번 안내가 남습니다. 새로 만드는 확장은 새 이름을 사용하세요.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -73,6 +74,9 @@
|
||||
|
||||
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
|
||||
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
|
||||
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
|
||||
- 본인인증 진행 상태를 조회하는 화면용 응답에 지금까지의 인증 시도 횟수가 함께 실려 나가던 것을 빼도록 바로잡았습니다. 원래 내보내지 않기로 한 값이며, 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있었습니다. 화면 동작에는 영향이 없습니다.
|
||||
- 플러그인 설정 화면이 저장된 비밀값(암호화 키·API 시크릿 등)을 다시 내려받던 것을 가려서 표시하도록 바로잡았습니다. 값이 저장되어 있다는 표시만 보여주며, 그 항목을 건드리지 않고 저장하면 저장된 값이 그대로 유지됩니다.
|
||||
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
|
||||
- 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다.
|
||||
- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다.
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
||||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||||
use App\Extension\Traits\ComputesLayoutContentHash;
|
||||
use App\Extension\Traits\InvalidatesLayoutCache;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Playwright E2E 용 시드 레이아웃 설치/제거 커맨드.
|
||||
*
|
||||
* 레이아웃 편집기 E2E 중 **저장(PUT)까지 수행하는** spec 은 편집 결과가 그대로 영속된다.
|
||||
* 이들이 제품 화면(`home` / `admin_dashboard` 등)을 대상으로 하면 실행할 때마다 빈 표·차트가
|
||||
* 누적돼 개발 사이트가 오염된다(실측: `home` 20,321 → 33,696 bytes, 빈 표 7개). spec 안에
|
||||
* 원복 장치를 넣어도 편집기가 캐시한 문서를 다시 밀어 넣어 오염 시점 크기로 되돌아왔다.
|
||||
*
|
||||
* 그래서 저장 spec 전용 시드 화면(`e2e_sandbox`)을 제품 레이아웃과 분리한다. 이 커맨드가
|
||||
* 그 화면을 **활성 템플릿 디렉토리에만** 설치하므로 `_bundled`(배포 원본)은 손대지 않고,
|
||||
* 활성 디렉토리는 Git 무시 대상이라 릴리스 산출물에도 포함되지 않는다.
|
||||
*
|
||||
* 설치 절차(템플릿별):
|
||||
* ① `tests/Playwright/fixtures/seed-layouts/{identifier}.e2e_sandbox.json` 을
|
||||
* `templates/{identifier}/layouts/e2e_sandbox.json` 으로 복사
|
||||
* ② 활성 `templates/{identifier}/routes.json` 에 라우트 1건 추가(멱등 — 마커로 식별).
|
||||
* 편집기 라우트 트리는 routes.json 에서 만들어지므로 이 항목이 없으면 `?route=` 로
|
||||
* 시드 화면을 열 수 없다
|
||||
* ③ 시드 레이아웃 DB 행만 upsert. 편집기의 조회/저장 대상은 DB 행이라 이 단계가 없으면 404
|
||||
*
|
||||
* ③ 에서 `template:refresh-layout`(전체 재동기화)을 쓰지 않는 이유: 그 경로는 파일에 없는
|
||||
* DB 레이아웃을 지우고 모든 레이아웃을 파일 기준으로 되돌린다. 편집기 UI 로 저장한 변경은
|
||||
* 파일이 아니라 DB 에만 있으므로, E2E 를 돌릴 때마다 사람이 편집기로 만든 결과가 사라진다.
|
||||
* 시드 설치는 시드 행 하나만 건드린다.
|
||||
*
|
||||
* `--remove` 는 ①②를 되돌리고 시드 DB 행만 삭제한다 (다른 레이아웃 무영향).
|
||||
*
|
||||
* 보안 가드 (2중) — `PlaywrightIssueToken` 과 동일 규약:
|
||||
* ① CLI 한정 — `php_sapi_name() === 'cli'` 확인. production 웹 요청에서 절대 도달 불가
|
||||
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 부여 필수.
|
||||
* `.env` 영구 수정 없이 인라인 환경변수로만 활성화 가능 → 무심코 production 으로 새지 않음
|
||||
*
|
||||
* 호출 예시 (PowerShell):
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-layout
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-layout --remove
|
||||
*/
|
||||
class PlaywrightSeedLayout extends Command
|
||||
{
|
||||
use ComputesLayoutContentHash, InvalidatesLayoutCache;
|
||||
|
||||
protected $signature = 'playwright:seed-layout
|
||||
{--template=* : 대상 템플릿 identifier (생략 시 기본 대상 전체)}
|
||||
{--remove : 시드 레이아웃/라우트를 제거한다}';
|
||||
|
||||
protected $description = 'Playwright E2E 전용 시드 레이아웃 설치/제거 (CLI + G7_PLAYWRIGHT_BYPASS 가드)';
|
||||
|
||||
/** 시드 레이아웃 이름 — 파일명·DB name·라우트 layout 이 모두 이 값을 공유한다. */
|
||||
private const LAYOUT_NAME = 'e2e_sandbox';
|
||||
|
||||
/**
|
||||
* routes.json 의 시드 라우트 식별 마커.
|
||||
*
|
||||
* 경로 문자열은 템플릿 타입마다 다르므로(사용자 템플릿 vs 관리자 템플릿), 제거 시에는
|
||||
* 경로가 아니라 이 마커로 대상을 찾는다.
|
||||
*/
|
||||
private const ROUTE_MARKER = 'playwright-seed-layout';
|
||||
|
||||
/** 원본 routes.json 보관 파일 접미사 — 제거 시 서식까지 바이트 동일 복원용 */
|
||||
private const ROUTES_BACKUP_SUFFIX = '.playwright-backup';
|
||||
|
||||
/**
|
||||
* 기본 대상 템플릿 → 라우트 경로.
|
||||
*
|
||||
* 관리자 템플릿 라우트는 관리자 URL 프리픽스 규약을 따른다.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const DEFAULT_TARGETS = [
|
||||
'sirsoft-basic' => '/e2e-sandbox',
|
||||
'sirsoft-admin_basic' => '*/admin/e2e-sandbox',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private LayoutRepositoryInterface $layoutRepository,
|
||||
private TemplateRepositoryInterface $templateRepository
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드 (0 성공)
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
|
||||
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
|
||||
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// settings JSON 이 debug 를 덮어써도 시드 작업이 막히지 않도록 인라인 강제
|
||||
// (PlaywrightIssueToken 과 동일 근거 — bypass flag 인지 시 SettingsServiceProvider 는 이미 우회 상태).
|
||||
Config::set('app.debug', true);
|
||||
|
||||
$remove = (bool) $this->option('remove');
|
||||
$targets = $this->resolveTargets();
|
||||
|
||||
if ($targets === []) {
|
||||
$this->error('대상 템플릿이 없습니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ($targets as $identifier => $routePath) {
|
||||
if (! $this->processTemplate($identifier, $routePath, $remove)) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* `--template` 옵션을 기본 대상 목록과 대조해 처리 대상을 결정합니다.
|
||||
*
|
||||
* @return array<string, string> identifier => 라우트 경로
|
||||
*/
|
||||
private function resolveTargets(): array
|
||||
{
|
||||
$requested = $this->option('template') ?: [];
|
||||
|
||||
if ($requested === []) {
|
||||
return self::DEFAULT_TARGETS;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
foreach ($requested as $identifier) {
|
||||
if (! isset(self::DEFAULT_TARGETS[$identifier])) {
|
||||
$this->warn("시드 라우트 경로가 정의되지 않은 템플릿입니다 — 건너뜁니다: {$identifier}");
|
||||
|
||||
continue;
|
||||
}
|
||||
$targets[$identifier] = self::DEFAULT_TARGETS[$identifier];
|
||||
}
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 1개에 대해 시드 레이아웃 설치 또는 제거를 수행합니다.
|
||||
*
|
||||
* @param string $identifier 템플릿 identifier
|
||||
* @param string $routePath 시드 라우트 경로
|
||||
* @param bool $remove true 면 제거, false 면 설치
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
private function processTemplate(string $identifier, string $routePath, bool $remove): bool
|
||||
{
|
||||
$templateDir = base_path("templates/{$identifier}");
|
||||
|
||||
// 활성 디렉토리가 없으면 그 템플릿은 설치되지 않은 것 — 조용히 건너뛴다
|
||||
// (E2E 대상 템플릿이 환경마다 다를 수 있으므로 실패로 취급하지 않는다).
|
||||
if (! File::isDirectory($templateDir)) {
|
||||
$this->warn("활성 템플릿 디렉토리가 없어 건너뜁니다: {$identifier}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$template = $this->templateRepository->findByIdentifier($identifier);
|
||||
if (! $template) {
|
||||
$this->warn("템플릿이 DB 에 없어 건너뜁니다: {$identifier}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$layoutPath = "{$templateDir}/layouts/".self::LAYOUT_NAME.'.json';
|
||||
|
||||
if ($remove) {
|
||||
if (File::exists($layoutPath)) {
|
||||
File::delete($layoutPath);
|
||||
}
|
||||
$this->removeSeedRoute($identifier, $templateDir);
|
||||
$this->layoutRepository->deleteByName($template->id, self::LAYOUT_NAME);
|
||||
} else {
|
||||
$fixturePath = base_path(
|
||||
'tests/Playwright/fixtures/seed-layouts/'.$identifier.'.'.self::LAYOUT_NAME.'.json'
|
||||
);
|
||||
if (! File::exists($fixturePath)) {
|
||||
$this->error("시드 레이아웃 fixture 가 없습니다: {$fixturePath}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = json_decode((string) File::get($fixturePath), true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($content)) {
|
||||
$this->error("시드 레이아웃 fixture 파싱 실패: {$fixturePath}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
File::copy($fixturePath, $layoutPath);
|
||||
$this->addSeedRoute($identifier, $templateDir, $routePath);
|
||||
|
||||
$this->layoutRepository->updateOrCreate(
|
||||
['template_id' => $template->id, 'name' => self::LAYOUT_NAME],
|
||||
[
|
||||
'content' => $content,
|
||||
'source_type' => 'template',
|
||||
'original_content_hash' => $this->computeContentHash($content),
|
||||
'original_content_size' => $this->computeContentSize($content),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// 편집기/공개 서빙 캐시에서 시드 레이아웃 키를 지운다. 삭제 경로에서도 캐시가 남으면
|
||||
// 편집기가 stale 문서를 계속 받는다.
|
||||
$this->forgetSeedLayoutCache($template->id, $identifier);
|
||||
|
||||
$this->info(($remove ? '제거' : '설치')." 완료: {$identifier} ({$routePath})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 routes.json 에 시드 라우트를 추가합니다.
|
||||
*
|
||||
* 재작성은 JSON 재직렬화라 원본의 주석 그룹 사이 빈 줄 같은 서식이 사라진다. 그래서 첫
|
||||
* 설치 시 원본을 `.playwright-backup` 으로 보관하고, 제거 때 그 파일을 그대로 되돌린다
|
||||
* (바이트 동일 복원). 백업이 이미 있으면 덮어쓰지 않는다 — 비정상 종료로 시드가 남은 상태의
|
||||
* routes.json 을 "원본" 으로 굳히지 않기 위함.
|
||||
*
|
||||
* @param string $identifier 템플릿 identifier
|
||||
* @param string $templateDir 활성 템플릿 디렉토리 절대 경로
|
||||
* @param string $routePath 등록할 시드 라우트 경로
|
||||
*/
|
||||
private function addSeedRoute(string $identifier, string $templateDir, string $routePath): void
|
||||
{
|
||||
$path = "{$templateDir}/routes.json";
|
||||
$routes = $this->readRoutes($identifier, $path);
|
||||
if ($routes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backupPath = $path.self::ROUTES_BACKUP_SUFFIX;
|
||||
if (! File::exists($backupPath)) {
|
||||
File::copy($path, $backupPath);
|
||||
}
|
||||
|
||||
// 마커 항목을 먼저 걷어내 반복 실행이 라우트를 중복 추가하지 않게 한다.
|
||||
$entries = $this->withoutSeedRoutes($routes);
|
||||
$entries[] = [
|
||||
'_marker' => self::ROUTE_MARKER,
|
||||
'_comment' => 'Playwright E2E 전용 시드 화면 — playwright:seed-layout 이 설치/제거한다. 제품 라우트가 아니다.',
|
||||
'path' => $routePath,
|
||||
'layout' => self::LAYOUT_NAME,
|
||||
'auth_required' => str_starts_with($routePath, '*/admin'),
|
||||
'meta' => [
|
||||
'title' => 'E2E Sandbox',
|
||||
],
|
||||
];
|
||||
|
||||
$routes['routes'] = $entries;
|
||||
|
||||
File::put(
|
||||
$path,
|
||||
json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 routes.json 에서 시드 라우트를 제거합니다.
|
||||
*
|
||||
* 백업이 있으면 그것을 그대로 복원해 서식까지 원상 복구한다. 백업이 없으면(다른 경로로 시드가
|
||||
* 들어간 경우) 마커 항목만 걷어낸 재직렬화로 대체한다.
|
||||
*
|
||||
* @param string $identifier 템플릿 identifier
|
||||
* @param string $templateDir 활성 템플릿 디렉토리 절대 경로
|
||||
*/
|
||||
private function removeSeedRoute(string $identifier, string $templateDir): void
|
||||
{
|
||||
$path = "{$templateDir}/routes.json";
|
||||
$backupPath = $path.self::ROUTES_BACKUP_SUFFIX;
|
||||
|
||||
if (File::exists($backupPath)) {
|
||||
File::put($path, File::get($backupPath));
|
||||
File::delete($backupPath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$routes = $this->readRoutes($identifier, $path);
|
||||
if ($routes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$routes['routes'] = $this->withoutSeedRoutes($routes);
|
||||
|
||||
File::put(
|
||||
$path,
|
||||
json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* routes.json 을 디코드해 반환합니다 (부재/파싱 실패 시 경고 후 null).
|
||||
*
|
||||
* @param string $identifier 템플릿 identifier (경고 메시지용)
|
||||
* @param string $path routes.json 절대 경로
|
||||
* @return array<string, mixed>|null 디코드 결과
|
||||
*/
|
||||
private function readRoutes(string $identifier, string $path): ?array
|
||||
{
|
||||
if (! File::exists($path)) {
|
||||
$this->warn("routes.json 이 없어 라우트 처리를 건너뜁니다: {$identifier}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$routes = json_decode((string) File::get($path), true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($routes)) {
|
||||
$this->warn("routes.json 을 파싱할 수 없어 라우트 처리를 건너뜁니다: {$identifier}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트 배열에서 시드 마커가 붙은 항목을 제외해 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $routes 디코드된 routes.json
|
||||
* @return array<int, mixed> 마커 항목이 제거된 라우트 배열
|
||||
*/
|
||||
private function withoutSeedRoutes(array $routes): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$routes['routes'] ?? [],
|
||||
fn ($route) => ! is_array($route) || ($route['_marker'] ?? null) !== self::ROUTE_MARKER
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 시드 레이아웃의 캐시 키를 삭제합니다.
|
||||
*
|
||||
* `InvalidatesLayoutCache::forgetLayoutCacheKeys` 는 레이아웃 객체의
|
||||
* `template_id` / `name` / `source_type` / `source_identifier` 만 읽으므로,
|
||||
* 삭제 후(모델 부재) 에도 동일 형태의 경량 객체로 호출할 수 있다.
|
||||
*
|
||||
* @param int $templateId 템플릿 ID
|
||||
* @param string $identifier 템플릿 identifier (버전 포함 공개 서빙 키에 사용)
|
||||
*/
|
||||
private function forgetSeedLayoutCache(int $templateId, string $identifier): void
|
||||
{
|
||||
$this->forgetLayoutCacheKeys(
|
||||
(object) [
|
||||
'template_id' => $templateId,
|
||||
'name' => self::LAYOUT_NAME,
|
||||
'source_type' => null,
|
||||
'source_identifier' => null,
|
||||
],
|
||||
$identifier
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,19 @@ interface LayoutRepositoryInterface
|
||||
*/
|
||||
public function findByName(int $templateId, string $name): ?TemplateLayout;
|
||||
|
||||
/**
|
||||
* 특정 레이아웃을 영구 삭제 (템플릿 ID와 이름으로)
|
||||
*
|
||||
* soft delete 가 아닌 `forceDelete` 다. 이름으로 재등록될 수 있는 레이아웃
|
||||
* (파일 → DB 동기화 대상) 은 soft delete 잔여 행이 남으면 재등록이 충돌하므로
|
||||
* 파일 기준 동기화 경로와 동일하게 영구 삭제한다.
|
||||
*
|
||||
* @param int $templateId 템플릿 ID
|
||||
* @param string $name 레이아웃 이름
|
||||
* @return bool 삭제 여부 (대상 부재 시 false)
|
||||
*/
|
||||
public function deleteByName(int $templateId, string $name): bool;
|
||||
|
||||
/**
|
||||
* ID로 레이아웃 조회
|
||||
*
|
||||
|
||||
@@ -19,6 +19,13 @@ class HookManager implements HookManagerInterface
|
||||
|
||||
private static array $dispatching = [];
|
||||
|
||||
/**
|
||||
* 구 훅 이름 사용 경고를 이미 남긴 훅 이름 집합 — 요청마다 한 번씩만 경고합니다.
|
||||
*
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private static array $legacyHookNoticeShown = [];
|
||||
|
||||
/**
|
||||
* 현재 실행 중인 훅 이름 스택 — 정책 Listener 등 "어느 훅에서 호출되었는지" 알아야 하는
|
||||
* 단일 핸들러 패턴에 사용됩니다. (내부 전용)
|
||||
@@ -362,4 +369,43 @@ class HookManager implements HookManagerInterface
|
||||
|
||||
return static::applyFilters($hookName, $value, ...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 표준 이름과 구 이름을 함께 발행하는 Filter 실행
|
||||
*
|
||||
* 이름이 표준(`core.{대상}.{동작}_validation_rules`)과 어긋난 채 이미 공개된 훅을 표준 이름으로
|
||||
* 옮길 때 사용합니다. 표준 이름을 먼저 적용하고 그 결과에 구 이름을 다시 적용하므로,
|
||||
* 새로 구독하는 확장과 구 이름을 구독 중인 기존 확장이 함께 동작합니다.
|
||||
*
|
||||
* 구 이름에 실제 구독자가 있을 때만 한 번 경고를 남깁니다 — 개명 사실을 확장 개발자가 알 수 있게
|
||||
* 하되, 구독자가 없는 환경에서 로그를 어지럽히지 않기 위함입니다.
|
||||
*
|
||||
* @param string $hookName 표준 훅 이름
|
||||
* @param string $legacyHookName 구 훅 이름 (하위호환용)
|
||||
* @param mixed $value 필터링할 값
|
||||
* @param mixed ...$args 필터에 전달할 추가 인자
|
||||
* @return mixed 필터링된 값
|
||||
*/
|
||||
public static function applyFiltersWithLegacyName(
|
||||
string $hookName,
|
||||
string $legacyHookName,
|
||||
mixed $value = null,
|
||||
...$args
|
||||
): mixed {
|
||||
$value = static::applyFilters($hookName, $value, ...$args);
|
||||
|
||||
if (! isset(self::$filters[$legacyHookName])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! isset(self::$legacyHookNoticeShown[$legacyHookName])) {
|
||||
self::$legacyHookNoticeShown[$legacyHookName] = true;
|
||||
Log::warning('구 훅 이름 사용 중 — 표준 이름으로 옮겨 주세요', [
|
||||
'legacy' => $legacyHookName,
|
||||
'standard' => $hookName,
|
||||
]);
|
||||
}
|
||||
|
||||
return static::applyFilters($legacyHookName, $value, ...$args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Extension\PluginManager;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\Admin\UpdatePluginSettingsRequest;
|
||||
use App\Services\PluginSettingsService;
|
||||
use App\Support\SensitiveSettingMask;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
@@ -18,13 +20,35 @@ class PluginSettingsController extends AdminBaseController
|
||||
* PluginSettingsController 생성자
|
||||
*
|
||||
* @param PluginSettingsService $pluginSettingsService 플러그인 설정 서비스
|
||||
* @param PluginManager $pluginManager 설정 스키마(sensitive 플래그) 조회용
|
||||
*/
|
||||
public function __construct(
|
||||
private PluginSettingsService $pluginSettingsService
|
||||
private PluginSettingsService $pluginSettingsService,
|
||||
private PluginManager $pluginManager
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답에 실을 설정에서 민감값을 마스크로 치환한다.
|
||||
*
|
||||
* 이 응답은 관리자 화면으로 나가므로 암호화 키·시크릿의 평문이 브라우저·개발자 도구·프록시
|
||||
* 로그에 남지 않아야 한다. 값이 저장되어 있다는 사실만 마스크로 알린다. 운영자가 값을 건드리지
|
||||
* 않으면 화면이 마스크를 그대로 되돌려 보내고, 저장 단계가 그것을 걸러 기존 값을 보존한다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @param array<string, mixed> $settings 복호화된 설정
|
||||
* @return array<string, mixed> 마스킹된 설정
|
||||
*/
|
||||
private function maskSensitive(string $identifier, array $settings): array
|
||||
{
|
||||
$plugin = $this->pluginManager->getPlugin($identifier);
|
||||
|
||||
return $plugin === null
|
||||
? $settings
|
||||
: SensitiveSettingMask::apply($settings, $plugin->getSettingsSchema());
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정을 조회합니다.
|
||||
*
|
||||
@@ -39,7 +63,7 @@ class PluginSettingsController extends AdminBaseController
|
||||
return $this->notFound('plugins.not_found');
|
||||
}
|
||||
|
||||
return $this->success('messages.success', $settings);
|
||||
return $this->success('messages.success', $this->maskSensitive($identifier, $settings));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +89,7 @@ class PluginSettingsController extends AdminBaseController
|
||||
|
||||
return $this->success(
|
||||
'plugins.settings.updated',
|
||||
$this->pluginSettingsService->get($identifier)
|
||||
$this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,21 @@ class UpdatePluginSettingsRequest extends FormRequest
|
||||
$rules[$field] = $fieldRules;
|
||||
}
|
||||
|
||||
return HookManager::applyFilters('core.plugin_settings.update_rules', $rules, $identifier);
|
||||
// 표준 이름(`core.{대상}.{동작}_validation_rules`)으로 발행하되, 이미 공개돼 구독 중일 수 있는
|
||||
// 구 이름(`core.plugin_settings.update_rules`)도 함께 발행한다 — 구 이름을 구독하는 제3자
|
||||
// 확장이 조용히 멈추지 않도록 한다. 구 이름은 구독자가 있을 때만 1회 경고가 남는다.
|
||||
//
|
||||
// 3번째 인자로 이번 요청의 입력값을 함께 넘긴다. 확장이 "현재 입력한 모드에 따라
|
||||
// 다른 필드를 필수로 만드는" 조건부 규칙을 만들려면 입력값이 필요한데, 그것이 없어
|
||||
// 확장이 request() 를 직접 들여다보던 것을 없애기 위함이다.
|
||||
// 기존 2인자 구독자는 그대로 동작한다(초과 인자는 무시된다).
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.plugin_settings.update_validation_rules',
|
||||
'core.plugin_settings.update_rules',
|
||||
$rules,
|
||||
$identifier,
|
||||
$this->all(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,11 +13,13 @@ class ValidateResetTokenRequest extends FormRequest
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* 공개 엔드포인트이므로 항상 통과시킨다 — 접근 제어는 미들웨어가 담당한다.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // 공개 엔드포인트
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +34,8 @@ class ValidateResetTokenRequest extends FormRequest
|
||||
'email' => 'required|email',
|
||||
];
|
||||
|
||||
return HookManager::applyFilters(
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.auth.validate_reset_token_validation_rules',
|
||||
'core.auth.validate_reset_token_rules',
|
||||
$rules,
|
||||
$this
|
||||
|
||||
@@ -9,6 +9,8 @@ class VerifyPasswordRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -27,6 +29,9 @@ class VerifyPasswordRequest extends FormRequest
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.auth.verify_password_rules', $rules);
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.auth.verify_password_validation_rules',
|
||||
'core.auth.verify_password_rules',
|
||||
$rules);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class ChangelogRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -33,7 +35,10 @@ class ChangelogRequest extends FormRequest
|
||||
'to_version' => 'nullable|string|regex:/^\d+\.\d+\.\d+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$/|required_with:from_version',
|
||||
];
|
||||
|
||||
return HookManager::applyFilters('core.extension.changelog_rules', $rules);
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.extension.changelog_validation_rules',
|
||||
'core.extension.changelog_rules',
|
||||
$rules);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,8 @@ class SearchRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -39,7 +41,10 @@ class SearchRequest extends FormRequest
|
||||
|
||||
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
|
||||
// 예: sort의 in 규칙, board_slug 등 모듈별 파라미터
|
||||
return HookManager::applyFilters('core.search.validation_rules', $rules, $this);
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.search.index_validation_rules',
|
||||
'core.search.validation_rules',
|
||||
$rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,8 @@ class UploadAvatarRequest extends FormRequest
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -46,7 +48,10 @@ class UploadAvatarRequest extends FormRequest
|
||||
],
|
||||
];
|
||||
|
||||
return HookManager::applyFilters('core.user.upload_avatar_rules', $rules);
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.user.upload_avatar_validation_rules',
|
||||
'core.user.upload_avatar_rules',
|
||||
$rules);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,28 @@ class LayoutRepository implements LayoutRepositoryInterface
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 레이아웃을 영구 삭제 (템플릿 ID와 이름으로)
|
||||
*
|
||||
* @param int $templateId 템플릿 ID
|
||||
* @param string $name 레이아웃 이름
|
||||
* @return bool 삭제 여부 (대상 부재 시 false)
|
||||
*/
|
||||
public function deleteByName(int $templateId, string $name): bool
|
||||
{
|
||||
$layout = TemplateLayout::where('template_id', $templateId)
|
||||
->where('name', $name)
|
||||
->first();
|
||||
|
||||
if (! $layout) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$layout->forceDelete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 레이아웃 조회
|
||||
*
|
||||
|
||||
@@ -174,11 +174,11 @@ class IdentityVerificationService
|
||||
* 비동기 검증 흐름(Stripe Identity / 토스인증 push / 외부 redirect 콜백 대기) 에서 클라이언트가
|
||||
* `GET /api/identity/challenges/{id}` 로 상태를 폴링할 때 사용합니다.
|
||||
*
|
||||
* 반환 필드는 코드 본체·내부 metadata 를 제외한 공개 안전 필드만:
|
||||
* - id / status / provider_id / purpose / render_hint / expires_at / attempts / max_attempts / public_payload
|
||||
* 반환 필드는 코드 본체·내부 metadata·시도 횟수를 제외한 공개 안전 필드만:
|
||||
* - id / status / provider_id / purpose / render_hint / expires_at / max_attempts / public_payload
|
||||
*
|
||||
* attempts/max_attempts 는 프론트 풀페이지 (`auth/identity_challenge.json`) 가 "남은 시도 횟수" UI
|
||||
* 카운트다운에 사용한다. URL 직접 진입(외부 redirect 콜백 후) 흐름에서도 정확한 한도 표시 보장.
|
||||
* max_attempts 는 정책 상수라 노출해도 무방하며, 프론트 풀페이지 (`auth/identity_challenge.json`) 가
|
||||
* 시도 한도 표시에 사용한다. 누적 시도 횟수(attempts)는 싣지 않는다 — 사유는 반환 지점 주석 참조.
|
||||
*
|
||||
* @param string $challengeId Challenge UUID
|
||||
* @return array<string, mixed>|null 공개 상태 또는 null (없는 경우)
|
||||
@@ -207,8 +207,9 @@ class IdentityVerificationService
|
||||
'render_hint' => $log->render_hint,
|
||||
'expires_at' => optional($log->expires_at)->toIso8601String(),
|
||||
// 시도 횟수(attempts)는 공개 폴링 응답에 싣지 않는다 — challenge id 만 알면 누구나
|
||||
// 조회할 수 있는 경로라, 남의 인증 시도가 몇 번 실패했는지가 그대로 드러난다.
|
||||
// 화면의 '남은 시도 횟수' 는 모달이 자기 시도를 세어 표시하므로 영향이 없다.
|
||||
// 조회할 수 있는 경로라, 남의 인증 시도가 몇 번 실패했는지가 드러나고 잠금 직전까지
|
||||
// 시도 횟수를 맞춰 보는 데도 쓰일 수 있다. 상한(max_attempts)은 정책 상수라 노출해도
|
||||
// 무방하며, 화면의 '남은 시도 횟수' 는 모달이 자기 시도를 세어 표시하므로 영향이 없다.
|
||||
'max_attempts' => (int) $log->max_attempts,
|
||||
'public_payload' => $publicPayload,
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Support\SensitiveSettingMask;
|
||||
use App\Traits\FiltersFrontendSchema;
|
||||
use App\Traits\NormalizesSettingsData;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -140,6 +141,17 @@ class PluginSettingsService
|
||||
return $settings ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정을 저장합니다.
|
||||
*
|
||||
* 스키마의 sensitive 필드는 암호화하여 보관하며, 화면이 마스크를 그대로 되돌려 보낸 항목은
|
||||
* "변경 없음" 으로 보아 기존 값을 유지합니다. 저장은 기존 설정 위 병합이므로 요청에 없는
|
||||
* 키는 종전 값이 남습니다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @param array<string, mixed> $settings 저장할 설정 (검증 통과분)
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
public function save(string $identifier, array $settings): bool
|
||||
{
|
||||
// Before 훅
|
||||
@@ -162,6 +174,9 @@ class PluginSettingsService
|
||||
|
||||
// 스키마 기반으로 민감한 필드 암호화
|
||||
$schema = $pluginInstance->getSettingsSchema();
|
||||
// 화면이 마스크를 그대로 되돌려 보낸 sensitive 필드는 "변경 없음" 이다 — 병합에서 기존 값이
|
||||
// 유지되도록 요청에서 제거한다. 빈 문자열은 제거하지 않는다(지우겠다는 명시적 의사).
|
||||
$settings = SensitiveSettingMask::stripUnchanged($settings, $schema);
|
||||
$settings = $this->encryptSensitiveFields($settings, $schema);
|
||||
|
||||
// 기존 설정과 병합
|
||||
@@ -202,6 +217,14 @@ class PluginSettingsService
|
||||
return $storage->put('settings', self::SETTINGS_FILENAME, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정을 기본값으로 되돌립니다.
|
||||
*
|
||||
* 설정 파일을 삭제하고 캐시를 비웁니다 — 이후 조회는 플러그인이 선언한 기본값을 돌려줍니다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @return bool 초기화 성공 여부
|
||||
*/
|
||||
public function reset(string $identifier): bool
|
||||
{
|
||||
// Before 훅
|
||||
@@ -230,6 +253,14 @@ class PluginSettingsService
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정 디렉토리를 통째로 삭제합니다.
|
||||
*
|
||||
* 플러그인 삭제 시 남은 설정 파일을 정리하는 용도입니다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @return bool 삭제 성공 여부
|
||||
*/
|
||||
public function deleteSettingsDirectory(string $identifier): bool
|
||||
{
|
||||
// Before 훅
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 민감 설정값(sensitive: true)의 응답 마스킹 규약.
|
||||
*
|
||||
* 관리자 설정 조회 응답은 저장된 비밀값(암호화 키, API 시크릿 등)을 평문으로 되돌려주지 않는다.
|
||||
* 값이 저장되어 있다는 사실만 마스크로 알리고, 실제 값은 서버 안에만 둔다 — 화면·브라우저 개발자
|
||||
* 도구·프록시 로그에 비밀값이 남지 않게 하기 위함이다.
|
||||
*
|
||||
* 저장 쪽과 짝을 이룬다: 운영자가 값을 건드리지 않으면 화면이 마스크를 그대로 되돌려 보내므로,
|
||||
* 저장 단계에서 마스크를 걸러내 기존 값을 보존한다. 빈 문자열은 마스크가 아니라 "지우겠다" 는
|
||||
* 명시적 의사로 취급한다.
|
||||
*
|
||||
* 내부 소비자(provider 에 자격증명을 주입하는 listener 등)는 마스킹되지 않은 값을 그대로 받는다 —
|
||||
* 마스킹은 오직 관리자 API 응답 경계에서만 적용한다.
|
||||
*
|
||||
* @since 7.0.6
|
||||
*/
|
||||
final class SensitiveSettingMask
|
||||
{
|
||||
/**
|
||||
* 저장된 비밀값을 대신하는 마스크 문자열.
|
||||
*
|
||||
* 실제 값으로 오인될 수 없도록 통상적인 키·시크릿 문자셋에 없는 형태를 쓴다.
|
||||
*/
|
||||
public const MASK = '••••••••';
|
||||
|
||||
/**
|
||||
* 스키마의 sensitive 필드를 마스크로 치환한다.
|
||||
*
|
||||
* 값이 비어 있는 필드는 마스크를 씌우지 않는다 — 화면이 "아직 입력되지 않음" 을 구분해야 한다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 설정 배열 (복호화된 상태)
|
||||
* @param array<string, mixed> $schema 설정 스키마 (sensitive 플래그 보유)
|
||||
* @return array<string, mixed> 마스킹된 설정 배열
|
||||
*/
|
||||
public static function apply(array $settings, array $schema): array
|
||||
{
|
||||
foreach ($schema as $field => $config) {
|
||||
if (! is_array($config) || ! ($config['sensitive'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
if (! array_key_exists($field, $settings)) {
|
||||
continue;
|
||||
}
|
||||
if ($settings[$field] === null || $settings[$field] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$settings[$field] = self::MASK;
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 요청에서 마스크로 되돌아온 sensitive 필드를 제거한다.
|
||||
*
|
||||
* 제거된 필드는 저장 단계의 기존 설정 병합에서 종전 값이 유지된다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 저장 요청 설정 배열
|
||||
* @param array<string, mixed> $schema 설정 스키마
|
||||
* @return array<string, mixed> 마스크 필드가 제거된 설정 배열
|
||||
*/
|
||||
public static function stripUnchanged(array $settings, array $schema): array
|
||||
{
|
||||
foreach ($schema as $field => $config) {
|
||||
if (! is_array($config) || ! ($config['sensitive'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
if (($settings[$field] ?? null) === self::MASK) {
|
||||
unset($settings[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ return new class extends Migration
|
||||
$table->string('extension_type', 20)->nullable()->comment('확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의)');
|
||||
$table->string('extension_identifier', 255)->nullable()->comment('확장 식별자 (예: core, sirsoft-board, sirsoft-payment)');
|
||||
$table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록 (예: ["name", "icon", "order"])');
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('등록자 ID');
|
||||
$table->foreignId('created_by')->nullable()->comment('등록자 ID')->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['parent_id', 'order']);
|
||||
|
||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('user_consents', function (Blueprint $table) {
|
||||
$table->id()->comment('동의 이력 ID');
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('사용자 ID');
|
||||
$table->foreignId('user_id')->comment('사용자 ID')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('consent_type', 30)->comment('동의 유형: terms, privacy');
|
||||
$table->timestamp('agreed_at')->comment('동의 일시');
|
||||
$table->timestamp('revoked_at')->nullable()->comment('철회 일시 (향후 플러그인 확장용)');
|
||||
|
||||
@@ -28,7 +28,7 @@ return new class extends Migration
|
||||
$table->boolean('update_available')->default(false)->comment('업데이트 가능 여부');
|
||||
$table->string('update_source', 20)->nullable()->comment('업데이트 출처 (github, pending, bundled)');
|
||||
$table->mediumText('config')->nullable()->comment('모듈 설정 정보');
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('모듈 생성자 ID');
|
||||
$table->foreignId('created_by')->nullable()->comment('모듈 생성자 ID')->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ return new class extends Migration
|
||||
$table->string('github_changelog_url', 512)->nullable()->comment('GitHub 변경 내역 URL');
|
||||
$table->enum('status', ['active', 'inactive', 'installing', 'uninstalling', 'updating'])->default('inactive')->index()->comment('상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중)');
|
||||
$table->mediumText('hooks')->nullable()->comment('훅 설정 정보');
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('플러그인 생성자 ID');
|
||||
$table->foreignId('created_by')->nullable()->comment('플러그인 생성자 ID')->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('template_layouts', function (Blueprint $table) {
|
||||
$table->id()->comment('레이아웃 ID');
|
||||
$table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID');
|
||||
$table->foreignId('template_id')->comment('템플릿 ID')->constrained('templates')->cascadeOnDelete();
|
||||
$table->string('name')->comment('레이아웃 이름 (예: dashboard, users, user_edit)');
|
||||
$table->longText('content')->comment('레이아웃 JSON 내용');
|
||||
$table->string('extends')->nullable()->index()->comment('부모 레이아웃 이름 (예: layouts/_admin_base)');
|
||||
|
||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('template_layout_versions', function (Blueprint $table) {
|
||||
$table->id()->comment('버전 ID');
|
||||
$table->foreignId('layout_id')->constrained('template_layouts')->cascadeOnDelete()->comment('레이아웃 ID');
|
||||
$table->foreignId('layout_id')->comment('레이아웃 ID')->constrained('template_layouts')->cascadeOnDelete();
|
||||
$table->unsignedInteger('version')->comment('버전 번호 (자동 증가)');
|
||||
$table->longText('content')->comment('레이아웃 JSON 스냅샷');
|
||||
$table->text('changes_summary')->nullable()->comment('변경 요약 JSON: {"added": 3, "removed": 2, "is_restored": false, "restored_from": null}');
|
||||
|
||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('template_layout_extensions', function (Blueprint $table) {
|
||||
$table->id()->comment('확장 ID');
|
||||
$table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID (g7_templates 참조)');
|
||||
$table->foreignId('template_id')->comment('템플릿 ID (g7_templates 참조)')->constrained('templates')->cascadeOnDelete();
|
||||
$table->string('extension_type', 50)->comment('확장 타입: extension_point=확장점 방식, overlay=ID 기반 오버레이 방식');
|
||||
$table->string('target_name')->comment('타겟 이름 (extension_point: 확장점명, overlay: 레이아웃명)');
|
||||
$table->string('source_type', 50)->comment('출처 타입: template=템플릿(오버라이드용), module=모듈, plugin=플러그인');
|
||||
|
||||
@@ -15,12 +15,12 @@ return new class extends Migration
|
||||
$table->id()->comment('활동 로그 ID');
|
||||
$table->string('log_type', 20)->index()->comment('로그 유형 (admin: 관리자, user: 사용자, system: 시스템)');
|
||||
$table->nullableMorphs('loggable');
|
||||
$table->foreignId('user_id')->nullable()->index()->constrained('users')->nullOnDelete()->comment('사용자 ID');
|
||||
$table->foreignId('user_id')->nullable()->index()->comment('사용자 ID')->constrained('users')->nullOnDelete();
|
||||
$table->string('action', 50)->index()->comment('액션 유형 (created, updated, deleted, login, export 등)');
|
||||
$table->text('description')->comment('액션 상세 설명');
|
||||
$table->mediumText('properties')->nullable()->comment('변경 상세 데이터 (old/new 값)');
|
||||
$table->string('ip_address', 45)->nullable()->comment('IP 주소 (IPv6 대응)');
|
||||
$table->string('user_agent', 500)->nullable()->comment('User Agent');
|
||||
$table->string('user_agent', 500)->nullable()->comment('접속 브라우저 정보 (User Agent)');
|
||||
$table->timestamp('created_at')->useCurrent()->index()->comment('생성일시');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
Schema::create('template_layout_previews', function (Blueprint $table) {
|
||||
$table->id()->comment('ID');
|
||||
$table->char('token', 36)->unique()->comment('미리보기 URL 토큰 (UUID)');
|
||||
$table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID');
|
||||
$table->foreignId('template_id')->comment('템플릿 ID')->constrained('templates')->cascadeOnDelete();
|
||||
$table->string('layout_name', 255)->comment('미리보기 대상 레이아웃 이름');
|
||||
$table->longText('content')->comment('편집 중인 레이아웃 JSON');
|
||||
$table->unsignedBigInteger('admin_id')->comment('미리보기 생성 관리자 ID');
|
||||
|
||||
@@ -15,14 +15,14 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('notification_templates', function (Blueprint $table) {
|
||||
$table->id()->comment('ID');
|
||||
$table->foreignId('definition_id')->constrained('notification_definitions')->cascadeOnDelete()->comment('알림 정의 ID');
|
||||
$table->foreignId('definition_id')->comment('알림 정의 ID')->constrained('notification_definitions')->cascadeOnDelete();
|
||||
$table->string('channel', 50)->comment('채널: mail, database, fcm');
|
||||
$table->text('subject')->nullable()->comment('다국어 제목 ({"ko": "...", "en": "..."})');
|
||||
$table->mediumText('body')->comment('다국어 본문 ({"ko": "...", "en": "..."})');
|
||||
$table->boolean('is_active')->default(true)->comment('해당 채널 활성 여부');
|
||||
$table->boolean('is_default')->default(true)->comment('시더 생성 여부');
|
||||
$table->text('user_overrides')->nullable()->comment('사용자가 수정한 필드명 목록');
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
|
||||
$table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['definition_id', 'channel']);
|
||||
|
||||
@@ -19,12 +19,10 @@ return new class extends Migration
|
||||
$table->string('notification_type', 100)->comment('알림 타입: welcome, order_confirmed 등');
|
||||
$table->string('extension_type', 20)->default('core')->comment('확장 타입: core, module, plugin');
|
||||
$table->string('extension_identifier', 100)->default('core')->comment('확장 식별자');
|
||||
$table->foreignId('recipient_user_id')->nullable()->constrained('users')->nullOnDelete()
|
||||
->comment('수신자 회원 ID (회원인 경우)');
|
||||
$table->foreignId('recipient_user_id')->nullable()->comment('수신자 회원 ID (회원인 경우)')->constrained('users')->nullOnDelete();
|
||||
$table->string('recipient_identifier', 255)->comment('수신자 식별자 (채널별: 이메일, 디바이스토큰, user_id 등)');
|
||||
$table->string('recipient_name', 255)->nullable()->comment('수신자 표시명 (발송 시점 스냅샷)');
|
||||
$table->foreignId('sender_user_id')->nullable()->constrained('users')->nullOnDelete()
|
||||
->comment('발송자 회원 ID (null=시스템 자동)');
|
||||
$table->foreignId('sender_user_id')->nullable()->comment('발송자 회원 ID (null=시스템 자동)')->constrained('users')->nullOnDelete();
|
||||
$table->string('subject', 500)->nullable()->comment('렌더링된 제목');
|
||||
$table->longText('body')->nullable()->comment('렌더링된 본문');
|
||||
$table->string('status', 20)->default('sent')->comment('상태: sent, failed, skipped');
|
||||
|
||||
@@ -26,9 +26,9 @@ return new class extends Migration
|
||||
$table->foreignId('user_id')
|
||||
->nullable()
|
||||
->index()
|
||||
->comment('사용자 탈퇴 시 NULL 로 유지 — 감사 이력 보존 (CASCADE 금지 규정 준수)')
|
||||
->constrained('users')
|
||||
->nullOnDelete()
|
||||
->comment('사용자 탈퇴 시 NULL 로 유지 — 감사 이력 보존 (CASCADE 금지 규정 준수)');
|
||||
->nullOnDelete();
|
||||
|
||||
$table->string('target_hash', 64)->index()->comment('SHA256(email|phone) — PII 원본 저장 회피');
|
||||
$table->string('status', 32)->index()->comment('인증 challenge 생명주기 상태 (requested|sent|processing|verified|failed|expired|cancelled|policy_violation_logged) — App\\Enums\\IdentityVerificationStatus enum');
|
||||
|
||||
@@ -39,7 +39,7 @@ return new class extends Migration
|
||||
$table->json('manifest')->comment('language-pack.json 전체 스냅샷');
|
||||
$table->string('source_type', 30)->nullable()->comment('설치 소스 유형 (zip/github/url/bundled/bundled_with_extension)');
|
||||
$table->string('source_url', 500)->nullable()->comment('설치 소스 URL 또는 경로');
|
||||
$table->foreignId('installed_by')->nullable()->constrained('users')->nullOnDelete()->comment('설치자 사용자 ID');
|
||||
$table->foreignId('installed_by')->nullable()->comment('설치자 사용자 ID')->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('installed_at')->nullable()->comment('설치 시각');
|
||||
$table->timestamp('activated_at')->nullable()->comment('활성화 시각');
|
||||
$table->timestamps();
|
||||
|
||||
@@ -19,9 +19,9 @@ return new class extends Migration
|
||||
Schema::create('identity_message_templates', function (Blueprint $table) {
|
||||
$table->id()->comment('ID');
|
||||
$table->foreignId('definition_id')
|
||||
->comment('메시지 정의 ID (FK)')
|
||||
->constrained('identity_message_definitions')
|
||||
->cascadeOnDelete()
|
||||
->comment('메시지 정의 ID (FK)');
|
||||
->cascadeOnDelete();
|
||||
$table->string('channel', 20)->comment('메시지 템플릿 채널 (mail 현재 / sms 등 향후) — IdentityVerificationChannel 과는 별개의 도메인 분류');
|
||||
$table->text('subject')->nullable()
|
||||
->comment('다국어 제목 ({"ko":"...", "en":"..."}) — mail 채널에서만 의미');
|
||||
@@ -32,9 +32,9 @@ return new class extends Migration
|
||||
->comment('운영자가 수정한 필드명 목록 (예: ["subject","body","is_active"])');
|
||||
$table->foreignId('updated_by')
|
||||
->nullable()
|
||||
->comment('수정자 (사용자 삭제 시 NULL)')
|
||||
->constrained('users')
|
||||
->nullOnDelete()
|
||||
->comment('수정자 (사용자 삭제 시 NULL)');
|
||||
->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['definition_id', 'channel'], 'idx_identity_message_tpl_def_channel_unique');
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('template_layout_extension_versions', function (Blueprint $table) {
|
||||
$table->id()->comment('버전 ID');
|
||||
$table->foreignId('extension_id')->constrained('template_layout_extensions')->cascadeOnDelete()->comment('레이아웃 확장 ID');
|
||||
$table->foreignId('extension_id')->comment('레이아웃 확장 ID')->constrained('template_layout_extensions')->cascadeOnDelete();
|
||||
$table->unsignedInteger('version')->comment('버전 번호 (자동 증가)');
|
||||
$table->longText('content')->comment('확장 정의 JSON 스냅샷');
|
||||
$table->text('changes_summary')->nullable()->comment('변경 요약 JSON: {"added": 3, "removed": 2, "is_restored": false, "restored_from": null}');
|
||||
|
||||
@@ -18,9 +18,9 @@ return new class extends Migration
|
||||
Schema::create('template_layout_attachments', function (Blueprint $table) {
|
||||
$table->id()->comment('첨부 파일 ID');
|
||||
$table->foreignId('template_id')
|
||||
->comment('소속 템플릿 ID')
|
||||
->constrained('templates')
|
||||
->cascadeOnDelete()
|
||||
->comment('소속 템플릿 ID');
|
||||
->cascadeOnDelete();
|
||||
$table->string('layout_name', 150)->nullable()->comment('사용 출처 레이아웃 이름');
|
||||
$table->string('disk', 50)->comment('스토리지 디스크 이름');
|
||||
$table->string('path', 500)->comment('스토리지 내 파일 경로');
|
||||
@@ -29,9 +29,9 @@ return new class extends Migration
|
||||
$table->unsignedBigInteger('size')->comment('파일 크기(바이트)');
|
||||
$table->foreignId('created_by')
|
||||
->nullable()
|
||||
->comment('업로더 사용자 ID')
|
||||
->constrained('users')
|
||||
->nullOnDelete()
|
||||
->comment('업로더 사용자 ID');
|
||||
->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['template_id', 'layout_name'], 'index_template_layout');
|
||||
|
||||
@@ -14,16 +14,16 @@ return new class extends Migration
|
||||
Schema::create('template_custom_translations', function (Blueprint $table) {
|
||||
$table->id()->comment('커스텀 다국어 키 ID');
|
||||
$table->foreignId('template_id')
|
||||
->comment('소속 템플릿 ID')
|
||||
->constrained('templates')
|
||||
->cascadeOnDelete()
|
||||
->comment('소속 템플릿 ID');
|
||||
->cascadeOnDelete();
|
||||
$table->string('layout_name', 150)->nullable()->comment('생성 출처 레이아웃 이름');
|
||||
$table->string('translation_key', 255)->comment('다국어 키 ($t: 참조 경로)');
|
||||
$table->json('values')->comment('로케일별 번역 값');
|
||||
$table->json('user_overrides')->nullable()->comment('사용자 수정 보존 추적');
|
||||
$table->enum('status', ['active', 'orphaned'])->default('active')->comment('상태 (active: 활성, orphaned: 고아)');
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자');
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
|
||||
$table->foreignId('created_by')->nullable()->comment('생성자')->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['template_id', 'translation_key'], 'template_custom_translations_template_key_unique');
|
||||
|
||||
@@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
|
||||
|
||||
<!-- @generated:start:api-readme-extensions -->
|
||||
- **확장 수**: 10 · **엔드포인트 수**: 384
|
||||
- **확장 수**: 13 · **엔드포인트 수**: 385
|
||||
|
||||
| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -214,6 +214,9 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 |
|
||||
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -2860,7 +2860,7 @@ HTTP/1.1 200
|
||||
| token | body | string | 예 | — | 인증/검증 토큰 |
|
||||
| email | body | email | 예 | — | 이메일 주소 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.validate_reset_token_rules`, `core.auth.validate_reset_token_messages`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.validate_reset_token_validation_rules`, `core.auth.validate_reset_token_messages`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| avatar | body | image | 예 | max 2048 | 아바타 이미지 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.upload_avatar_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.upload_avatar_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -103,6 +103,6 @@ Content-Type: image/png
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 현재 인증 사용자의 아바타 이미지를 업로드합니다. 기존 아바타가 있으면 먼저 삭제한 뒤 새 이미지를 `avatar` 컬렉션의 다형성 첨부파일로 등록하고, 업로드 활동을 로그로 기록합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 이미지는 최대 2048KB로 제한됩니다. 확장은 `core.user.upload_avatar_rules` 훅으로 검증 규칙을 추가할 수 있습니다.
|
||||
**설명** 현재 인증 사용자의 아바타 이미지를 업로드합니다. 기존 아바타가 있으면 먼저 삭제한 뒤 새 이미지를 `avatar` 컬렉션의 다형성 첨부파일로 등록하고, 업로드 활동을 로그로 기록합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 이미지는 최대 2048KB로 제한됩니다. 확장은 `core.user.upload_avatar_validation_rules` 훅으로 검증 규칙을 추가할 수 있습니다.
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) |
|
||||
| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -1586,7 +1586,7 @@ HTTP/1.1 200
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 코어의 버전별 변경사항(CHANGELOG)을 구조화된 배열로 조회합니다. `source`(active/bundled/github)로 어느 위치의 CHANGELOG를 읽을지, `from_version`/`to_version`으로 조회 범위를 지정합니다. `core.settings.read` 권한이 필요하며, 업데이트 안내 화면에서 새 버전에 무엇이 바뀌는지 보여줄 때 사용합니다. 확장은 `core.extension.changelog_rules` 훅으로 파라미터를 확장할 수 있습니다.
|
||||
**설명** 코어의 버전별 변경사항(CHANGELOG)을 구조화된 배열로 조회합니다. `source`(active/bundled/github)로 어느 위치의 CHANGELOG를 읽을지, `from_version`/`to_version`으로 조회 범위를 지정합니다. `core.settings.read` 권한이 필요하며, 업데이트 안내 화면에서 새 버전에 무엇이 바뀌는지 보여줄 때 사용합니다. 확장은 `core.extension.changelog_validation_rules` 훅으로 파라미터를 확장할 수 있습니다.
|
||||
|
||||
|
||||
### POST /api/admin/core-update/check
|
||||
|
||||
@@ -1883,8 +1883,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| purpose | string | `sensitive_action` | 인증 목적 (signup\|password_reset\|self_update\|sensitive_action\|login\|*module-defined*) — 코어 5종은 App\Enums\IdentityVerificationPurpose enum, 모듈/플러그인은 declaredPurposes 레지스트리 |
|
||||
| render_hint | string | `text_code` | 프론트 렌더 힌트 (text_code\|link\|external_redirect) |
|
||||
| expires_at | string | `2026-05-12T18:14:19+00:00` | expires 일시 |
|
||||
| attempts | integer | `3` | 시도 횟수 |
|
||||
| max_attempts | integer | `5` | 허용 최대 시도 횟수 |
|
||||
| max_attempts | integer | `5` | 허용 최대 시도 횟수 (정책 상수). 누적 시도 횟수(attempts)는 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있어 본 응답에 포함하지 않는다 |
|
||||
| public_payload | array | `[]` | 프론트 렌더에 필요한 공개 안전 페이로드 (민감 metadata 제외, 프로바이더별 UI 힌트 — 없으면 빈 배열) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -902,7 +902,7 @@ HTTP/1.1 200
|
||||
| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) |
|
||||
| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -753,7 +753,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) |
|
||||
| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -924,7 +924,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| identifier | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin_settings.update_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin_settings.update_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
| sort | query | string | 아니오 | `relevance`, `latest`, `oldest`, `views`, `popular`, `price_asc`, `price_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) |
|
||||
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
|
||||
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
||||
| board_slug | query | string | 아니오 | max 100 | 검색 범위를 특정 게시판으로 한정 (게시판 모듈이 `core.search.validation_rules` 훅으로 추가하는 파라미터, 해당 slug의 게시판 글만 검색) |
|
||||
| board_slug | query | string | 아니오 | max 100 | 검색 범위를 특정 게시판으로 한정 (게시판 모듈이 `core.search.index_validation_rules` 훅으로 추가하는 파라미터, 해당 slug의 게시판 글만 검색) |
|
||||
| category_id | query | integer | 아니오 | — | category 식별자 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.search.validation_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.search.index_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -704,7 +704,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) |
|
||||
| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| password | body | string | 예 | — | 비밀번호 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.verify_password_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.verify_password_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -126,6 +126,39 @@ return new class extends Migration
|
||||
};
|
||||
```
|
||||
|
||||
### 컬럼 comment 와 FK 체인 순서
|
||||
|
||||
`->comment()` 는 `->constrained()` / `->references()` / `->on()` **앞**에 둔다. 뒤에 체인하면 comment 가 컬럼이 아니라 외래키 정의에 부착되어 DB 에 방출되지 않는다.
|
||||
|
||||
```php
|
||||
// ✅ comment 가 컬럼에 부착된다
|
||||
$table->foreignId('user_id')
|
||||
->nullable()
|
||||
->comment('사용자 ID')
|
||||
->constrained('users')
|
||||
->nullOnDelete();
|
||||
|
||||
// ❌ comment 가 사라진다 — COLUMN_COMMENT 가 빈 문자열로 생성된다
|
||||
$table->foreignId('user_id')
|
||||
->nullable()
|
||||
->constrained('users')
|
||||
->nullOnDelete()
|
||||
->comment('사용자 ID');
|
||||
```
|
||||
|
||||
원인은 반환 타입 전이다. `ForeignIdColumnDefinition::constrained()` 는 `$this->references(...)->on(...)` 을 반환하며 이것은 `ColumnDefinition` 이 아니라 `ForeignKeyDefinition` 이다. `ForeignKeyDefinition` 은 `Fluent` 라서 `->comment()` 호출이 예외 없이 통과하지만, 그 값은 외래키 커맨드의 속성으로만 남고 MySQL grammar 의 `compileForeign` 은 comment 를 다루지 않는다. 그래서 **에러도 경고도 없이 조용히 사라진다.**
|
||||
|
||||
```sql
|
||||
-- 잘못된 순서로 생성된 테이블의 실측 결과
|
||||
SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS ...
|
||||
g7_menus name [메뉴 이름 (다국어 JSON)]
|
||||
g7_menus created_by [] ← 소스에는 comment 가 적혀 있다
|
||||
```
|
||||
|
||||
이미 `migrate` 를 마친 설치본은 마이그레이션을 다시 실행하지 않으므로, 소스를 교정해도 기존 DB 의 comment 는 비어 있는 채로 남는다. 소스 교정과 **업그레이드 스텝 백필**을 함께 수행한다. 백필은 comment 가 비어 있을 때만 채워 운영자가 직접 넣은 값을 보존하고, 자료형·NULL 허용·기본값을 현재 스키마에서 읽어 그대로 재적용해 설명 외에는 아무 것도 바꾸지 않는다.
|
||||
|
||||
면제: `// audit:allow migration-fk-comment-order <사유>` 인라인 주석 (해당 구문에 부착)
|
||||
|
||||
### 마이그레이션 멱등성
|
||||
|
||||
```
|
||||
|
||||
@@ -131,6 +131,15 @@ core.permission.update_validation_rules
|
||||
core.menu.store_validation_rules
|
||||
core.menu.update_validation_rules
|
||||
|
||||
# 구 이름에서 표준 이름으로 옮긴 훅 (7.0.6) — 구 이름도 함께 발행되므로 기존 구독은 유지되나,
|
||||
# 새로 구독할 때는 표준 이름을 쓴다. 구 이름에 구독자가 있으면 로그에 1회 안내가 남는다.
|
||||
core.plugin_settings.update_validation_rules # 구: core.plugin_settings.update_rules
|
||||
core.auth.validate_reset_token_validation_rules # 구: core.auth.validate_reset_token_rules
|
||||
core.auth.verify_password_validation_rules # 구: core.auth.verify_password_rules
|
||||
core.extension.changelog_validation_rules # 구: core.extension.changelog_rules
|
||||
core.search.index_validation_rules # 구: core.search.validation_rules
|
||||
core.user.upload_avatar_validation_rules # 구: core.user.upload_avatar_rules
|
||||
|
||||
# Layout Extension 훅
|
||||
core.layout_extension.before_apply
|
||||
core.layout_extension.after_apply
|
||||
|
||||
@@ -263,8 +263,50 @@ export function initModule(): void {
|
||||
|
||||
// IIFE 빌드 시 즉시 실행
|
||||
initModule();
|
||||
|
||||
// 코어 재초기화 시 재등록 진입점 노출 (아래 "코어 재초기화 시 핸들러 재등록" 참조)
|
||||
(window as any).__SirsoftEcommerce = {
|
||||
identifier: MODULE_IDENTIFIER,
|
||||
initModule,
|
||||
};
|
||||
```
|
||||
|
||||
### 코어 재초기화 시 핸들러 재등록
|
||||
|
||||
로케일 전환처럼 `TemplateApp` 이 다시 초기화되는 시점에 ActionDispatcher 는 **새 인스턴스로 교체**된다.
|
||||
이때 앞서 등록해 둔 확장 핸들러는 전부 사라지므로, 코어가 각 확장에 재등록을 요청한다.
|
||||
요청 방식은 **window 전역 객체에서 약속된 이름의 함수를 찾아 호출**하는 것 하나뿐이다.
|
||||
|
||||
| 확장 타입 | 전역 객체 | 재등록 진입점 |
|
||||
| --- | --- | --- |
|
||||
| 모듈 | `window.__[ModuleName]` | `initModule()` |
|
||||
| 플러그인 | `window.__[PluginName]` | `initPlugin()` |
|
||||
| 템플릿 | `window.G7TemplateHandlers` | 코어가 직접 재등록 (확장 작업 불필요) |
|
||||
|
||||
```typescript
|
||||
// 플러그인 엔트리 파일 (index.ts)
|
||||
function initPlugin(): void {
|
||||
registerHandlersWithRetry(); // 핸들러 재등록만 수행
|
||||
}
|
||||
|
||||
initPlugin();
|
||||
|
||||
(window as any).__SirsoftDaumPostcode = {
|
||||
identifier: PLUGIN_IDENTIFIER,
|
||||
initPlugin,
|
||||
};
|
||||
```
|
||||
|
||||
이름은 고정이다. 전역 객체를 노출하지 않거나 진입점 이름이 다르면(`init`, `bootstrap`, `setup` 등)
|
||||
코어는 그 확장을 재등록 대상에서 조용히 건너뛴다. 그 결과는 다음과 같다:
|
||||
|
||||
- 사용자가 언어를 한 번 바꾼 뒤부터 해당 확장의 모든 액션이 **무반응**이 된다
|
||||
- 핸들러가 없으므로 dispatch 는 그대로 무시된다 — 콘솔 에러도, 토스트도, 네트워크 요청도 없다
|
||||
- 새로고침하면 정상으로 돌아오므로 재현 조건을 모르면 원인 추적이 어렵다
|
||||
|
||||
진입점은 **핸들러 재등록만** 수행한다. 최초 진입 1회로 충분한 작업(리다이렉트 복귀 처리,
|
||||
MutationObserver·인터셉터 설치, DOM 주입 등)은 넣지 않는다 — 재초기화마다 중복 실행된다.
|
||||
|
||||
### 핸들러 정의
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -67,6 +67,24 @@
|
||||
- 지정 시: 해당 ID 의 DOM 요소에만 spinner mount. 요소 미발견 시 `transition_overlay.fallback_target` → `#app` 순으로 3단계 폴백
|
||||
- `replace: true` 아닌 일반 navigate(다른 path)는 `handleRouteChange` 경로로 가며 이 옵션은 효과 없음
|
||||
|
||||
`replace: true` 는 이 옵션의 **전제 조건**이다. 같은 `params` 에 함께 두지 않으면 엔진이 값을
|
||||
읽지 않아 오버레이가 표시되지 않는다. 키를 잘못 적은 것도 아니고 값이 틀린 것도 아니라 경고·예외가
|
||||
전혀 남지 않으므로, 화면을 직접 보지 않으면 무효 상태를 알아차릴 수 없다. 검색·필터 변경처럼
|
||||
목록을 다시 그리는 이동에도 동일하게 적용된다.
|
||||
|
||||
```json
|
||||
// ❌ 조용히 무시된다 — 오버레이가 뜨지 않는다
|
||||
{ "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true,
|
||||
"transition_overlay_target": "users_data_grid__body" } }
|
||||
|
||||
// ✅ replace 를 함께 선언
|
||||
{ "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true, "replace": true,
|
||||
"transition_overlay_target": "users_data_grid__body" } }
|
||||
```
|
||||
|
||||
정적 검사가 이 조합을 강제한다. 의도적으로 오버레이를 끄려면 키 자체를 지우고, 예외가 필요하면
|
||||
액션 노드 `comment` 에 `audit:allow layout-transition-overlay-target-requires-replace <사유>` 를 남긴다.
|
||||
|
||||
**DataGrid body 영역 한정 spinner 컨벤션**:
|
||||
|
||||
목록 페이지 페이지네이션 시 **pagination 영역은 제외하고 그리드 본문만** spinner 를 표시하려면 `transition_overlay_target` 에 DataGrid 의 `${id}__body` suffix 를 사용한다. DataGrid composite 컴포넌트는 root Div 에 `id` 를, 테이블/카드 목록을 감싸는 내부 wrapper Div 에 `${id}__body` 를 자동으로 부여한다 (pagination 은 body wrapper 밖의 형제).
|
||||
|
||||
@@ -68,6 +68,29 @@
|
||||
| `desktop` | 1024px 이상 | 데스크톱 전용 |
|
||||
| `portable` | 0 ~ 1023px | 모바일 + 태블릿 (비데스크톱) |
|
||||
|
||||
### 확장 코드에서 같은 경계를 판정할 때
|
||||
|
||||
모듈·플러그인이 화면 폭에 따라 다른 동작을 고르는 경우(팝업 대신 페이지 전환 등), 그 판정은
|
||||
레이아웃이 `responsive` 로 띄우는 안내와 **같은 값을 같은 방법으로** 읽어야 한다.
|
||||
|
||||
```ts
|
||||
// ✅ 엔진과 동일 — ResponsiveManager 가 window.innerWidth 로 breakpoint 를 정한다
|
||||
const PORTABLE_MAX_WIDTH = 1023;
|
||||
const isPortable = window.innerWidth <= PORTABLE_MAX_WIDTH;
|
||||
|
||||
// ❌ 경계에서 어긋난다
|
||||
const isPortable = window.matchMedia('(max-width: 1023px)').matches;
|
||||
```
|
||||
|
||||
`matchMedia` 는 CSS 픽셀 기준이라 `devicePixelRatio` 가 정수가 아닌 환경에서 `window.innerWidth`
|
||||
와 1px 어긋난다. 실측(Chrome, dPR 1.0000000447)에서 `innerWidth === 1023` 인데
|
||||
`matchMedia('(max-width: 1023px)')` 가 `false` 였다. 이 어긋남은 딱 경계 폭에서만 나타나므로
|
||||
개발 중에는 드러나지 않고, 화면은 "페이지로 이동합니다" 라고 안내해 놓고 실제로는 팝업이 열리는
|
||||
형태로 사용자에게만 보인다.
|
||||
|
||||
경계값 자체도 상수로 두고 SSoT(`ResponsiveManager` 의 `portable` 정의)를 주석에 밝힌다 —
|
||||
프리셋 범위가 바뀌면 확장도 함께 고쳐야 한다는 신호가 코드에 남는다.
|
||||
|
||||
### portable 프리셋 사용 규칙
|
||||
|
||||
`portable` 프리셋은 `mobile`과 `tablet`을 합친 범위입니다. 다음 규칙을 준수하세요:
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
- 도구: Playwright 1.49+ (TypeScript, Vitest 와 동일 스택)
|
||||
- 인증: PlaywrightIssueToken artisan 커맨드가 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS=1 + APP_DEBUG 3중 가드)
|
||||
- Base URL: PLAYWRIGHT_BASE_URL 환경변수 우선, .env APP_URL 차순위 (하드코딩 회피)
|
||||
- 로케일: 모든 config 에 use.locale = 'ko-KR' 고정 (미지정 시 Accept-Language 가 en-US 로 나가 화면이 영어로 렌더 → 한국어 단언 전멸)
|
||||
- 코어/확장 분리: 코어 = tests/Playwright/, 확장 = {확장 디렉토리}/tests/Playwright/
|
||||
- 데이터 생성: 데이터를 소유한 영역에 시드 커맨드 배치 (코어 ↔ 모듈 의존 역전 회피)
|
||||
- 편집기 저장 spec: 제품 화면 대신 전용 시드 화면(e2e_sandbox) 대상 — globalSetup 이 매 실행 원본으로 덮어씀 (§4.1)
|
||||
- 외부 의존: mock-first 전략 (page.route() 로 결제창/외부 API 가로채기)
|
||||
```
|
||||
|
||||
@@ -87,19 +89,36 @@ npm run test:e2e:ui # UI 디버깅 모드
|
||||
|
||||
base URL 은 코어 `.env` 의 `APP_URL` 에서 자동 해석된다 (`PLAYWRIGHT_BASE_URL` 로 덮어쓸 수 있다).
|
||||
|
||||
### 로케일 고정 (`locale: 'ko-KR'`)
|
||||
|
||||
코어와 확장의 모든 config 은 `use.locale` 을 `'ko-KR'` 로 고정한다. 생략하면 한국어 문구를 단언하는
|
||||
spec 이 전부 실패한다.
|
||||
|
||||
엔진의 로케일 우선순위는 `localStorage.g7_locale` → 서버가 내려준 값 → `'ko'` 이고
|
||||
([TemplateApp.ts](../../resources/js/core/TemplateApp.ts) 생성자), 서버값은 `SetLocale` 미들웨어가
|
||||
**미로그인 요청에서 `Accept-Language` 헤더로** 결정한다. Playwright 의 `locale` 옵션이 그 헤더를
|
||||
만들므로, 지정하지 않으면 첫 페이지 로드가 `en-US` 로 나가 화면이 영어로 렌더되고 엔진이 그 값을
|
||||
`localStorage` 에 저장한다. 이후 인증해도 저장된 값이 최우선이라 세션 전체가 영어로 고정된다.
|
||||
|
||||
`getByText('전체회원수')` 처럼 한국어만 단언하는 곳은 물론이고, `getByRole('button', { name: /저장/ })`
|
||||
같은 접근 가능한 이름 조회도 함께 깨진다.
|
||||
|
||||
### 실행 시 유의 (경험칙)
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --- | --- |
|
||||
| 산출물 위치 | 리포트·trace 는 **코어 루트**의 `test-results/{type}/{id}/`, `playwright-report/{type}/{id}/` 에 쌓인다. 확장 디렉토리 안에 쌓으면 Windows 에서 `{module\|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다 |
|
||||
| 레이아웃 수정 후 | 레이아웃 JSON 을 고쳤으면 `{type}:update {id} --force` **+ `template:cache-clear {템플릿}`** 까지 해야 브라우저에 반영된다. 레이아웃은 템플릿 캐시(`/api/layouts/{template}/...`)로 서빙되므로 `cache:clear` 만으로는 갱신되지 않는다 |
|
||||
| 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 설정 저장을 수반하는 spec 은 `--workers=1` 로 확인한다 |
|
||||
| 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 실행 옵션에 맡기지 않고 그 `describe` 에 `test.describe.configure({ mode: 'serial' })` 를 둔다 |
|
||||
| 워커 수 | 관리자 SPA 는 번들이 크고 레이아웃을 여러 번 받아온다. 개발 머신에서 2워커 이상이면 `page.waitForLoadState` 가 30초를 넘겨 **비결정적으로** 실패한다(실측: 같은 스위트가 회차마다 다른 5~7건 실패, 테스트당 8초 → 25초). 판정은 `--workers=1` 결과로 한다 |
|
||||
| 편집기 spec 만 몰아 실행할 때 | 레이아웃 편집기 spec 만 골라 돌리면 워커 전부가 동시에 편집기 페이지를 연다 — 전체 스위트에서는 가벼운 spec 이 섞여 그 집중이 생기지 않는다. 실측: 편집기 7파일 27건을 7워커로 돌리면 `g7le-preview-frame` 대기가 전부 30초 타임아웃(27/27 실패), 같은 코드로 2워커는 26/27 통과. 편집기만 선택 실행할 때는 `--workers=2` 이하로 둔다 |
|
||||
|
||||
## §4. 데이터 생성 위치 — 책임 분리 매트릭스
|
||||
|
||||
| 데이터 종류 | 책임 영역 | 위치 | 호출 |
|
||||
|---|---|---|---|
|
||||
| 코어 권한/역할/유저/Sanctum 토큰 | 코어 | `app/Console/Commands/PlaywrightIssueToken.php` | `php artisan playwright:issue-token --permissions=core.xxx` |
|
||||
| 편집기 저장 spec 대상 시드 화면 | 코어 | `app/Console/Commands/PlaywrightSeedLayout.php` | `php artisan playwright:seed-layout [--remove]` (globalSetup/globalTeardown 자동 호출) |
|
||||
| 모듈 권한 (`sirsoft-ecommerce.*`) | 모듈 | 코어 커맨드의 `--permissions=` 임의 식별자 | 동일 (Permission::firstOrCreate 자동 생성) |
|
||||
| 모듈 도메인 데이터 (상품/주문) | 모듈 | `modules/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | `php artisan playwright:seed-{id}` |
|
||||
| 플러그인 도메인 데이터 (결제 키) | 플러그인 | `plugins/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | 동일 |
|
||||
@@ -107,6 +126,81 @@ base URL 은 코어 `.env` 의 `APP_URL` 에서 자동 해석된다 (`PLAYWRIGHT
|
||||
|
||||
**핵심 원칙**: 코어는 모듈 도메인을 모른다. 모듈 도메인 시드를 코어에 두면 의존 역전.
|
||||
|
||||
### 4.1 저장(PUT)하는 spec 은 제품 화면을 대상으로 두지 않는다
|
||||
|
||||
레이아웃 편집기 spec 이 저장까지 수행하면 그 편집 결과는 **그대로 영속된다**. 대상이 제품 화면
|
||||
(`home` / `admin_dashboard` 등)이면 실행할 때마다 노드가 누적돼 개발 사이트에 그대로 노출된다.
|
||||
|
||||
실측(2026-07-30): `home` 에 빈 표 7개가 쌓여 20,321 → 33,696 bytes, 관리자 대시보드에 빈
|
||||
DonutChart 5개. 누적되면 캔버스 구조가 회차마다 달라져 같은 파일의 다른 테스트도 간헐 실패한다.
|
||||
|
||||
spec 안에 "추가한 노드를 삭제하고 다시 저장" 원복을 넣는 것으로는 해결되지 않는다 — 원복 실행
|
||||
후에도 레이아웃이 오염 시점과 정확히 같은 크기로 되돌아왔다(편집기가 그 시점에 들고 있던 문서를
|
||||
통째로 다시 저장). 원복은 그 자체가 또 한 번의 저장이라, 실패하면 잔여물이 남는다.
|
||||
|
||||
그래서 **저장 대상 자체를 전용 시드 화면으로 분리**한다.
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 레이아웃 이름 | `e2e_sandbox` |
|
||||
| 라우트 | 사용자 템플릿 `/e2e-sandbox`, 관리자 템플릿 `*/admin/e2e-sandbox` |
|
||||
| fixture 원본 | `tests/Playwright/fixtures/seed-layouts/{템플릿}.e2e_sandbox.json` |
|
||||
| 설치 위치 | **활성 템플릿 디렉토리만** (`_bundled` 배포 원본 무변경, 활성 디렉토리는 Git 무시 → 릴리스 미포함) |
|
||||
| 설치/제거 | `php artisan playwright:seed-layout [--remove]` (CLI + `G7_PLAYWRIGHT_BYPASS=1` 가드) |
|
||||
| 자동 호출 | `globalSetup` 설치 / `globalTeardown` 제거 |
|
||||
| spec 헬퍼 | `tests/Playwright/fixtures/seed-layout.ts` — `sandboxRouteParam()`, `SANDBOX_ROOT_ID` |
|
||||
|
||||
설치는 3종을 함께 처리한다: 레이아웃 파일 + `routes.json` 라우트(마커 기반 멱등) + DB 행 upsert.
|
||||
편집기의 라우트 트리는 `routes.json` 에서, 조회/저장은 DB 행을 대상으로 하므로 셋 중 하나만
|
||||
빠지면 `?route=` 로 열 수 없거나 404 가 된다.
|
||||
|
||||
`routes.json` 은 재직렬화하면 원본의 주석 그룹 사이 빈 줄 같은 서식이 사라지므로, 설치 시 원본을
|
||||
`routes.json.playwright-backup` 으로 보관하고 제거 때 그 파일을 그대로 되돌린다(바이트 동일 복원).
|
||||
백업이 이미 있으면 덮어쓰지 않는다 — 비정상 종료로 시드가 남은 상태의 파일을 "원본" 으로 굳히지
|
||||
않기 위함이다.
|
||||
|
||||
시드 설치는 **시드 행 하나만** 건드린다. `template:refresh-layout`(전체 재동기화)을 쓰지 않는
|
||||
이유는 그 경로가 파일에 없는 DB 레이아웃을 지우고 모든 레이아웃을 파일 기준으로 되돌리기
|
||||
때문이다 — 편집기 UI 로 저장한 변경은 파일이 아니라 DB 에만 있으므로 E2E 를 돌릴 때마다 사람이
|
||||
편집기로 만든 결과가 사라진다.
|
||||
|
||||
시드 화면은 매 실행 fixture 원본으로 덮어써지므로 회차 간 누적이 성립하지 않는다. 따라서 저장
|
||||
spec 에 원복 절차를 둘 필요가 없다.
|
||||
|
||||
```typescript
|
||||
import { SANDBOX_ROOT_ID, sandboxRouteParam } from '../../fixtures/seed-layout';
|
||||
import { editorPath } from '../../fixtures/layout-editor';
|
||||
|
||||
test('편집 후 저장 → PUT 200', async ({ page }) => {
|
||||
await gotoEditor(page, sandboxRouteParam()); // 사용자 템플릿
|
||||
const container = await editorPath(page, '', SANDBOX_ROOT_ID); // 고정 id 컨테이너
|
||||
// ... 편집 + 저장
|
||||
});
|
||||
```
|
||||
|
||||
**저장하지 않는(읽기 전용) spec 은 계속 제품 화면을 대상으로 둔다** — 오염 위험이 없고, 실제
|
||||
제품 레이아웃에 대한 검증이 유지되는 편이 낫다.
|
||||
|
||||
예외: **팔레트로 노드를 추가한 뒤 그 노드를 선택해야 하는** spec 은 저장하지 않아도 시드 화면을
|
||||
쓴다. 제품 화면에서는 삽입 위치가 "선택 가능한 Div 후보 순회" 로 정해지는데, 그 위치가 모듈이 주입한
|
||||
잠금 서브트리 안이면 선택이 조상 노드로 escalate 되어 추가한 노드를 지목할 수 없다(실측: 관리자
|
||||
대시보드에 BarChart 추가 시 오버레이 타입 라벨이 `↑Div` + ⓘ 미표시, 같은 절차를 시드 컨테이너에서
|
||||
하면 `↑BarChart` + ⓘ 표시). 시드 화면의 컨테이너는 고정 id 라 삽입 위치가 결정적이다.
|
||||
|
||||
### 4.2 편집기 spec 작성 시 자주 틀리는 측정 기준
|
||||
|
||||
전수 실행에서 드러난 실패의 상당수가 제품 결함이 아니라 **측정 방법**의 문제였다. 아래는 실측으로
|
||||
확인된 것들이다.
|
||||
|
||||
| 하지 말 것 | 이유 (실측) | 대신 |
|
||||
|---|---|---|
|
||||
| 전환 오버레이 가림을 `toBeVisible()` 로 판정 | Playwright 의 가시성 판정은 **가림(occlusion)을 보지 않는다**. 오버레이는 타겟 안/head 에 덧붙는 방식이고 콘텐츠는 DOM 에 남으므로, 덮여 있어도 visible 로 판정된다 | 오버레이 엘리먼트의 attach/detach 시각으로 측정 (`#g7-skeleton-overlay` 또는 `style#g7-transition-overlay`) |
|
||||
| "캔버스 텍스트 길이가 늘어난다" 로 본체 렌더 판정 | 탭/상태를 바꾸면 **줄어들 수도** 있다. 실측: my_comments 서브탭이 정상 렌더되는데 881 → 669 로 감소(항목당 길이가 짧아서) | 그 데이터에만 있는 고유 문구 존재로 판정 |
|
||||
| 상태/옵션의 **총 개수**를 단언 | 상태 그룹은 편집기 스펙에서 계속 늘어난다. 체크아웃 상태가 3 → 4 로 늘면서 `toHaveCount(3)` 이 깨졌다 | 그 테스트가 실제로 쓰는 값의 존재로 판정 |
|
||||
| `waitForLoadState('networkidle')` | 관리자 SPA 는 실시간 연결·주기 폴링이 붙어 500ms 무통신 구간이 오지 않을 수 있다. 실측 30초 타임아웃 | 다음 단계에 필요한 구체 신호(클릭할 버튼의 가시성 등) |
|
||||
| `/admin/layout-editor/{id}` 에 모듈/플러그인 식별자 | 그 세그먼트는 **템플릿 식별자** 자리다. 모듈 라우트는 해당 타입 템플릿 트리에 병합되므로 템플릿으로 진입해야 한다 | 템플릿 식별자 (모듈 admin 라우트 → admin 템플릿) |
|
||||
| 테스트 예산과 내부 대기를 같은 값으로 | `test` 기본 예산 30초 안에서 30초 `waitForResponse` 를 걸면 앞 단계가 조금만 늦어도 구조적으로 완주 불가 | 내부 대기를 줄이거나 `test.setTimeout()` 상향 |
|
||||
|
||||
## §5. fixture 패턴
|
||||
|
||||
### 5.1 코어 fixture (`tests/Playwright/fixtures/auth.ts`)
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-07-29
|
||||
|
||||
### Added
|
||||
|
||||
- 본인인증 결과 중계 페이지 제목의 일본어 번역 추가 — 인증 창에서 결과를 전달하는 중계 페이지의 제목이 일본어 로케일로 표시됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 설정 화면의 중복 가입 관련 항목 일본어 표기를 원문 용어 정비에 맞춰 갱신했습니다 (「同一人識別」 → 「重複登録ブロック」 계열).
|
||||
|
||||
## [1.0.1] - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
+2
-1
@@ -10,7 +10,7 @@ return [
|
||||
'test_mode' => 'テストモード',
|
||||
'live_mid' => 'ライブMID(SRBプレフィックス)',
|
||||
'live_api_key' => 'ライブAPIキー',
|
||||
'duplicate_field' => '同一人識別基準',
|
||||
'duplicate_field' => '重複チェック基準',
|
||||
'duplicate_block_enabled' => [
|
||||
'label' => '重複登録ブロック',
|
||||
'description' => '有効化時に本人認証を通過した人が以前に別のメールアドレスで登録した場合、登録を拒否します。家族携帯電話共有またはB2Bシナリオなど、1人が複数のアカウントを登録する必要がある場合は無効化してください。この設定とは関係なく、同一メールアドレスの再登録は常にブロックされます(コア基本動作)。',
|
||||
@@ -40,4 +40,5 @@ return [
|
||||
'description' => '必ずKGイニシスプロバイダーのみにマッピングしてください。メール/SMSプロバイダーは生年月日を返さないため、成人かどうかの判定ができません。誤りマッピング時に未成年ユーザーに成人向けコンテンツが露出する可能性があります。',
|
||||
],
|
||||
],
|
||||
'bridge_page_title' => '本人認証結果',
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "KGイニシス統合認証の本人確認サービスをG7コア本人認証インフラに接続するプラグインです。",
|
||||
"settings": {
|
||||
"title": "KGイニシス本人確認設定",
|
||||
"description": "運用モードとライブ資格情報、同一人識別基準を設定します。",
|
||||
"description": "運用モードとライブ資格情報、重複登録ブロック基準を設定します。",
|
||||
"info": {
|
||||
"activation": "本人認証の有効化は [環境設定 > 本人認証] から設定します。",
|
||||
"logs": "イニシス認証履歴は [管理者 > 本人認証履歴] から「KGイニシス本人確認」でフィルターして閲覧できます。"
|
||||
@@ -17,13 +17,13 @@
|
||||
"live_mid_hint": "イニシスライブ加盟店MIDはSRBで始まります (例:SRB1234567)。運用モード使用前に必須入力です。",
|
||||
"live_api_key": "ライブAPIキー",
|
||||
"section": {
|
||||
"duplicate_field": "同一人識別"
|
||||
"duplicate_field": "重複登録ブロック"
|
||||
},
|
||||
"fields": {
|
||||
"duplicate_field": {
|
||||
"label": "重複チェック基準",
|
||||
"hint": "DIは本加盟店でのみ同一人識別、CIはすべての機関で共通識別が可能です。(デフォルト:DI)",
|
||||
"warning": "運用中に変更した場合、既存登録者の同一人チェックが一時的に不完全になる可能性があります。"
|
||||
"hint": "DIは本加盟店でのみ重複登録を確認し、CIはすべての機関で共通して識別できます。(デフォルト:DI)",
|
||||
"warning": "運用中に変更した場合、既存登録者の重複チェックが一時的に不完全になる可能性があります。"
|
||||
},
|
||||
"duplicate_block_enabled": {
|
||||
"label": "重複登録ブロック",
|
||||
@@ -34,10 +34,10 @@
|
||||
}
|
||||
},
|
||||
"duplicate_field": {
|
||||
"di": "DI (加盟店従属同一人識別値)",
|
||||
"ci": "CI (全機関共通同一人識別値)",
|
||||
"hint": "DIは本加盟店でのみ同一人識別、CIはすべての機関で共通識別が可能です。",
|
||||
"warning": "運用中に変更した場合、既存登録者の同一人チェックが一時的に不完全になる可能性があります。"
|
||||
"di": "DI (加盟店従属の固有値)",
|
||||
"ci": "CI (全機関共通の固有値)",
|
||||
"hint": "DIは本加盟店でのみ重複登録を確認し、CIはすべての機関で共通して識別できます。",
|
||||
"warning": "運用中に変更した場合、既存登録者の重複チェックが一時的に不完全になる可能性があります。"
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-verification_kginicis) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-verification_kginicis) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-verification_kginicis",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
이 언어팩의 모든 주요 변경사항을 기록합니다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0] - 2026-07-29
|
||||
|
||||
### Added
|
||||
|
||||
- NHN KCP 휴대폰 본인확인 플러그인(sirsoft-verification_nhnkcp)의 일본어 번들 언어팩 초기 제공
|
||||
- 본인확인 안내 창 문구(시작 안내·진행 중 안내·모바일 페이지 이동 안내·팝업 차단 안내)의 일본어 번역
|
||||
- 실패 안내 문구(취소·성인 인증 필요·중복 가입·인증기관 통신 실패·인증 정보 불완전·인증 세션 만료·저장 실패)의 일본어 번역
|
||||
- 관리자 환경설정 문구(테스트 모드·운영 인증 정보·사이트 코드·암호화 키·웹사이트 ID·중복 가입 차단 기준)의 일본어 번역
|
||||
- 마이페이지 본인확인 카드 문구(이름·생년월일·휴대폰·인증 일시·성인 여부, 생년월일 미제공·성인 여부 확인 불가 안내 포함)의 일본어 번역
|
||||
- 레이아웃 편집기 상태 변종 라벨(본인확인 완료 / 생년월일 미제공 / 내역 없음)의 일본어 번역
|
||||
- 본인확인 결과 중계 페이지 제목의 일본어 번역 — 인증 창에서 결과를 전달하는 중계 페이지의 제목이 일본어 로케일로 표시됩니다.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'encrypt_failed' => '本人認証リクエストデータを準備できませんでした。しばらく後に再度お試しください。',
|
||||
'decrypt_failed' => '本人認証情報の復号化に失敗しました。再度お試しください。',
|
||||
'remote_call_failed' => 'NHN KCP認証サーバーとの通信に失敗しました。しばらく後に再度お試しください。',
|
||||
'not_found' => '本人認証情報が見つかりません。認証を再度実行してください。',
|
||||
'already_consumed' => '既に処理済みの本人認証です。',
|
||||
'duplicate_register' => '本人認証で登録済みのアカウントが既に存在します。ログインまたはパスワード再設定をご利用ください。',
|
||||
'binding_mismatch' => '本人認証情報が対象アカウントと一致していません。',
|
||||
'not_adult' => '成人認証が必要なサービスです。満19歳以上のみご利用いただけます。',
|
||||
'incomplete_identity' => '本人認証情報が完全ではありません。別の認証方法で再度お試しください。',
|
||||
'storage_failed' => '本人認証情報の保存に失敗しました。しばらく後に再度お試しください。',
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'NHN KCP 携帯電話本人確認',
|
||||
'description' => 'NHN KCP の携帯電話本人確認サービスを G7 コア本人認証インフラストラクチャに接続するプラグインです。',
|
||||
'channels' => [
|
||||
'phone' => '携帯電話',
|
||||
],
|
||||
'settings' => [
|
||||
'test_mode' => 'テストモード',
|
||||
'test_site_cd' => 'テスト サイトコード',
|
||||
'test_enc_key' => 'テスト 暗号化キー',
|
||||
'live_site_cd' => '運営 サイトコード (SM プレフィックス)',
|
||||
'live_enc_key' => '運営 暗号化キー',
|
||||
'web_siteid' => 'ウェブサイト ID',
|
||||
'live_site_cd_attribute' => '運営 サイトコード',
|
||||
'live_enc_key_attribute' => '運営 暗号化キー',
|
||||
'duplicate_field' => '重複チェック基準',
|
||||
'duplicate_block_enabled' => [
|
||||
'label' => '重複登録ブロック',
|
||||
'description' => '有効化した場合、本人確認を完了したユーザーが以前に異なるメールアドレスで登録した履歴がある場合は登録を拒否します。ファミリー携帯電話の共有または B2B シナリオなど、1 人が複数のアカウントを登録する必要がある場合は無効化してください。この設定に関わらず、同一メールアドレスの再登録は常にブロックされます (コア デフォルト動作)。',
|
||||
],
|
||||
],
|
||||
'card' => [
|
||||
'title' => '本人認証情報',
|
||||
'method' => '認証方式',
|
||||
'method_value' => 'NHN KCP 携帯電話本人確認',
|
||||
'verified_at' => '認証日時',
|
||||
'name' => '実名',
|
||||
'birthday' => '生年月日',
|
||||
'phone' => '携帯電話',
|
||||
'is_adult' => [
|
||||
'label' => '成人否定状況',
|
||||
'true' => '成人',
|
||||
'false' => '未成年者',
|
||||
],
|
||||
],
|
||||
'purposes' => [
|
||||
'adult_verification' => [
|
||||
'label' => '成人認証 (NHN KCP 本人確認専用)',
|
||||
'description' => '必ず NHN KCP プロバイダーのみにマッピングしてください。メール/SMS プロバイダーは生年月日を返さないため成人判定ができません。誤ったマッピングの場合、成人向けコンテンツが未成年ユーザーに公開される可能性があります。',
|
||||
],
|
||||
],
|
||||
'bridge_page_title' => '本人確認結果',
|
||||
];
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"editor": {
|
||||
"data_source": {
|
||||
"nhnkcpRecord": "本人認証記録",
|
||||
"settings": "本人認証設定"
|
||||
},
|
||||
"state": {
|
||||
"test_mode_on": "テストモード オン",
|
||||
"test_mode_off": "ライブモード",
|
||||
"identity_challenge_nhnkcp": "本人認証ガイダンス画面 (NHN KCP)",
|
||||
"record_verified": "本人認証完了 (成人)",
|
||||
"record_birthday_missing": "本人認証完了 (生年月日未提供)",
|
||||
"record_none": "本人認証履歴なし"
|
||||
}
|
||||
},
|
||||
"title": "NHN KCP 携帯電話本人認証",
|
||||
"description": "NHN KCP の携帯電話本人認証サービスを G7 コア本人認証インフラストラクチャに接続するプラグインです。",
|
||||
"settings": {
|
||||
"title": "NHN KCP 携帯電話本人認証設定",
|
||||
"description": "ライブモードと運用認証情報、重複登録ブロック基準を設定します。",
|
||||
"info": {
|
||||
"activation": "本人認証の有効化は [環境設定 > 本人認証] で設定します。",
|
||||
"logs": "認証履歴は [管理者 > 本人認証履歴] で「NHN KCP 携帯電話本人認証」でフィルタリングして閲覧できます。"
|
||||
},
|
||||
"test_mode": "テストモード",
|
||||
"test_mode_hint": "テストモードでは KCP テスト環境に接続され、実際の認証手数料は請求されません。",
|
||||
"live_mode_warning_title": "ライブモード有効化のお知らせ",
|
||||
"live_mode_warning_body": "ライブモードは実際の加盟店サイトコードで本人認証を実行します。運用サイトコードと暗号化キーを入力してから運用してください。",
|
||||
"section_live_keys": "運用認証情報 (テストモード OFF 時は必須)",
|
||||
"live_site_cd": "サイトコード",
|
||||
"live_site_cd_hint": "KCP が発行したサイトコードの前の SM を除いた値のみ入力してください。SM は自動的に付加されます。",
|
||||
"live_enc_key": "暗号化キー",
|
||||
"live_enc_key_hint": "保存時に暗号化して保管されます。",
|
||||
"web_siteid": "ウェブサイト ID",
|
||||
"web_siteid_hint": "KCP 加盟店管理画面で発行されたサイト識別コードを入力してください。重複登録確認情報 (DI) の受信に使用されます。",
|
||||
"section": {
|
||||
"duplicate_field": "重複登録ブロック"
|
||||
},
|
||||
"fields": {
|
||||
"duplicate_field": {
|
||||
"label": "重複チェック基準",
|
||||
"hint": "DI はこのサイトでのみ重複登録を確認し、CI はすべての機関で共通に識別できます。(デフォルト: DI)"
|
||||
},
|
||||
"duplicate_block_enabled": {
|
||||
"label": "重複登録ブロック",
|
||||
"hint": "有効化時、本人認証を通過した人が以前に別のメールアドレスで登録したことがあれば登録を拒否します。家族の携帯電話共有または B2B シナリオで 1 人が複数のアカウント登録が必要な場合は無効化してください。(デフォルト: 有効化)",
|
||||
"note": "この設定に関わらず、同一メールアドレスの再登録は常にブロックされます (コア デフォルト動作)。"
|
||||
}
|
||||
},
|
||||
"duplicate_field": {
|
||||
"di": "DI — サイト別固有値 (推奨)",
|
||||
"ci": "CI — サービス共通固有値",
|
||||
"warning": "運用中に変更すると、既存登録者の重複チェックが一時的に不完全になる可能性があります。"
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"title": "携帯電話本人認証",
|
||||
"description": "KCP 認証画面で通信事業者認証を進めてください。",
|
||||
"mobile_description": "認証画面に移動します。認証完了後、自動的にこの画面に戻ります。",
|
||||
"adult_title": "成人認証",
|
||||
"adult_description": "満19歳以上の成人否か確認するため、携帯電話本人認証を進めます。認証後、自動的に前の画面に戻ります。",
|
||||
"start_button": "本人認証開始",
|
||||
"test_mode_notice": "テストモードです。実際の認証は発生しません。",
|
||||
"in_progress": {
|
||||
"title": "認証画面で本人認証を進めてください",
|
||||
"hint": "認証画面が表示されない場合は、ブラウザのポップアップブロックを解除してください。ウィンドウを閉じると再試行できます。"
|
||||
},
|
||||
"popup_blocked": "ポップアップがブロックされました。ブラウザのポップアップ許可設定後、再試行してください。",
|
||||
"payload_not_ready": "本人認証の準備が完了していません。しばらく経ってから再試行してください。",
|
||||
"mobile_return_success": "本人認証が完了しました。続けて進めてください。"
|
||||
},
|
||||
"card": {
|
||||
"title": "本人認証情報",
|
||||
"method": "認証方式",
|
||||
"verified_at": "認証日時",
|
||||
"name": "名前",
|
||||
"birthday": "生年月日",
|
||||
"phone": "携帯電話",
|
||||
"not_provided": "提供されていません",
|
||||
"is_adult": {
|
||||
"label": "成人否か",
|
||||
"true": "確認済み",
|
||||
"false": "未確認 (満19歳未満)",
|
||||
"unknown": "確認不可 (生年月日未提供)"
|
||||
},
|
||||
"pending": {
|
||||
"title": "まだ本人認証履歴がありません。",
|
||||
"hint": "本人認証が必要な作業 (パスワード再設定、情報変更など) を進める際に認証できます。"
|
||||
},
|
||||
"provider_badge": "NHN KCP"
|
||||
},
|
||||
"errors": {
|
||||
"cancelled": "本人認証をキャンセルしました。",
|
||||
"not_adult": "成人認証が必要なサービスです。満19歳以上のみご利用できます。",
|
||||
"duplicate": "すでに同一の名義で登録されたアカウントがあります。ログインまたはパスワード検索を使用してください。",
|
||||
"remote_call_failed": "認証機関との通信に失敗しました。しばらく経ってから再試行してください。",
|
||||
"decrypt_failed": "正常な認証ではありません。再試行してください。",
|
||||
"incomplete_identity": "正常な認証ではありません。再試行してください。",
|
||||
"not_found": "認証セッションが期限切れになりました。最初から再試行してください。",
|
||||
"already_consumed": "認証セッションが期限切れになりました。最初から再試行してください。",
|
||||
"binding_mismatch": "本人認証情報が対象アカウントと一致しません。",
|
||||
"storage_failed": "本人認証情報の保存に失敗しました。しばらく経ってから再試行してください。",
|
||||
"verify_failed": "本人認証に失敗しました。再試行してください。"
|
||||
},
|
||||
"purposes": {
|
||||
"adult_verification": {
|
||||
"label": "成人認証 (NHN KCP 本人認証専用)",
|
||||
"description": "必ず NHN KCP プロバイダーにのみマッピングしてください。メール/SMS プロバイダーは生年月日を返さないため、成人否か判定ができません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"identifier": "g7-plugin-sirsoft-verification_nhnkcp-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-verification_nhnkcp) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-verification_nhnkcp) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-verification_nhnkcp) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-verification_nhnkcp) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-verification_nhnkcp) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-verification_nhnkcp) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-verification_nhnkcp",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "NHN KCP 携帯電話本人確認",
|
||||
"description": "NHN KCP 携帯電話本人確認(V2 REST)をG7コアIDVインフラにProviderとして登録するプラグイン"
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 글을 수정하면서 미리 올려 둔 파일을 첨부하면 저장은 되는데 첨부만 연결되지 않던 문제를 수정했습니다. 관리자 화면에서는 정상 연결되어 같은 요청이 화면에 따라 다르게 동작했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 개별 게시판을 수정할 때 목록 표시 개수·제목/내용 길이·댓글 깊이·첨부 개수 등 숫자 항목의 허용 범위가 화면에 표시되지 않던 문제를 수정했습니다. 게시판 환경설정(기본값) 화면에는 범위가 표시되는데 개별 게시판 화면에는 없어, 범위를 벗어난 값을 넣어도 저장을 눌러 봐야 알 수 있었습니다. 이제 두 화면이 같은 범위를 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
|
||||
- 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
@@ -49,6 +50,9 @@
|
||||
- 대댓글 깊이가 게시판 설정값을 넘어 계속 달리던 문제를 수정했습니다. 예전에는 6단계부터 들여쓰기가 더 들어가지 않고 설정한 최대 깊이도 지켜지지 않았습니다. 이제 설정한 깊이까지만 답글을 달 수 있고 화면의 단계 표시도 정확해집니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 기존에 쌓인 댓글 중 깊이가 어긋났거나 다른 게시글의 댓글을 부모로 가진 것을 업데이트 시 자동으로 정리합니다. 잘못 연결된 댓글은 삭제하지 않고 최상위 댓글로 옮기며, 내용은 그대로 보존됩니다.
|
||||
- 연동 기능이 블라인드·삭제된 글에 댓글을 등록하려 할 때 "댓글 등록에 실패했습니다" 라는 서버 오류만 표시되던 문제를 수정했습니다. 이제 글쓰기 화면에서와 똑같이 왜 등록할 수 없는지 사유가 표시됩니다.
|
||||
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
|
||||
- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
|
||||
- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다.
|
||||
|
||||
## [1.0.2] - 2026-07-14
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ return new class extends Migration
|
||||
// 신고 로그
|
||||
Schema::create('boards_report_logs', function (Blueprint $table) {
|
||||
$table->id()->comment('신고 로그 ID');
|
||||
$table->foreignId('report_id')->constrained('boards_reports')->cascadeOnDelete()->comment('케이스 ID (boards_reports.id)');
|
||||
$table->foreignId('report_id')->comment('케이스 ID (boards_reports.id)')->constrained('boards_reports')->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('reporter_id')->nullable()->comment('신고자 ID (탈퇴 시 NULL)');
|
||||
$table->mediumText('snapshot')->nullable()->comment('신고 당시 게시물 스냅샷 (JSON: board_name, title, content, content_mode, author_name)');
|
||||
$table->string('reason_type', 50)->nullable()->comment('신고 사유 유형 (abuse, hate_speech, spam, copyright, privacy, misinformation, sexual, violence, other)');
|
||||
|
||||
+4
-2
@@ -21,7 +21,9 @@ return new class extends Migration
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('notification_templates')) {
|
||||
throw new \RuntimeException(
|
||||
// audit:allow i18n-throw-hardcoded-korean reason: 마이그레이션 선행조건 가드 —
|
||||
// 콘솔에서 개발자·운영자가 보는 진단 메시지이며 사용자 화면에 닿지 않는다.
|
||||
throw new RuntimeException(
|
||||
'board_mail_templates 제거 전 notification_templates 가 존재해야 합니다. '
|
||||
.'코어 Upgrade_7_0_0_beta_2 + 게시판 Upgrade_1_0_0_beta_2 업그레이드 스텝을 먼저 실행하세요.'
|
||||
);
|
||||
@@ -52,7 +54,7 @@ return new class extends Migration
|
||||
$table->boolean('is_active')->default(true)->comment('활성 여부');
|
||||
$table->boolean('is_default')->default(true)->comment('시더 생성 여부');
|
||||
$table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록');
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
|
||||
$table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
+12
-8
@@ -164,7 +164,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "refresh",
|
||||
"className": "w-5 h-5"
|
||||
"className": "text-xl"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -194,7 +194,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "plus",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -320,7 +320,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "{{_global.showAdvancedSearch ? 'chevron-up' : 'chevron-down'}}",
|
||||
"className": "w-4 h-4 text-white dark:text-gray-300"
|
||||
"className": "text-base text-white dark:text-gray-300"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -373,6 +373,7 @@
|
||||
"category": "{{$event.target.value === 'all' ? '' : $event.target.value}}",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "posts_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -672,6 +673,7 @@
|
||||
"sort_order": "{{$event.target.value.split('_').pop()}}",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "posts_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -714,6 +716,7 @@
|
||||
"per_page": "{{$event.target.value}}",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "posts_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -785,7 +788,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "level-up-alt",
|
||||
"className": "w-3.5 h-3.5 fa-rotate-90 mt-1 text-gray-400 dark:text-gray-500"
|
||||
"className": "text-sm fa-rotate-90 mt-1 text-gray-400 dark:text-gray-500"
|
||||
},
|
||||
"if": "{{row.is_reply}}"
|
||||
},
|
||||
@@ -819,7 +822,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "lock",
|
||||
"className": "w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
|
||||
"className": "text-sm text-gray-500 dark:text-gray-400"
|
||||
},
|
||||
"if": "{{row.is_secret}}"
|
||||
},
|
||||
@@ -859,7 +862,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "paperclip",
|
||||
"className": "w-3.5 h-3.5 text-gray-400 dark:text-gray-500"
|
||||
"className": "text-sm text-gray-400 dark:text-gray-500"
|
||||
},
|
||||
"if": "{{row.has_attachment}}"
|
||||
},
|
||||
@@ -879,7 +882,7 @@
|
||||
"name": "Span",
|
||||
"if": "{{row?.is_new}}",
|
||||
"props": {
|
||||
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-500 text-white"
|
||||
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-500 dark:bg-red-600 text-white"
|
||||
},
|
||||
"text": "N"
|
||||
},
|
||||
@@ -1019,6 +1022,7 @@
|
||||
"query": {
|
||||
"page": "{{$args[0]}}"
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "posts_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -1101,7 +1105,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "px-4 py-2 bg-red-600 dark:bg-red-700 text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-800 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_global.deleteModalLoading}}"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
+8
-7
@@ -136,7 +136,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "arrows-rotate",
|
||||
"className": "w-5 h-5"
|
||||
"className": "text-xl"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -324,7 +324,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "{{_global.showAdvancedSearch ? 'chevron-up' : 'chevron-down'}}",
|
||||
"className": "w-4 h-4 text-white dark:text-gray-300"
|
||||
"className": "text-base text-white dark:text-gray-300"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -520,7 +520,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "{{_global.showAdvancedSearch ? 'chevron-up' : 'chevron-down'}}",
|
||||
"className": "w-4 h-4 text-white dark:text-gray-300"
|
||||
"className": "text-base text-white dark:text-gray-300"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -2192,6 +2192,7 @@
|
||||
"query": {
|
||||
"page": "{{$args[0]}}"
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "reports_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -2302,7 +2303,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "exclamation-triangle",
|
||||
"className": "w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0"
|
||||
"className": "text-base text-red-600 dark:text-red-400 flex-shrink-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2328,7 +2329,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "exclamation-triangle",
|
||||
"className": "w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0"
|
||||
"className": "text-base text-yellow-600 dark:text-yellow-400 flex-shrink-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2354,7 +2355,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "eye",
|
||||
"className": "w-4 h-4 text-orange-600 dark:text-orange-400 flex-shrink-0"
|
||||
"className": "text-base text-orange-600 dark:text-orange-400 flex-shrink-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2380,7 +2381,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "spinner",
|
||||
"className": "w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin mr-2"
|
||||
"className": "text-xl text-blue-600 dark:text-blue-400 animate-spin mr-2"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@ use Modules\Sirsoft\Board\Traits\FormatsBoardDate;
|
||||
*
|
||||
* core.search.results Filter Hook을 구독하여 검색 결과에 게시글을 추가합니다.
|
||||
* core.search.build_response Filter Hook을 구독하여 응답 구조를 생성합니다.
|
||||
* core.search.validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
* core.search.index_validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
*/
|
||||
class SearchPostsListener implements HookListenerInterface
|
||||
{
|
||||
@@ -43,7 +43,7 @@ class SearchPostsListener implements HookListenerInterface
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
],
|
||||
'core.search.validation_rules' => [
|
||||
'core.search.index_validation_rules' => [
|
||||
'method' => 'addValidationRules',
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
|
||||
@@ -65,6 +65,12 @@ export default defineConfig({
|
||||
],
|
||||
use: {
|
||||
baseURL: resolveBaseUrl(),
|
||||
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
|
||||
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
|
||||
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
|
||||
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
|
||||
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
|
||||
locale: 'ko-KR',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
|
||||
@@ -38,7 +38,7 @@ class SearchPostsListenerTest extends TestCase
|
||||
|
||||
$this->assertArrayHasKey('core.search.results', $hooks);
|
||||
$this->assertArrayHasKey('core.search.build_response', $hooks);
|
||||
$this->assertArrayHasKey('core.search.validation_rules', $hooks);
|
||||
$this->assertArrayHasKey('core.search.index_validation_rules', $hooks);
|
||||
|
||||
foreach ($hooks as $hook) {
|
||||
$this->assertEquals('filter', $hook['type']);
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_0_3\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 외래키 컬럼의 누락된 한국어 comment 를 채웁니다.
|
||||
*
|
||||
* 배경:
|
||||
* `$table->foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가
|
||||
* 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가
|
||||
* 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친
|
||||
* 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다.
|
||||
*
|
||||
* 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다.
|
||||
*
|
||||
* 안전:
|
||||
* - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다.
|
||||
* - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는
|
||||
* 아무 것도 바뀌지 않습니다.
|
||||
* - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다).
|
||||
*
|
||||
* 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다.
|
||||
*
|
||||
* V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을
|
||||
* 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조).
|
||||
*/
|
||||
class BackfillForeignKeyColumnComments implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 보정 대상 (1.0.3 시점 동결) — 테이블 => [컬럼 => 설명].
|
||||
*/
|
||||
private const TARGETS = [
|
||||
'boards_report_logs' => [
|
||||
'report_id' => '케이스 ID (boards_reports.id)',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillForeignKeyColumnComments';
|
||||
}
|
||||
|
||||
/**
|
||||
* 비어 있는 외래키 컬럼 설명을 채웁니다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
|
||||
$context->logger->info('[1.0.3] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$filled = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach (self::TARGETS as $table => $columns) {
|
||||
$prefixed = $context->table($table);
|
||||
|
||||
foreach ($columns as $column => $comment) {
|
||||
try {
|
||||
$meta = $this->columnMeta($prefixed, $column);
|
||||
|
||||
if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment));
|
||||
$filled++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$context->logger->warning(sprintf(
|
||||
'[1.0.3] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s',
|
||||
$prefixed,
|
||||
$column,
|
||||
$e->getMessage(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$context->logger->info(sprintf(
|
||||
'[1.0.3] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건',
|
||||
$filled,
|
||||
$skipped,
|
||||
$failed,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼의 현재 스키마 메타데이터를 조회합니다.
|
||||
*
|
||||
* @param string $table 프리픽스가 적용된 테이블명
|
||||
* @param string $column 컬럼명
|
||||
* @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null)
|
||||
*/
|
||||
private function columnMeta(string $table, string $column): ?object
|
||||
{
|
||||
$rows = DB::select(
|
||||
'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
|
||||
[$table, $column],
|
||||
);
|
||||
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다.
|
||||
*
|
||||
* @param string $table 프리픽스가 적용된 테이블명
|
||||
* @param string $column 컬럼명
|
||||
* @param object $meta columnMeta() 결과
|
||||
* @param string $comment 넣을 설명
|
||||
* @return string 실행할 SQL
|
||||
*/
|
||||
private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string
|
||||
{
|
||||
$sql = sprintf(
|
||||
'ALTER TABLE %s MODIFY COLUMN %s %s',
|
||||
$this->quoteIdentifier($table),
|
||||
$this->quoteIdentifier($column),
|
||||
$meta->COLUMN_TYPE,
|
||||
);
|
||||
|
||||
$sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL';
|
||||
|
||||
if ($meta->COLUMN_DEFAULT !== null) {
|
||||
$sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT);
|
||||
}
|
||||
|
||||
if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) {
|
||||
$sql .= ' AUTO_INCREMENT';
|
||||
}
|
||||
|
||||
return $sql.' COMMENT '.$this->quoteValue($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 식별자를 백틱으로 감쌉니다.
|
||||
*
|
||||
* @param string $identifier 테이블/컬럼명
|
||||
* @return string 이스케이프된 식별자
|
||||
*/
|
||||
private function quoteIdentifier(string $identifier): string
|
||||
{
|
||||
return '`'.str_replace('`', '``', $identifier).'`';
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 값을 SQL 리터럴로 인용합니다.
|
||||
*
|
||||
* @param string $value 값
|
||||
* @return string 인용된 리터럴
|
||||
*/
|
||||
private function quoteValue(string $value): string
|
||||
{
|
||||
return DB::connection()->getPdo()->quote($value);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
- 부분취소 후에도 주문의 부가세가 취소 전 금액으로 남아 과세표준과 어긋나던 문제를 수정했습니다. 취소로 과세표준이 줄면 부가세도 함께 다시 계산되며, 계산에는 주문 당시의 세율이 사용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 주문의 부가세가 항상 0으로 표시되던 문제를 수정했습니다. 부가세는 부분취소를 거친 주문에서만 채워져 같은 목록에서도 주문마다 세금 표기가 달랐습니다. 이제 주문을 만들 때 계산해 기록하며, 상품별 세율이 다르면 상품별로 계산해 합산합니다. 기존 주문의 부가세는 업데이트 시 자동으로 채워지며, 채운 주문 건수가 업그레이드 로그에 남습니다(이미 값이 있는 주문은 건드리지 않습니다). (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 주문서의 마일리지 '사용 가능 최대' 안내가 실제 결제 기준 금액과 최소 사용액을 반영하도록 수정했습니다. 이전에는 할인·배송비 반영 전 상품합계를 기준으로 계산해 실제로 쓸 수 있는 금액보다 크게 안내되었고, 최소 사용액에 못 미치는 경우에도 사용 가능한 것처럼 보였습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
|
||||
- 휴대폰으로 관리자 주문 목록을 볼 때 왼쪽 위 '뒤로' 버튼을 누르면 이전 화면이 아니라 빈 페이지로 넘어가던 문제를 수정했습니다.
|
||||
- 관리자 주문·상품·쿠폰·배송정책·리뷰·마일리지 목록에서 상세나 등록/수정 화면에 다녀오면 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. 필터를 여러 개 건 상태에서 주문 하나를 확인하고 돌아와도 조건이 그대로 유지됩니다. 정렬·표시 개수를 바꾸거나 새로고침해도 걸어 둔 조건은 그대로입니다. 반대로 초기화 버튼과 프리셋 적용은 종전처럼 조건을 새로 세팅합니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 관리자 브랜드·상품 공통정보·상품 고시정보 목록에서 검색어(브랜드는 정렬 포함)를 걸어 둔 채 새로고침 버튼을 누르면 걸어 둔 검색이 풀려 전체 목록으로 돌아가던 문제를 수정했습니다. 이제 새로고침은 보던 조건 그대로 다시 불러옵니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
@@ -63,6 +64,9 @@
|
||||
- 카테고리를 자기 자신이나 자기 하위 카테고리 아래로 옮길 때 화면이 응답하지 않고 해당 카테고리가 목록에서 사라지던 문제를 수정했습니다. 카테고리 수정과 순서 변경 양쪽 모두에 적용됩니다.
|
||||
- 카테고리를 최상위로 옮길 때 상위 연결은 풀렸지만 계층 정보(경로·단계)가 이전 값 그대로 남아, 하위 카테고리의 위치가 어긋나 보이던 문제를 수정했습니다.
|
||||
- 기존에 잘못 연결된 카테고리가 있으면 업데이트 시 자동으로 정리하고 계층 정보를 다시 계산합니다. 카테고리는 삭제하지 않고 최상위로 옮깁니다.
|
||||
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
|
||||
- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
|
||||
- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다.
|
||||
|
||||
## [1.0.4] - 2026-07-16
|
||||
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ return new class extends Migration
|
||||
$table->text('reply_content')->nullable()->comment('판매자 답변 내용');
|
||||
$table->string('reply_content_mode', 10)->default('text')->comment('답변 콘텐츠 모드: text / html');
|
||||
$table->foreignId('reply_admin_id')->nullable()
|
||||
->constrained('users')->nullOnDelete()
|
||||
->comment('답변 등록 관리자 ID');
|
||||
->comment('답변 등록 관리자 ID')
|
||||
->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('replied_at')->nullable()->comment('답변 등록일시');
|
||||
$table->timestamp('reply_updated_at')->nullable()->comment('답변 수정일시');
|
||||
|
||||
|
||||
+2
-2
@@ -43,8 +43,8 @@ return new class extends Migration
|
||||
|
||||
// 감사 필드
|
||||
$table->foreignId('created_by')->nullable()
|
||||
->constrained('users')->nullOnDelete()
|
||||
->comment('업로더 ID');
|
||||
->comment('업로더 ID')
|
||||
->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
$table->softDeletes()->comment('소프트 삭제 일시');
|
||||
|
||||
|
||||
+4
-2
@@ -21,7 +21,9 @@ return new class extends Migration
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('notification_templates')) {
|
||||
throw new \RuntimeException(
|
||||
// audit:allow i18n-throw-hardcoded-korean reason: 마이그레이션 선행조건 가드 —
|
||||
// 콘솔에서 개발자·운영자가 보는 진단 메시지이며 사용자 화면에 닿지 않는다.
|
||||
throw new RuntimeException(
|
||||
'ecommerce_mail_templates 제거 전 notification_templates 가 존재해야 합니다. '
|
||||
.'코어 Upgrade_7_0_0_beta_2 + 이커머스 Upgrade_1_0_0_beta_2 업그레이드 스텝을 먼저 실행하세요.'
|
||||
);
|
||||
@@ -52,7 +54,7 @@ return new class extends Migration
|
||||
$table->boolean('is_active')->default(true)->comment('활성 여부');
|
||||
$table->boolean('is_default')->default(true)->comment('시더 생성 여부');
|
||||
$table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록');
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
|
||||
$table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
+2
-2
@@ -13,13 +13,13 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('ecommerce_mileage_transactions', function (Blueprint $table) {
|
||||
$table->id()->comment('거래 ID');
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('회원 ID');
|
||||
$table->foreignId('user_id')->comment('회원 ID')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('currency', 10)->default('KRW')->comment('통화 코드 (주문 기준통화 스냅샷)');
|
||||
$table->string('type', 30)->comment('거래 유형 (MileageTransactionTypeEnum)');
|
||||
$table->decimal('amount', 12, 2)->comment('거래 금액 (양수=적립, 음수=차감)');
|
||||
$table->decimal('remaining_amount', 12, 2)->default(0)->comment('잔여 금액 (적립건만 양수, FIFO 차감용)');
|
||||
$table->decimal('balance_after', 12, 2)->comment('거래 후 잔액 (감사 스냅샷, 베스트에포트)');
|
||||
$table->foreignId('order_id')->nullable()->constrained('ecommerce_orders')->nullOnDelete()->comment('관련 주문 ID');
|
||||
$table->foreignId('order_id')->nullable()->comment('관련 주문 ID')->constrained('ecommerce_orders')->nullOnDelete();
|
||||
// 옵션 병합 시 하드 삭제(SoftDeletes 미사용)되므로 FK 미설정 — unsignedBigInteger + 인덱스만 유지
|
||||
$table->unsignedBigInteger('order_option_id')->nullable()->comment('관련 주문옵션 ID (병합 하드삭제 대응 — FK 미설정)');
|
||||
$table->unsignedBigInteger('order_cancel_id')->nullable()->comment('관련 주문취소 ID (복원 멱등 기준)');
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ return new class extends Migration
|
||||
// 진실의 원천(SSoT)은 ecommerce_mileage_transactions(원장)이며, 본 테이블은 표시 전용 캐시.
|
||||
Schema::create('ecommerce_mileage_balances', function (Blueprint $table) {
|
||||
$table->id()->comment('잔액 캐시 ID');
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('회원 ID');
|
||||
$table->foreignId('user_id')->comment('회원 ID')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('currency', 10)->default('KRW')->comment('통화 코드 (통화별 행)');
|
||||
$table->decimal('available', 12, 2)->default(0)->comment('사용 가능 잔액 (활성 lot SUM 스냅샷)');
|
||||
$table->decimal('pending', 12, 2)->default(0)->comment('적립 예정 (미취소·earn ledger 부재 옵션 적립액 합)');
|
||||
|
||||
+2
-1
@@ -243,7 +243,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{!_local.selectedProducts || _local.selectedProducts.length === 0 || order?.data?.abilities?.can_update !== true}}"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.order_info.batch_change",
|
||||
@@ -1307,6 +1307,7 @@
|
||||
"orderer_uuid": "",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "order_product_datagrid__body"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -184,7 +184,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "arrow-down-tray",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -610,6 +610,7 @@
|
||||
"search_keyword": "",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "order_datagrid__body"
|
||||
}
|
||||
}
|
||||
@@ -639,6 +640,7 @@
|
||||
"orderer_uuid": "",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "order_datagrid__body"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
*
|
||||
* core.search.results Filter Hook을 구독하여 검색 결과에 상품을 추가합니다.
|
||||
* core.search.build_response Filter Hook을 구독하여 응답 구조를 생성합니다.
|
||||
* core.search.validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
* core.search.index_validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
*/
|
||||
class SearchProductsListener implements HookListenerInterface
|
||||
{
|
||||
@@ -41,7 +41,7 @@ class SearchProductsListener implements HookListenerInterface
|
||||
'priority' => 20,
|
||||
'type' => 'filter',
|
||||
],
|
||||
'core.search.validation_rules' => [
|
||||
'core.search.index_validation_rules' => [
|
||||
'method' => 'addValidationRules',
|
||||
'priority' => 20,
|
||||
'type' => 'filter',
|
||||
|
||||
@@ -76,6 +76,12 @@ export default defineConfig({
|
||||
],
|
||||
use: {
|
||||
baseURL: resolveBaseUrl(),
|
||||
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
|
||||
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
|
||||
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
|
||||
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
|
||||
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
|
||||
locale: 'ko-KR',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
|
||||
+7
-2
@@ -36,7 +36,10 @@ test.describe('관리자 주문 목록 컨텍스트 왕복 (#75)', () => {
|
||||
test('주문 상세에서 목록으로 돌아오면 페이지와 표시 개수가 복원된다', async ({ page, ordersReadToken }) => {
|
||||
await authenticatePage(page, ordersReadToken);
|
||||
await page.goto(`${LIST_URL}?${LIST_STATE}`);
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
// `networkidle` 은 쓰지 않는다 — 관리자 SPA 는 실시간 알림 연결·주기 폴링이 붙어 500ms 무통신
|
||||
// 구간이 오지 않을 수 있고, 실제로 주문 상세 진입에서 30초 타임아웃했다. 다음 단계에 필요한
|
||||
// 구체 신호만 기다린다(여기서는 API 를 직접 부르므로 문서 로드까지).
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// 목록에서 상세로 들어간 직후와 동일한 URL 상태 (진입 leg 가 mergeQuery 로 만들어 주는 형태)
|
||||
const orderNumber = await page.evaluate(async () => {
|
||||
@@ -61,7 +64,9 @@ test.describe('관리자 주문 목록 컨텍스트 왕복 (#75)', () => {
|
||||
expect(orderNumber, '주문 시드가 없어 왕복을 검증할 수 없습니다.').not.toBeNull();
|
||||
|
||||
await page.goto(`${LIST_URL}/${orderNumber}?${LIST_STATE}`);
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
// 다음 단계가 클릭하는 '목록' 버튼이 실제로 조작 가능해질 때까지만 기다린다.
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await expect(page.locator('#list_button')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.locator('#list_button').click();
|
||||
await page.waitForURL(/\/admin\/ecommerce\/orders\?/, { timeout: 30_000 });
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ class SearchProductsListenerTest extends TestCase
|
||||
|
||||
$this->assertArrayHasKey('core.search.results', $hooks);
|
||||
$this->assertArrayHasKey('core.search.build_response', $hooks);
|
||||
$this->assertArrayHasKey('core.search.validation_rules', $hooks);
|
||||
$this->assertArrayHasKey('core.search.index_validation_rules', $hooks);
|
||||
|
||||
// 모두 filter 타입인지 확인
|
||||
foreach ($hooks as $hook) {
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_5\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 외래키 컬럼의 누락된 한국어 comment 를 채웁니다.
|
||||
*
|
||||
* 배경:
|
||||
* `$table->foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가
|
||||
* 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가
|
||||
* 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친
|
||||
* 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다.
|
||||
*
|
||||
* 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다.
|
||||
*
|
||||
* 안전:
|
||||
* - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다.
|
||||
* - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는
|
||||
* 아무 것도 바뀌지 않습니다.
|
||||
* - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다).
|
||||
*
|
||||
* 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다.
|
||||
*
|
||||
* V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을
|
||||
* 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조).
|
||||
*/
|
||||
class BackfillForeignKeyColumnComments implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 보정 대상 (1.0.5 시점 동결) — 테이블 => [컬럼 => 설명].
|
||||
*/
|
||||
private const TARGETS = [
|
||||
'ecommerce_mileage_balances' => [
|
||||
'user_id' => '회원 ID',
|
||||
],
|
||||
'ecommerce_mileage_transactions' => [
|
||||
'order_id' => '관련 주문 ID',
|
||||
'user_id' => '회원 ID',
|
||||
],
|
||||
'ecommerce_order_cancel_options' => [
|
||||
'order_cancel_id' => '취소 FK',
|
||||
'order_id' => '주문 FK',
|
||||
'order_option_id' => '주문옵션 FK',
|
||||
'processed_by' => '처리 관리자',
|
||||
],
|
||||
'ecommerce_order_cancels' => [
|
||||
'cancelled_by' => '취소 요청자',
|
||||
'order_id' => '주문 FK',
|
||||
],
|
||||
'ecommerce_order_refund_options' => [
|
||||
'order_id' => '주문 FK',
|
||||
'order_option_id' => '주문옵션 FK',
|
||||
'order_refund_id' => '환불 FK',
|
||||
'processed_by' => '처리 관리자',
|
||||
],
|
||||
'ecommerce_order_refunds' => [
|
||||
'order_cancel_id' => '취소 FK (추후 polymorphic 전환 가능)',
|
||||
'order_id' => '주문 FK',
|
||||
'processed_by' => '처리 관리자',
|
||||
],
|
||||
'ecommerce_product_review_images' => [
|
||||
'created_by' => '업로더 ID',
|
||||
],
|
||||
'ecommerce_product_reviews' => [
|
||||
'reply_admin_id' => '답변 등록 관리자 ID',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillForeignKeyColumnComments';
|
||||
}
|
||||
|
||||
/**
|
||||
* 비어 있는 외래키 컬럼 설명을 채웁니다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
|
||||
$context->logger->info('[1.0.5] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$filled = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach (self::TARGETS as $table => $columns) {
|
||||
$prefixed = $context->table($table);
|
||||
|
||||
foreach ($columns as $column => $comment) {
|
||||
try {
|
||||
$meta = $this->columnMeta($prefixed, $column);
|
||||
|
||||
if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment));
|
||||
$filled++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$context->logger->warning(sprintf(
|
||||
'[1.0.5] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s',
|
||||
$prefixed,
|
||||
$column,
|
||||
$e->getMessage(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$context->logger->info(sprintf(
|
||||
'[1.0.5] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건',
|
||||
$filled,
|
||||
$skipped,
|
||||
$failed,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼의 현재 스키마 메타데이터를 조회합니다.
|
||||
*
|
||||
* @param string $table 프리픽스가 적용된 테이블명
|
||||
* @param string $column 컬럼명
|
||||
* @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null)
|
||||
*/
|
||||
private function columnMeta(string $table, string $column): ?object
|
||||
{
|
||||
$rows = DB::select(
|
||||
'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
|
||||
[$table, $column],
|
||||
);
|
||||
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다.
|
||||
*
|
||||
* @param string $table 프리픽스가 적용된 테이블명
|
||||
* @param string $column 컬럼명
|
||||
* @param object $meta columnMeta() 결과
|
||||
* @param string $comment 넣을 설명
|
||||
* @return string 실행할 SQL
|
||||
*/
|
||||
private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string
|
||||
{
|
||||
$sql = sprintf(
|
||||
'ALTER TABLE %s MODIFY COLUMN %s %s',
|
||||
$this->quoteIdentifier($table),
|
||||
$this->quoteIdentifier($column),
|
||||
$meta->COLUMN_TYPE,
|
||||
);
|
||||
|
||||
$sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL';
|
||||
|
||||
if ($meta->COLUMN_DEFAULT !== null) {
|
||||
$sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT);
|
||||
}
|
||||
|
||||
if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) {
|
||||
$sql .= ' AUTO_INCREMENT';
|
||||
}
|
||||
|
||||
return $sql.' COMMENT '.$this->quoteValue($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 식별자를 백틱으로 감쌉니다.
|
||||
*
|
||||
* @param string $identifier 테이블/컬럼명
|
||||
* @return string 이스케이프된 식별자
|
||||
*/
|
||||
private function quoteIdentifier(string $identifier): string
|
||||
{
|
||||
return '`'.str_replace('`', '``', $identifier).'`';
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 값을 SQL 리터럴로 인용합니다.
|
||||
*
|
||||
* @param string $value 값
|
||||
* @return string 인용된 리터럴
|
||||
*/
|
||||
private function quoteValue(string $value): string
|
||||
{
|
||||
return DB::connection()->getPdo()->quote($value);
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,12 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
|
||||
- 관리자 페이지 목록에서 상세나 수정 화면에 다녀오면 보고 있던 페이지와 검색 조건이 사라지고 1페이지로 되돌아가던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 페이지 활동 로그에서 "제목"·"내용" 변경 항목의 이름이 비어 보이던 문제를 수정했습니다.
|
||||
- 다른 페이지의 주소로 특정 페이지의 이전 버전 내용을 열람할 수 있던 문제를 수정했습니다.
|
||||
- 다른 페이지에 속한 버전으로 복원을 시도하면 "복원에 실패했습니다" 라는 서버 오류가 나던 문제를 수정했습니다. 이제 열람과 동일하게 "찾을 수 없음" 으로 안내합니다.
|
||||
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
|
||||
|
||||
## [1.0.1] - 2026-07-08
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('page_versions', function (Blueprint $table) {
|
||||
$table->id()->comment('버전 ID');
|
||||
$table->foreignId('page_id')->constrained('pages')->cascadeOnDelete()->comment('페이지 ID');
|
||||
$table->foreignId('page_id')->comment('페이지 ID')->constrained('pages')->cascadeOnDelete();
|
||||
$table->unsignedInteger('version')->comment('버전 번호');
|
||||
$table->text('title')->comment('제목 스냅샷 (다국어 JSON)');
|
||||
$table->mediumText('content')->nullable()->comment('본문 스냅샷 (다국어 JSON)');
|
||||
|
||||
@@ -1656,7 +1656,8 @@
|
||||
"name": "Span",
|
||||
"if": "{{row.version !== 1 && row.changes_summary?.changed_fields?.length > 0}}",
|
||||
"props": {
|
||||
"className": "text-sm text-gray-600 dark:text-gray-400"
|
||||
"className": "text-sm text-gray-600 dark:text-gray-400",
|
||||
"data-testid": "page-version-changed-fields"
|
||||
},
|
||||
"text": "{{row.changes_summary?.changed_fields?.map(f => $t('sirsoft-page.admin.page.detail.versions.field_labels.' + f)).join(', ') ?? '-'}}"
|
||||
},
|
||||
|
||||
@@ -423,7 +423,8 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "form-hint block"
|
||||
"className": "form-hint block",
|
||||
"data-testid": "page-slug-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.form.fields.slug_hint|slug={{_local.form?.slug || 'example'}}"
|
||||
},
|
||||
@@ -956,7 +957,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "btn btn-secondary"
|
||||
"className": "btn btn-secondary",
|
||||
"data-testid": "page-form-cancel"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
"filters[0][operator]": "like",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "pages_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -268,6 +269,7 @@
|
||||
"filters[0][operator]": "like",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "pages_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -362,6 +364,7 @@
|
||||
"filters[0][operator]": "like",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "pages_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -389,6 +392,7 @@
|
||||
"filters[0][operator]": "like",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "pages_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -470,6 +474,7 @@
|
||||
"published": "{{$event.target.value}}",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "pages_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -513,6 +518,7 @@
|
||||
"sort_order": "{{$event.target.value.split('_').pop()}}",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "pages_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -555,6 +561,7 @@
|
||||
"per_page": "{{$event.target.value}}",
|
||||
"page": 1
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "pages_data_grid__body"
|
||||
}
|
||||
}
|
||||
@@ -820,6 +827,7 @@
|
||||
"query": {
|
||||
"page": "{{$args[0]}}"
|
||||
},
|
||||
"replace": true,
|
||||
"transition_overlay_target": "pages_data_grid__body"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ use Modules\Sirsoft\Page\Services\PageService;
|
||||
*
|
||||
* core.search.results Filter Hook을 구독하여 검색 결과에 페이지를 추가합니다.
|
||||
* core.search.build_response Filter Hook을 구독하여 응답 구조를 생성합니다.
|
||||
* core.search.validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
* core.search.index_validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
*/
|
||||
class SearchPagesListener implements HookListenerInterface
|
||||
{
|
||||
@@ -37,7 +37,7 @@ class SearchPagesListener implements HookListenerInterface
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
],
|
||||
'core.search.validation_rules' => [
|
||||
'core.search.index_validation_rules' => [
|
||||
'method' => 'addValidationRules',
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
|
||||
@@ -76,6 +76,12 @@ export default defineConfig({
|
||||
],
|
||||
use: {
|
||||
baseURL: resolveBaseUrl(),
|
||||
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
|
||||
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
|
||||
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
|
||||
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
|
||||
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
|
||||
locale: 'ko-KR',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
|
||||
+12
-15
@@ -1,23 +1,20 @@
|
||||
/**
|
||||
* 페이지 폼 취소 버튼 이동 경로 — 수정 모드는 상세로, 등록 모드는 목록으로
|
||||
* (placeholder — data-testid 보강 후 활성화).
|
||||
* 페이지 폼 취소 버튼 이동 경로 — 수정 모드는 상세로, 등록 모드는 목록으로.
|
||||
*
|
||||
* 결함: 취소 버튼이 navigateBack(history.back)이라 직전 위치(다른 관리 메뉴)로 튀거나 URL 직접
|
||||
* 진입 시 아무 데도 못 갔다. navigate + route.id 분기(수정=상세 /admin/pages/{id},
|
||||
* 등록=목록 /admin/pages)로 교체했다.
|
||||
*
|
||||
* 레이아웃 렌더링 테스트(admin-page-layouts.test.ts > "[M6] 취소 버튼이 navigateBack 이 아니라
|
||||
* navigate 로 목록/상세를 명시함")가 핸들러와 path 표현식을 구조적으로 잠그고, 본 spec 이
|
||||
* 실제 라우트 이동(직접 진입 → 취소 → 목록/상세)을 브라우저에서 잠근다.
|
||||
*
|
||||
* @scenario form_mode=create, form_mode=edit
|
||||
* @effects cancel_in_edit_mode_navigates_to_detail,
|
||||
* cancel_in_create_mode_navigates_to_list
|
||||
*
|
||||
* e2e:allow 취소 버튼이 navigateBack(history.back)이던 회귀 — 직전 위치(다른 관리 메뉴)로
|
||||
* 튀거나 URL 직접 진입 시 멈추던 문제를, navigate + route.id 분기(수정=상세 /admin/pages/{id},
|
||||
* 등록=목록 /admin/pages)로 교체했다.
|
||||
* 레이아웃 렌더링 테스트(admin-page-layouts.test.ts > "[M6] 취소 버튼이 navigateBack 이 아니라
|
||||
* navigate 로 목록/상세를 명시함")가 핸들러가 navigateBack 이 아니라 navigate 이고 path 표현식이
|
||||
* route.id 분기를 가짐을 구조적으로 회귀 차단한다. 본 placeholder spec(test.describe.skip)은
|
||||
* 취소 버튼에 data-testid 보강 후 활성화되어 실제 라우트 이동(직접 진입 → 취소 → 목록/상세)을 확인한다.
|
||||
*
|
||||
* 본 spec 은 다음 사전 작업 완료 후 활성화한다:
|
||||
* 1. 취소 버튼(footer_cancel_button)에 data-testid="page-form-cancel"
|
||||
* 2. seededPage fixture(playwright:seed-page) 실 시드 로직 구현
|
||||
* 3. test.describe.skip → test.describe 변경
|
||||
* 남은 개선: EDIT_URL 이 /admin/pages/1 을 하드코딩해 1번 페이지가 존재하는 환경에만 의존한다.
|
||||
* seededPage fixture(playwright:seed-page)가 돌려주는 id 로 교체하면 환경 독립이 된다.
|
||||
*
|
||||
* 매트릭스(시나리오 매니페스트 form_mode axis 와 1:1):
|
||||
* - edit 모드 직접 진입 → 취소 → /admin/pages/{id} (상세)
|
||||
@@ -28,7 +25,7 @@ import { test, expect, authenticatePage } from '../../fixtures/page-auth';
|
||||
const EDIT_URL = '/admin/pages/1/edit';
|
||||
const CREATE_URL = '/admin/pages/create';
|
||||
|
||||
test.describe.skip('페이지 폼 취소 이동 경로 (placeholder — data-testid 보강 후 활성화)', () => {
|
||||
test.describe('페이지 폼 취소 이동 경로', () => {
|
||||
test('수정 폼에 직접 진입 후 취소하면 해당 페이지 상세로 이동한다', async ({
|
||||
page,
|
||||
pageManageToken,
|
||||
|
||||
+10
-5
@@ -13,10 +13,15 @@
|
||||
* $t('sirsoft-page.admin.page.detail.versions.field_labels.<field>') 로 매핑함을
|
||||
* 구조적으로 회귀 차단한다. 브라우저 실제 로케일 렌더만 본 spec 이 확인한다.
|
||||
*
|
||||
* 본 spec 은 다음 사전 작업 완료 후 활성화한다 (data-testid 보강):
|
||||
* 1. 버전 이력 행의 변경 항목 셀에 data-testid="page-version-changed-fields"
|
||||
* 2. seededPage fixture(playwright:seed-page) 실 시드 로직 구현 (버전 이력 있는 페이지)
|
||||
* 3. test.describe.skip → test.describe 변경
|
||||
* 본 spec 은 다음 사전 작업 완료 후 활성화한다:
|
||||
* 1. (완료) 버전 이력 행의 변경 항목 셀에 data-testid="page-version-changed-fields"
|
||||
* 2. playwright:seed-page 가 **변경 이력이 있는 버전**까지 생성하도록 확장.
|
||||
* 현재 시드는 페이지 + 첨부만 만들고 버전 행을 만들지 않는다. 레이아웃의 변경 항목 셀은
|
||||
* `row.version !== 1 && row.changes_summary?.changed_fields?.length > 0` 조건이라
|
||||
* 버전 1뿐인 페이지에서는 아예 렌더되지 않는다 (실측: element not found).
|
||||
* 3. DETAIL_URL 하드코딩(/admin/pages/1) 제거 — seededPage fixture 가 돌려주는 id 사용.
|
||||
* 지금은 우연히 존재하는 1번 페이지에 의존해 환경마다 결과가 달라진다.
|
||||
* 4. test.describe.skip → test.describe 변경
|
||||
*
|
||||
* 매트릭스(시나리오 매니페스트 page-attachment-order-and-seo-cache.yaml 의 page_change=update 와 1:1):
|
||||
* - update 후 상세 진입: 변경 항목이 필드 키 원문이 아닌 다국어 라벨로 표시
|
||||
@@ -25,7 +30,7 @@ import { test, expect, authenticatePage } from '../../fixtures/page-auth';
|
||||
|
||||
const DETAIL_URL = '/admin/pages/1';
|
||||
|
||||
test.describe.skip('페이지 버전 변경 필드 다국어 라벨 (placeholder — data-testid 보강 후 활성화)', () => {
|
||||
test.describe.skip('페이지 버전 변경 필드 다국어 라벨 (placeholder — 버전 이력 시드 후 활성화)', () => {
|
||||
test('버전 이력의 변경 항목이 필드 키 원문 대신 다국어 라벨로 표시된다', async ({
|
||||
page,
|
||||
pageReadToken,
|
||||
|
||||
@@ -49,7 +49,7 @@ class SearchPagesListenerTest extends ModuleTestCase
|
||||
$this->assertCount(3, $hooks);
|
||||
$this->assertArrayHasKey('core.search.results', $hooks);
|
||||
$this->assertArrayHasKey('core.search.build_response', $hooks);
|
||||
$this->assertArrayHasKey('core.search.validation_rules', $hooks);
|
||||
$this->assertArrayHasKey('core.search.index_validation_rules', $hooks);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* sirsoft-page 모듈 1.0.2 업그레이드 스텝
|
||||
*
|
||||
* 외래키 컬럼의 비어 있는 한국어 comment 를 채운다(1컬럼). `->comment()` 가
|
||||
* `->constrained()` 뒤에 체인되어 컬럼이 아닌 외래키 정의에 부착되던 문제를 소스에서
|
||||
* 교정했으나, 기설치본은 마이그레이션이 재실행되지 않아 그대로 남기 때문이다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.0.2/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*/
|
||||
class Upgrade_1_0_2 extends AbstractUpgradeStep {}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftPage\V1_0_2\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 외래키 컬럼의 누락된 한국어 comment 를 채웁니다.
|
||||
*
|
||||
* 배경:
|
||||
* `$table->foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가
|
||||
* 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가
|
||||
* 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친
|
||||
* 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다.
|
||||
*
|
||||
* 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다.
|
||||
*
|
||||
* 안전:
|
||||
* - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다.
|
||||
* - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는
|
||||
* 아무 것도 바뀌지 않습니다.
|
||||
* - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다).
|
||||
*
|
||||
* 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다.
|
||||
*
|
||||
* V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을
|
||||
* 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조).
|
||||
*/
|
||||
class BackfillForeignKeyColumnComments implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 보정 대상 (1.0.2 시점 동결) — 테이블 => [컬럼 => 설명].
|
||||
*/
|
||||
private const TARGETS = [
|
||||
'page_versions' => [
|
||||
'page_id' => '페이지 ID',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillForeignKeyColumnComments';
|
||||
}
|
||||
|
||||
/**
|
||||
* 비어 있는 외래키 컬럼 설명을 채웁니다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
|
||||
$context->logger->info('[1.0.2] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$filled = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach (self::TARGETS as $table => $columns) {
|
||||
$prefixed = $context->table($table);
|
||||
|
||||
foreach ($columns as $column => $comment) {
|
||||
try {
|
||||
$meta = $this->columnMeta($prefixed, $column);
|
||||
|
||||
if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment));
|
||||
$filled++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$context->logger->warning(sprintf(
|
||||
'[1.0.2] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s',
|
||||
$prefixed,
|
||||
$column,
|
||||
$e->getMessage(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$context->logger->info(sprintf(
|
||||
'[1.0.2] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건',
|
||||
$filled,
|
||||
$skipped,
|
||||
$failed,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼의 현재 스키마 메타데이터를 조회합니다.
|
||||
*
|
||||
* @param string $table 프리픽스가 적용된 테이블명
|
||||
* @param string $column 컬럼명
|
||||
* @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null)
|
||||
*/
|
||||
private function columnMeta(string $table, string $column): ?object
|
||||
{
|
||||
$rows = DB::select(
|
||||
'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
|
||||
[$table, $column],
|
||||
);
|
||||
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다.
|
||||
*
|
||||
* @param string $table 프리픽스가 적용된 테이블명
|
||||
* @param string $column 컬럼명
|
||||
* @param object $meta columnMeta() 결과
|
||||
* @param string $comment 넣을 설명
|
||||
* @return string 실행할 SQL
|
||||
*/
|
||||
private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string
|
||||
{
|
||||
$sql = sprintf(
|
||||
'ALTER TABLE %s MODIFY COLUMN %s %s',
|
||||
$this->quoteIdentifier($table),
|
||||
$this->quoteIdentifier($column),
|
||||
$meta->COLUMN_TYPE,
|
||||
);
|
||||
|
||||
$sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL';
|
||||
|
||||
if ($meta->COLUMN_DEFAULT !== null) {
|
||||
$sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT);
|
||||
}
|
||||
|
||||
if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) {
|
||||
$sql .= ' AUTO_INCREMENT';
|
||||
}
|
||||
|
||||
return $sql.' COMMENT '.$this->quoteValue($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 식별자를 백틱으로 감쌉니다.
|
||||
*
|
||||
* @param string $identifier 테이블/컬럼명
|
||||
* @return string 이스케이프된 식별자
|
||||
*/
|
||||
private function quoteIdentifier(string $identifier): string
|
||||
{
|
||||
return '`'.str_replace('`', '``', $identifier).'`';
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 값을 SQL 리터럴로 인용합니다.
|
||||
*
|
||||
* @param string $value 값
|
||||
* @return string 인용된 리터럴
|
||||
*/
|
||||
private function quoteValue(string $value): string
|
||||
{
|
||||
return DB::connection()->getPdo()->quote($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"preset": "laravel",
|
||||
"rules": {
|
||||
"no_superfluous_phpdoc_tags": false
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,11 @@ function resolveBaseUrl(): string {
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/Playwright/specs',
|
||||
// 레이아웃 편집기 저장(PUT) spec 전용 시드 화면 설치/제거.
|
||||
// 저장 spec 이 제품 화면(home/admin_dashboard)을 대상으로 하면 실행마다 편집 결과가
|
||||
// 누적돼 개발 사이트가 오염된다 — 상세는 tests/Playwright/fixtures/seed-layout.ts.
|
||||
globalSetup: './tests/Playwright/global-setup.ts',
|
||||
globalTeardown: './tests/Playwright/global-teardown.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
@@ -72,6 +77,12 @@ export default defineConfig({
|
||||
],
|
||||
use: {
|
||||
baseURL: resolveBaseUrl(),
|
||||
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
|
||||
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
|
||||
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
|
||||
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
|
||||
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
|
||||
locale: 'ko-KR',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
|
||||
@@ -22,9 +22,11 @@
|
||||
- 「동의하지 않고 계속하기」를 눌러도 필수 쿠키가 「동의함」으로 기록되지 않도록 바로잡았습니다 — 필수 쿠키는 서비스 이용에 반드시 필요하여 동의 대상이 아닙니다.
|
||||
- 거부 시 안내 문구를 「동의하지 않고 계속합니다」로 바로잡았습니다 (이전에는 「동의가 저장되었습니다」로 잘못 표시).
|
||||
- 관리자 환경설정의 「쿠키 배너 노출」 안내 문구를 실제 동작에 맞게 바로잡았습니다 — 이 토글은 배너와 자동 차단만 제어하며, 마이페이지 「쿠키 동의 관리」 카드는 동의·철회 이력이 있는 회원에게 이 토글과 무관하게 항상 노출됩니다.
|
||||
- 사이트 언어를 바꾼 뒤 쿠키 동의를 저장하면 차단 해제·재차단이 즉시 반영되지 않던 문제를 수정했습니다. 페이지를 새로 고쳐야 반영되던 증상입니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
|
||||
- 관리자 환경설정의 쿠키 카테고리·차단 도메인 목록에서 항목이 여러 개일 때 화면 요소 식별자가 중복되던 문제를 바로잡았습니다.
|
||||
- 관리자 환경설정의 카테고리 「필수」 배지가 다크 모드에서 배경·글자색이 적용되지 않던 문제를 바로잡았습니다.
|
||||
- 관리자 환경설정의 「운영 주체 / 개인정보처리방침」 섹션 제목 영역이 다른 카드와 다르게 표시되던 문제를 바로잡았습니다.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-gdpr",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
|
||||
+2
-2
@@ -17,9 +17,9 @@ return new class extends Migration
|
||||
Schema::create('gdpr_user_consents', function (Blueprint $table) {
|
||||
$table->id()->comment('행 ID');
|
||||
$table->foreignId('user_id')
|
||||
->comment('사용자 ID')
|
||||
->constrained('users')
|
||||
->cascadeOnDelete()
|
||||
->comment('사용자 ID');
|
||||
->cascadeOnDelete();
|
||||
$table->string('consent_key', 50)->comment('동의 항목 키 (cookie_necessary 등)');
|
||||
$table->string('consent_category', 30)->nullable()->comment('동의 분류 (cookie 등)');
|
||||
$table->boolean('is_consented')->default(false)->comment('현재 동의 여부');
|
||||
|
||||
+2
-2
@@ -18,9 +18,9 @@ return new class extends Migration
|
||||
$table->id()->comment('행 ID');
|
||||
$table->foreignId('user_id')
|
||||
->nullable()
|
||||
->comment('사용자 ID (게스트면 NULL, 삭제 시 NULL 익명화)')
|
||||
->constrained('users')
|
||||
->nullOnDelete()
|
||||
->comment('사용자 ID (게스트면 NULL, 삭제 시 NULL 익명화)');
|
||||
->nullOnDelete();
|
||||
$table->string('session_id', 100)->nullable()->comment('게스트 세션 ID (회원이면 NULL)');
|
||||
$table->string('consent_key', 50)->comment('동의 항목 키');
|
||||
$table->string('action', 20)->comment('변경 유형 (granted/revoked/acknowledged)');
|
||||
|
||||
+4
-3
@@ -4,6 +4,7 @@ use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Plugins\Sirsoft\Gdpr\Plugin;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
@@ -22,9 +23,9 @@ return new class extends Migration
|
||||
$table->json('snapshot')->comment('발행 시점 settings 스냅샷 (cookie_categories + blocked_domains + privacy_policy_slug 등)');
|
||||
$table->foreignId('created_by')
|
||||
->nullable()
|
||||
->comment('발행 운영자 user_id (운영자 삭제 시 NULL — 이력은 영구 보존)')
|
||||
->constrained('users')
|
||||
->nullOnDelete()
|
||||
->comment('발행 운영자 user_id (운영자 삭제 시 NULL — 이력은 영구 보존)');
|
||||
->nullOnDelete();
|
||||
$table->timestamp('created_at')->nullable()->comment('발행 일시 (UPDATED_AT 없음 — 불변 레코드)');
|
||||
|
||||
$table->index('created_at');
|
||||
@@ -45,7 +46,7 @@ return new class extends Migration
|
||||
// 빈 snapshot 으로 두면 회원이 v1 에 동의했을 때 "어떤 정책에 동의했는지" 입증 불가.
|
||||
// cookie_categories 는 settings 컬럼이 string 이라 json_encode 된 형태이므로 snapshot
|
||||
// 에는 디코드된 배열로 정규화 (snapshot 스키마는 항상 객체/배열 — admin 측 발행 경로와 일치).
|
||||
$defaults = (new \Plugins\Sirsoft\Gdpr\Plugin())->getConfigValues();
|
||||
$defaults = (new Plugin)->getConfigValues();
|
||||
if (isset($defaults['cookie_categories']) && is_string($defaults['cookie_categories'])) {
|
||||
$decoded = json_decode($defaults['cookie_categories'], true);
|
||||
$defaults['cookie_categories'] = is_array($decoded) ? $decoded : [];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user