fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
공개이슈 ··· 및 그 전수 해소분. - 설정 저장·복원·초기화가 상주 프로세스 미러를 갱신하도록 단일 소유자화 - 만료 데이터 자동 정리 예약 12종 신설, 파기는 기본키 배치로 분할 - 회원 탈퇴를 전 경로 원자화하고 관리자 상태변경 경로를 정식 탈퇴로 통일 - 일괄 처리 목록의 선택을 화면에 보이는 행으로 한정(selectionScope) - 비밀번호 정책 문구를 도메인 중립 라벨로 정정 - 정적 검사 2종·검증 스킬 설계 정합성 축 신설
This commit is contained in:
@@ -356,6 +356,21 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
의도적 리셋(검색 초기화 / 필터 초기화 / 탭 전환 / 프리셋 적용 / 다른 목록으로의 이동)은 예외다. 그 경우 액션 노드 `comment` 에 `audit:allow layout-list-context-navigate-merge-query <사유>` 를 남겨 의도를 코드에 기록한다. 상세: [actions-handlers-navigation.md "목록 컨텍스트 왕복 규약"](docs/frontend/actions-handlers-navigation.md)
|
||||
|
||||
### 일괄 처리 목록의 선택 범위
|
||||
|
||||
체크박스 선택은 화면 밖(전역/로컬 상태)에 저장된다. 그래서 검색·필터·페이지 이동으로 행이 목록에서 빠져도 그 행의 선택은 남는다. 그 상태에서 일괄 처리를 누르면 사용자가 보고 있지도, 체크하지도 않은 행이 대상이 된다. 확인 모달은 건수만 말하므로 실행 전에 알아챌 방법이 없고, 처리는 정상 성공하므로 실행 후에도 오류가 남지 않는다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 선택을 화면 밖 상태에 저장하는 DataGrid 에 `selectionScope` 미선언 | `"selectionScope": "page"`(일괄 처리 목록) 또는 `"free"`(선택 자체가 저장 대상인 폼)를 **명시** |
|
||||
| 일괄 처리 버튼이 달린 목록에 `"free"` | `"page"` — 대상은 언제나 "화면에 보이고 체크된 행" |
|
||||
| 여러 페이지에 걸쳐 고르는 폼 선택기에 `"page"` | `"free"` — 페이지를 넘기면 앞 페이지 선택이 사라져 기능이 깨진다 |
|
||||
| `selectable` 이 꺼진 화면이라 안전하다고 간주 | 체크박스가 없으면 남은 선택이 **더** 안 보인다 — 범위 판정은 `selectable` 과 무관 |
|
||||
| `"selectionScope": "{{조건}}"` 표현식 분기 | 리터럴 고정 — 분기마다 보존 여부가 갈리면 한쪽이 조용히 대상 밖 행을 싣는다 |
|
||||
| 화면마다 검색·필터·페이지 액션에 선택 초기화 액션을 복제 | 컴포넌트가 단일 지점에서 정리 — 액션 복제는 한 곳만 빠져도 같은 결함이 남는다 |
|
||||
|
||||
정적 검사가 `onSelectionChange` 가 배선된 DataGrid 를 전수 검사해 미선언을 차단한다. 상세: [component-props.md DataGrid](docs/frontend/component-props.md)
|
||||
|
||||
### 중첩 리소스 스코프 / 계층 무결성
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|
||||
@@ -13,14 +13,24 @@
|
||||
- 서버에서 동작할 수 없는 드라이버(필요한 라이브러리·PHP 확장이 없는 경우)를 저장하려 하면 사유와 함께 거부합니다 — 잘못된 드라이버 저장으로 사이트가 멈추는 사고를 저장 시점에 차단합니다.
|
||||
- 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)을 S3+CDN 등 원격 디스크의 직접 URL 로 서빙할 수 있습니다. 환경설정 > 드라이버의 "공개 자산 스토리지"에서 디스크를 고르면 새로 올리는 이미지가 그 디스크에 저장되고 화면에는 CDN 주소가 실립니다 — 서버를 거치지 않아 트래픽 부담이 줄어듭니다. 사용 안 함(기본)이면 종전 방식 그대로이며, 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. 확장(쇼핑몰·에디터)별로 다른 디스크를 쓰도록 개별 설정할 수도 있습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
- 확장 개발자용: 스토리지 URL 계약이 public 외에도 URL 이 설정된 디스크(S3+CDN 등)에서 직접 주소를 반환하며, 생성 결과를 공급·수정·차단할 수 있는 `core.storage.filter_url` 필터 훅이 추가되었습니다. 공개 자산 디스크 선택지는 `core.settings.available_public_asset_drivers` 훅으로 플러그인이 확장할 수 있고, 확장이 이미지 같은 카테고리 단위로 다른 스토리지 디스크를 쓰도록 분리할 수 있습니다. 공개 자산 디스크 오버라이드 설정을 선언한 플러그인은 설정 조회·저장 응답에 디스크 선택지 목록이 함께 실리고 저장 시 선택지 검증도 함께 걸리므로, 설정 화면이 별도 권한의 코어 환경설정 API 를 조회하거나 플러그인이 검증을 따로 구현할 필요가 없습니다.
|
||||
- 보존 기간이 지난 데이터의 자동 정리에 확장이 개입할 수 있는 훅을 제공합니다 — 활동 로그·알림 발송 이력·예약 작업 이력의 정리 전후에 발생하므로, 확장이 정리 대상을 외부에 따로 보관하는 등의 처리를 붙일 수 있습니다.
|
||||
- 관리자가 회원 정보 수정 화면에서 상태를 '탈퇴'로 바꿔 저장하면 확인 창을 거치도록 했습니다. 이 처리는 회원의 이메일·이름·닉네임을 익명화하고 되돌릴 수 없으므로, 무엇이 처리되는지 먼저 알려 드립니다. 함께, 관리자가 상태만 바꿔 탈퇴시킨 경우에도 회원이 직접 탈퇴한 것과 동일한 처리(익명화·연계 데이터 정리)가 이뤄지도록 통일했습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 공개 자산을 외부 주소(CDN)로 서빙할 때, 그 주소로 나가는 요청에는 로그인 토큰을 함께 보내지 않습니다 — 공개 자산은 인증이 필요 없고, 토큰이 외부 서비스로 전달되지 않아야 하기 때문입니다. 사이트 자신의 주소로 가는 요청은 종전과 동일합니다.
|
||||
- 확장 개발자용: 확장 캐시 계약 인터페이스에 카테고리별 스토리지 접근이 추가되었습니다. 코어가 제공하는 확장 베이스 클래스·서비스 프로바이더를 상속하는 확장은 수정 없이 호환되지만, 이 계약을 직접 구현하는 확장은 새 메서드를 구현해야 합니다 (번들 확장은 모두 반영 완료 — 최소 요구 코어 버전 7.0.7).
|
||||
- 예약 작업의 실행 시각이 환경설정의 시간대를 기준으로 해석되도록 바꿨습니다. 종전에는 세계표준시 기준이어서 '새벽 4시'로 등록해도 국내 기준 낮에 실행됐습니다. 이번 변경으로 새로 추가된 자동 정리 작업은 물론, 기존 사이트맵 자동 생성·GeoIP 갱신·확장이 제공하는 예약 작업, 관리자가 직접 등록한 예약 작업까지 모두 같은 기준을 따릅니다. **시각을 지정해 둔 예약은 실제 실행 시각이 이동하므로 확인이 필요합니다.**
|
||||
|
||||
### Fixed
|
||||
|
||||
- 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 회원가입 등 일반 화면의 비밀번호 오류 문구가 'SMTP 비밀번호'로 표시되던 문제를 수정했습니다. 같은 이유로 어긋나 있던 '기본 언어'·'기본 통화'·'사용자 ID 목록'·'알림 채널' 등의 문구도 화면에 맞게 정리했습니다. (#113 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 회원 삭제·회원 정보 수정·회원가입·역할 삭제·비밀번호 재설정·첨부 파일 삭제·메뉴 삭제·예약 작업 삭제가 중간에 실패하면 일부만 처리된 상태로 남던 문제를 수정했습니다. 회원 정보 수정은 프로필 변경·로그인 세션 정리·역할 변경이 함께 처리되므로, 역할 변경에서 실패하면 이름만 바뀌고 로그인이 끊긴 상태로 남았습니다. 이제 각 작업은 전부 성공하거나 전부 취소되며, 파일 삭제는 데이터 정리가 확정된 뒤에 수행됩니다.
|
||||
- 페이지 모듈의 첨부 파일 설정(개수·용량·형식)이 저장해도 반영되지 않던 문제를 수정했습니다.
|
||||
- 회원 목록에서 여러 명을 골라 일괄 탈퇴할 때, 탈퇴할 수 없는 계정(관리자 등)이 섞여 있으면 그 건이 조용히 건너뛰어지던 문제를 수정했습니다. 이제 처리된 인원·처리하지 못한 인원과 그 사유가 응답에 함께 담깁니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이 동작하지 않던 문제를 수정했습니다. S3 구동에 필요한 스토리지 어댑터가 빠져 있었고, 연결 테스트는 통과해도 실제 저장 경로는 동작하지 않았습니다. 이제 어댑터가 기본 포함되고, 연결 테스트도 실제 연결 경로와 같은 설정(엔드포인트·Path-style 포함)을 사용합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- 파일 스토리지를 S3 로 저장해도 첨부 파일 업로드는 계속 서버 로컬 디스크에 저장되던 문제를 수정했습니다. 이제 S3 저장 시 새 첨부 파일이 S3 로 저장되며, 기존 파일은 종전 위치에서 계속 서빙됩니다. 서버 환경변수(`ATTACHMENT_DISK`)로 저장 위치를 명시한 경우에는 그 값이 우선합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- phpredis PHP 확장이 없는 서버에서 캐시·세션·큐 드라이버로 Redis 를 선택하면 사이트 전체가 열리지 않던 문제를 예방했습니다 — Redis 클라이언트 라이브러리(predis)가 기본 포함되어 확장 없이도 동작합니다.
|
||||
|
||||
@@ -515,6 +515,7 @@ cp .env.example .env
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
|
||||
@@ -531,6 +531,7 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\IdentityLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 만료 시각이 지난 본인인증 challenge 를 만료 상태로 전환하는 커맨드
|
||||
*
|
||||
* 상태 전환만 수행하며 행을 삭제하지 않습니다 (물리 파기는 identity:prune-logs 담당).
|
||||
*/
|
||||
class ExpireIdentityChallengesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'identity:expire-challenges';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '만료 시각이 지난 본인인증 challenge 를 만료 처리합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param IdentityLogService $service 본인인증 이력 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(IdentityLogService $service): int
|
||||
{
|
||||
$expired = $service->expirePastDue();
|
||||
|
||||
$this->info("본인인증 challenge {$expired}건이 만료 처리되었습니다.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ActivityLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 활동 로그를 정리하는 커맨드
|
||||
*/
|
||||
class PruneActivityLogsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'activity-log:prune {--days=365 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 활동 로그를 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
|
||||
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param ActivityLogService $service 활동 로그 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ActivityLogService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->prune($days);
|
||||
|
||||
$this->info("활동 로그 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\IdentityLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 본인인증 이력을 파기하는 커맨드
|
||||
*
|
||||
* 관리자 화면의 수동 파기 버튼과 동일한 기본 보관주기(180일)를 사용합니다.
|
||||
*/
|
||||
class PruneIdentityLogsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'identity:prune-logs {--days=180 : 보관 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보관 기간이 지난 본인인증 이력을 파기합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
|
||||
* 보존 기간 하한은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param IdentityLogService $service 본인인증 이력 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(IdentityLogService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$purged = $service->purge($days);
|
||||
|
||||
$this->info("본인인증 이력 {$purged}건이 파기되었습니다. (보관 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\NotificationLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 알림 발송 이력을 정리하는 커맨드
|
||||
*/
|
||||
class PruneNotificationLogsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'notification-log:prune {--days=90 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 알림 발송 이력을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
|
||||
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param NotificationLogService $service 알림 발송 이력 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(NotificationLogService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->prune($days);
|
||||
|
||||
$this->info("알림 발송 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ScheduleService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 스케줄 실행 이력을 정리하는 커맨드
|
||||
*
|
||||
* 이력 행은 출력(longText)을 포함해 실행마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
|
||||
*/
|
||||
class PruneScheduleHistoryCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'schedules:prune-history {--days=90 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 스케줄 실행 이력을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
|
||||
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param ScheduleService $service 스케줄 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ScheduleService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->pruneHistory($days);
|
||||
|
||||
$this->info("스케줄 실행 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Seo\SeoCacheStatsService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 SEO 캐시 통계를 정리하는 커맨드
|
||||
*
|
||||
* 통계 행은 봇 요청마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
|
||||
*/
|
||||
class PruneSeoCacheStatsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'seo:prune-stats {--days=30 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 SEO 캐시 통계 기록을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
|
||||
* 보존 기간 하한은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param SeoCacheStatsService $service SEO 캐시 통계 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(SeoCacheStatsService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->cleanup($days);
|
||||
|
||||
$this->info("SEO 캐시 통계 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -65,4 +65,12 @@ interface ActivityLogRepositoryInterface
|
||||
* @return int 익명화된 row 수
|
||||
*/
|
||||
public function anonymizeUserId(int $userId): int;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 활동 로그를 삭제합니다.
|
||||
*
|
||||
* @param int $days 보존 기간 (일)
|
||||
* @return int 삭제된 건수
|
||||
*/
|
||||
public function deleteOlderThan(int $days): int;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ interface NotificationLogRepositoryInterface
|
||||
*/
|
||||
public function bulkDelete(array $ids): int;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 발송 이력을 삭제합니다.
|
||||
*
|
||||
* @param int $days 보존 기간 (일)
|
||||
* @return int 삭제된 건수
|
||||
*/
|
||||
public function deleteOlderThan(int $days): int;
|
||||
|
||||
/**
|
||||
* 페이지네이션 목록 조회.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Support\ExtensionSettingsMirror;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
if (! function_exists('g7_settings')) {
|
||||
@@ -16,15 +17,12 @@ if (! function_exists('g7_settings')) {
|
||||
* @example
|
||||
* // 코어 메일 설정 조회
|
||||
* $mailHost = g7_settings('core.mail.host');
|
||||
*
|
||||
* @example
|
||||
* // 모듈 설정 조회
|
||||
* $shopName = g7_settings('modules.sirsoft-ecommerce.basic_info.shop_name');
|
||||
*
|
||||
* @example
|
||||
* // 플러그인 설정 조회
|
||||
* $displayMode = g7_settings('plugins.sirsoft-daum_postcode.display_mode');
|
||||
*
|
||||
* @example
|
||||
* // 전체 설정 조회
|
||||
* $allSettings = g7_settings();
|
||||
@@ -50,7 +48,6 @@ if (! function_exists('g7_core_settings')) {
|
||||
* @example
|
||||
* // 메일 호스트 조회
|
||||
* $mailHost = g7_core_settings('mail.host');
|
||||
*
|
||||
* @example
|
||||
* // 사이트명 조회
|
||||
* $siteName = g7_core_settings('general.site_name');
|
||||
@@ -77,7 +74,6 @@ if (! function_exists('g7_module_settings')) {
|
||||
* @example
|
||||
* // 이커머스 모듈의 쇼핑몰명 조회
|
||||
* $shopName = g7_module_settings('sirsoft-ecommerce', 'basic_info.shop_name');
|
||||
*
|
||||
* @example
|
||||
* // 모듈 전체 설정 조회
|
||||
* $allSettings = g7_module_settings('sirsoft-ecommerce');
|
||||
@@ -92,6 +88,23 @@ if (! function_exists('g7_module_settings')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('g7_refresh_module_settings_config')) {
|
||||
/**
|
||||
* 모듈 환경설정 config 미러를 다시 채웁니다.
|
||||
*
|
||||
* 모듈 설정 저장에는 코어 공통 지점이 없어 각 모듈의 SettingsService 가 직접 저장합니다.
|
||||
* 그 서비스가 자기 캐시를 비우는 자리에서 이 헬퍼를 호출하면, 상주 프로세스에서도
|
||||
* `g7_module_settings()` 가 저장 직후의 값을 읽습니다 (공개이슈 #109).
|
||||
*
|
||||
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
|
||||
* @return void
|
||||
*/
|
||||
function g7_refresh_module_settings_config(string $identifier): void
|
||||
{
|
||||
app(ExtensionSettingsMirror::class)->refreshModule($identifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('g7_plugin_settings')) {
|
||||
/**
|
||||
* 그누보드7 플러그인 환경설정 조회 헬퍼 함수
|
||||
@@ -104,7 +117,6 @@ if (! function_exists('g7_plugin_settings')) {
|
||||
* @example
|
||||
* // 다음 우편번호 플러그인의 표시 모드 조회
|
||||
* $displayMode = g7_plugin_settings('sirsoft-daum_postcode', 'display_mode');
|
||||
*
|
||||
* @example
|
||||
* // 플러그인 전체 설정 조회
|
||||
* $allSettings = g7_plugin_settings('sirsoft-daum_postcode');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\Auth;
|
||||
|
||||
use App\Enums\AttachmentSourceType;
|
||||
use App\Exceptions\CannotDeleteSuperAdminException;
|
||||
use App\Http\Controllers\Api\Base\AuthBaseController;
|
||||
use App\Http\Requests\Auth\VerifyPasswordRequest;
|
||||
use App\Http\Requests\User\ChangePasswordRequest;
|
||||
@@ -302,12 +303,23 @@ class ProfileController extends AuthBaseController
|
||||
return $this->unauthorized('auth.unauthenticated');
|
||||
}
|
||||
|
||||
$this->logUserActivity('profile.withdraw');
|
||||
|
||||
// 사용자 탈퇴 처리 (아바타, 토큰 삭제 및 익명화)
|
||||
$this->userService->withdrawUser($user);
|
||||
|
||||
// 활동 로그는 성공한 뒤에만 남긴다 — 실패한 탈퇴가 기록으로 남으면
|
||||
// 이력만 보고는 탈퇴한 것으로 읽힌다.
|
||||
$this->logUserActivity('profile.withdraw');
|
||||
|
||||
return $this->success('user.withdraw_success');
|
||||
} catch (ValidationException $e) {
|
||||
// 관리자/수퍼관리자 탈퇴 차단은 요청이 잘못된 것이지 서버 오류가 아니다.
|
||||
return $this->validationError($e->errors(), 'user.withdraw_failed');
|
||||
} catch (CannotDeleteSuperAdminException $e) {
|
||||
// 이 예외는 ValidationException 을 상속하지 않으므로 별도 매핑이 필요하다.
|
||||
return $this->validationError(
|
||||
['general' => [$e->getMessage()]],
|
||||
'user.withdraw_failed'
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('user.withdraw_failed', 500, null, ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -880,25 +880,25 @@ class SaveSettingsRequest extends FormRequest
|
||||
'general.site_description' => __('validation.attributes.site_description'),
|
||||
'general.admin_email' => __('validation.attributes.admin_email'),
|
||||
'general.timezone' => __('validation.attributes.timezone'),
|
||||
'general.language' => __('validation.attributes.language'),
|
||||
'general.language' => __('validation.attributes.default_language'),
|
||||
// 본인인증(IDV) 필드
|
||||
'identity.default_provider' => __('validation.attributes.identity_default_provider'),
|
||||
'identity.purpose_providers' => __('validation.attributes.identity_purpose_providers'),
|
||||
'identity.challenge_ttl_minutes' => __('validation.attributes.identity_challenge_ttl_minutes'),
|
||||
'identity.max_attempts' => __('validation.attributes.identity_max_attempts'),
|
||||
// notifications
|
||||
'notifications.channels' => __('validation.attributes.channels'),
|
||||
'notifications.channels' => __('validation.attributes.notification_channels'),
|
||||
// general
|
||||
'general.currency' => __('validation.attributes.currency'),
|
||||
'general.currency' => __('validation.attributes.default_currency'),
|
||||
'general.maintenance_mode' => __('validation.attributes.maintenance_mode'),
|
||||
'general.asset_url_mode' => __('validation.attributes.asset_url_mode'),
|
||||
'general.site_logo' => __('validation.attributes.site_logo'),
|
||||
// mail
|
||||
'mail.mailer' => __('validation.attributes.mailer'),
|
||||
'mail.host' => __('validation.attributes.host'),
|
||||
'mail.port' => __('validation.attributes.port'),
|
||||
'mail.username' => __('validation.attributes.username'),
|
||||
'mail.password' => __('validation.attributes.password'),
|
||||
'mail.host' => __('validation.attributes.smtp_host'),
|
||||
'mail.port' => __('validation.attributes.smtp_port'),
|
||||
'mail.username' => __('validation.attributes.smtp_username'),
|
||||
'mail.password' => __('validation.attributes.smtp_password'),
|
||||
'mail.encryption' => __('validation.attributes.encryption'),
|
||||
'mail.mailgun_domain' => __('validation.attributes.mailgun_domain'),
|
||||
'mail.mailgun_secret' => __('validation.attributes.mailgun_secret'),
|
||||
@@ -930,7 +930,7 @@ class SaveSettingsRequest extends FormRequest
|
||||
'seo.twitter_default_card' => __('validation.attributes.twitter_default_card'),
|
||||
'seo.twitter_default_site' => __('validation.attributes.twitter_default_site'),
|
||||
'seo.cache_enabled' => __('validation.attributes.seo_page_cache_enabled'),
|
||||
'seo.cache_ttl' => __('validation.attributes.cache_ttl'),
|
||||
'seo.cache_ttl' => __('validation.attributes.seo_page_cache_ttl'),
|
||||
'seo.sitemap_enabled' => __('validation.attributes.sitemap_enabled'),
|
||||
'seo.sitemap_cache_ttl' => __('validation.attributes.sitemap_cache_ttl'),
|
||||
'seo.sitemap_urls_per_file' => __('validation.attributes.sitemap_urls_per_file'),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,7 @@ class TestMailRequest extends FormRequest
|
||||
/**
|
||||
* 검증 규칙을 반환합니다.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -72,8 +73,8 @@ class TestMailRequest extends FormRequest
|
||||
'to_email.max' => __('settings.invalid_email'),
|
||||
'from_address.required' => __('validation.required', ['attribute' => __('validation.attributes.from_address')]),
|
||||
'from_name.required' => __('validation.required', ['attribute' => __('validation.attributes.from_name')]),
|
||||
'host.required' => __('validation.required', ['attribute' => __('validation.attributes.host')]),
|
||||
'port.required' => __('validation.required', ['attribute' => __('validation.attributes.port')]),
|
||||
'host.required' => __('validation.required', ['attribute' => __('validation.attributes.smtp_host')]),
|
||||
'port.required' => __('validation.required', ['attribute' => __('validation.attributes.smtp_port')]),
|
||||
'mailgun_domain.required' => __('validation.required', ['attribute' => __('validation.attributes.mailgun_domain')]),
|
||||
'mailgun_secret.required' => __('validation.required', ['attribute' => __('validation.attributes.mailgun_secret')]),
|
||||
'ses_key.required' => __('validation.required', ['attribute' => __('validation.attributes.ses_key')]),
|
||||
@@ -81,4 +82,22 @@ class TestMailRequest extends FormRequest
|
||||
'ses_region.required' => __('validation.required', ['attribute' => __('validation.attributes.ses_region')]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 속성명을 반환합니다.
|
||||
*
|
||||
* 범용 필드명(host/port/username/password)은 전역 라벨이 범용 문구이므로,
|
||||
* SMTP 전용 라벨을 이 요청에서만 명시 매핑합니다.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'host' => __('validation.attributes.smtp_host'),
|
||||
'port' => __('validation.attributes.smtp_port'),
|
||||
'username' => __('validation.attributes.smtp_username'),
|
||||
'password' => __('validation.attributes.smtp_password'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Enums\UserStatus;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use App\Rules\ExcludeCurrentUser;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -13,17 +14,20 @@ class BulkUpdateUserStatusRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 요청 권한을 확인합니다.
|
||||
*
|
||||
* 권한 검사는 라우트의 `permission:admin,core.users.update` 미들웨어가 담당합니다.
|
||||
*
|
||||
* @return bool 항상 true (미들웨어에서 권한 제어)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
// 사용자 업데이트 권한 확인
|
||||
return $this->user()->can('core.users.update');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙을 정의합니다.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -45,9 +49,9 @@ class BulkUpdateUserStatusRequest extends FormRequest
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'ids.required' => __('validation.required', ['attribute' => __('validation.attributes.ids')]),
|
||||
'ids.array' => __('validation.array', ['attribute' => __('validation.attributes.ids')]),
|
||||
'ids.min' => __('validation.min.array', ['attribute' => __('validation.attributes.ids'), 'min' => 1]),
|
||||
'ids.required' => __('validation.required', ['attribute' => __('validation.attributes.user_ids')]),
|
||||
'ids.array' => __('validation.array', ['attribute' => __('validation.attributes.user_ids')]),
|
||||
'ids.min' => __('validation.min.array', ['attribute' => __('validation.attributes.user_ids'), 'min' => 1]),
|
||||
'ids.*.required' => __('validation.required', ['attribute' => __('validation.attributes.user_id')]),
|
||||
'ids.*.uuid' => __('validation.uuid', ['attribute' => __('validation.attributes.user_id')]),
|
||||
'ids.*.exists' => __('validation.exists', ['attribute' => __('validation.attributes.user_id')]),
|
||||
@@ -55,4 +59,20 @@ class BulkUpdateUserStatusRequest extends FormRequest
|
||||
'status.in' => __('validation.in', ['attribute' => __('validation.attributes.status')]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 속성명을 반환합니다.
|
||||
*
|
||||
* 범용 필드명 ids 는 전역 라벨이 범용 문구이므로,
|
||||
* 사용자 대상 라벨을 이 요청에서만 명시 매핑합니다.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'ids' => __('validation.attributes.user_ids'),
|
||||
'ids.*' => __('validation.attributes.user_id'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -7,6 +7,7 @@ use App\Enums\ScheduleFrequency;
|
||||
use App\Enums\ScheduleResultStatus;
|
||||
use App\Enums\ScheduleType;
|
||||
use App\Models\Concerns\HasUserOverrides;
|
||||
use Carbon\Carbon;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -153,8 +154,16 @@ class Schedule extends Model
|
||||
}
|
||||
|
||||
try {
|
||||
// cron 식은 사이트 설정 시간대로 해석한다 — 코드 예약(app.schedule_timezone)과
|
||||
// 같은 기준이어야 같은 화면에서 "새벽 4시" 의 근거가 어긋나지 않는다.
|
||||
// 계산 결과는 저장 타임존(app.timezone = UTC)으로 되돌려 저장한다.
|
||||
$storeTimezone = (string) config('app.timezone', 'UTC');
|
||||
$scheduleTimezone = (string) config('app.schedule_timezone', $storeTimezone);
|
||||
|
||||
$cron = new CronExpression($this->expression);
|
||||
$this->next_run_at = $cron->getNextRunDate();
|
||||
$nextRun = $cron->getNextRunDate('now', 0, false, $scheduleTimezone);
|
||||
|
||||
$this->next_run_at = Carbon::instance($nextRun)->setTimezone($storeTimezone);
|
||||
} catch (\Exception $e) {
|
||||
$this->next_run_at = null;
|
||||
}
|
||||
|
||||
+50
-3
@@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use Illuminate\Database\Schema\Builder as SchemaBuilder;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
@@ -24,6 +25,11 @@ class User extends Authenticatable implements HasLocalePreference
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* 닉네임 컬럼 최대 길이 (마이그레이션에서 명시한 값)
|
||||
*/
|
||||
public const NICKNAME_MAX_LENGTH = 50;
|
||||
|
||||
/** @var array<string, array> 활동 로그 추적 필드 */
|
||||
public static array $activityLogFields = [
|
||||
'name' => ['label_key' => 'activity_log.fields.name', 'type' => 'text'],
|
||||
@@ -602,20 +608,39 @@ class User extends Authenticatable implements HasLocalePreference
|
||||
*/
|
||||
public function withdraw(): bool
|
||||
{
|
||||
// 멱등 가드 — 이미 탈퇴한 계정에 다시 호출해도 접미사가 겹쳐 붙지 않는다.
|
||||
if ($this->isWithdrawn()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$dateSuffix = $now->format('Ymd'); // 예: 20260127
|
||||
|
||||
// name/email 은 마이그레이션에서 길이를 지정하지 않은 문자열 컬럼이므로
|
||||
// 스키마 기본 길이(AppServiceProvider 의 defaultStringLength)를 그대로 따른다.
|
||||
// 여기에 리터럴을 박으면 기본 길이를 바꾸는 순간 조용히 어긋난다.
|
||||
$defaultLength = SchemaBuilder::$defaultStringLength ?? 255;
|
||||
|
||||
// 이름에 suffix 추가 (있는 경우만)
|
||||
if ($this->name) {
|
||||
$this->name = $this->name.'_탈퇴_'.$dateSuffix;
|
||||
$this->name = $this->appendWithdrawnSuffix($this->name, '_탈퇴_'.$dateSuffix, $defaultLength);
|
||||
}
|
||||
|
||||
// 이메일에 suffix 추가 (필수)
|
||||
$this->email = $this->email.'_deleted_'.$dateSuffix;
|
||||
//
|
||||
// 접미사에 사용자 ID 를 포함해 구조적으로 유일하게 만든다. 날짜만 붙이면
|
||||
// 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴할 때 email unique 에
|
||||
// 걸려 탈퇴가 실패한다(공개이슈 #112).
|
||||
$this->email = $this->appendWithdrawnSuffix(
|
||||
$this->email,
|
||||
'_deleted_'.$dateSuffix.'_'.$this->id,
|
||||
$defaultLength,
|
||||
);
|
||||
|
||||
// 닉네임에 suffix 추가 (있는 경우만, 날짜 없이)
|
||||
if ($this->nickname) {
|
||||
$this->nickname = $this->nickname.'_탈퇴';
|
||||
// nickname 은 마이그레이션에서 길이를 명시(50)한 컬럼이다.
|
||||
$this->nickname = $this->appendWithdrawnSuffix($this->nickname, '_탈퇴', self::NICKNAME_MAX_LENGTH);
|
||||
}
|
||||
|
||||
// 상태 변경
|
||||
@@ -624,4 +649,26 @@ class User extends Authenticatable implements HasLocalePreference
|
||||
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 탈퇴 접미사를 컬럼 길이 안에서 부착합니다.
|
||||
*
|
||||
* 원값이 길면 접미사 길이만큼 앞을 잘라 붙입니다 — 자르지 않으면 접미사가 컬럼
|
||||
* 길이를 넘겨 strict mode 저장 예외가 나고, 탈퇴가 중간에서 실패합니다.
|
||||
*
|
||||
* @param string $value 원본 값
|
||||
* @param string $suffix 부착할 접미사
|
||||
* @param int $maxLength 컬럼 최대 길이
|
||||
* @return string 접미사가 부착된 값
|
||||
*/
|
||||
protected function appendWithdrawnSuffix(string $value, string $suffix, int $maxLength): string
|
||||
{
|
||||
$available = $maxLength - mb_strlen($suffix);
|
||||
|
||||
if ($available < 0) {
|
||||
$available = 0;
|
||||
}
|
||||
|
||||
return mb_substr($value, 0, $available).$suffix;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Console\Commands\BenchCommand;
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Contracts\Extension\ModuleSettingsInterface;
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Contracts\Extension\TemplateManagerInterface;
|
||||
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
|
||||
@@ -97,6 +96,7 @@ use App\Services\DriverRegistryService;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use App\Services\TemplateLayoutAttachmentService;
|
||||
use App\Services\UniqueIdService;
|
||||
use App\Support\ExtensionSettingsMirror;
|
||||
use App\Support\PrivilegedDatabaseAccounts;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -739,69 +739,8 @@ class CoreServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected function loadModuleSettingsToConfig(ModuleManager $moduleManager): void
|
||||
{
|
||||
$moduleSettings = [];
|
||||
|
||||
foreach (array_keys($moduleManager->getActiveModules()) as $identifier) {
|
||||
try {
|
||||
// 모듈별 환경설정 서비스 조회
|
||||
$settingsService = $this->resolveModuleSettingsService($identifier);
|
||||
|
||||
if ($settingsService instanceof ModuleSettingsInterface) {
|
||||
$settings = $settingsService->getAllSettings();
|
||||
if (! empty($settings)) {
|
||||
$moduleSettings[$identifier] = $settings;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("모듈 환경설정 로딩 실패: {$identifier}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Config::set('g7_settings.modules', $moduleSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈의 환경설정 서비스를 찾아 인스턴스화합니다.
|
||||
*
|
||||
* 다음 순서로 설정 서비스를 찾습니다:
|
||||
* 1. 인터페이스 바인딩: Modules\Vendor\Module\Contracts\ModuleSettingsServiceInterface
|
||||
* 2. 구체 클래스: Modules\Vendor\Module\Services\ModuleSettingsService
|
||||
*
|
||||
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
|
||||
* @return ModuleSettingsInterface|null 설정 서비스 인스턴스
|
||||
*/
|
||||
protected function resolveModuleSettingsService(string $identifier): ?ModuleSettingsInterface
|
||||
{
|
||||
// vendor-module 형식을 네임스페이스로 변환
|
||||
$parts = explode('-', $identifier);
|
||||
if (count($parts) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$vendor = ucfirst($parts[0]);
|
||||
$moduleName = ucfirst($parts[1]);
|
||||
|
||||
// 1. 인터페이스 바인딩 확인
|
||||
$interfaceClass = "Modules\\{$vendor}\\{$moduleName}\\Contracts\\{$moduleName}SettingsServiceInterface";
|
||||
if ($this->app->bound($interfaceClass)) {
|
||||
$service = $this->app->make($interfaceClass);
|
||||
if ($service instanceof ModuleSettingsInterface) {
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 구체 클래스 확인
|
||||
$concreteClass = "Modules\\{$vendor}\\{$moduleName}\\Services\\{$moduleName}SettingsService";
|
||||
if (class_exists($concreteClass)) {
|
||||
$service = $this->app->make($concreteClass);
|
||||
if ($service instanceof ModuleSettingsInterface) {
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
// 미러 채움 로직은 ExtensionSettingsMirror 가 단일 소유한다 (공개이슈 #109).
|
||||
app(ExtensionSettingsMirror::class)->refreshAllModules();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -814,28 +753,9 @@ class CoreServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected function loadPluginSettingsToConfig(PluginManager $pluginManager): void
|
||||
{
|
||||
$pluginSettings = [];
|
||||
|
||||
foreach (array_keys($pluginManager->getActivePlugins()) as $identifier) {
|
||||
try {
|
||||
$settingsPath = storage_path("app/plugins/{$identifier}/settings/setting.json");
|
||||
|
||||
if (File::exists($settingsPath)) {
|
||||
$content = File::get($settingsPath);
|
||||
$settings = json_decode($content, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && ! empty($settings)) {
|
||||
$pluginSettings[$identifier] = $settings;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("플러그인 환경설정 로딩 실패: {$identifier}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Config::set('g7_settings.plugins', $pluginSettings);
|
||||
// 종전에는 setting.json 을 raw 로 읽어 defaults 병합·정규화가 빠지고 암호문이 그대로
|
||||
// 실렸다 — 값의 형태가 전용 게터와 달랐다. 이제 미러 소유자에 위임한다 (공개이슈 #109).
|
||||
app(ExtensionSettingsMirror::class)->refreshAllPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Providers;
|
||||
|
||||
use App\Repositories\JsonConfigRepository;
|
||||
use App\Support\AllowedExtensions;
|
||||
use App\Support\ExtensionSettingsMirror;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Predis\Client;
|
||||
@@ -16,23 +17,8 @@ use Predis\Client;
|
||||
*/
|
||||
class SettingsServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* 코어 설정 카테고리 목록
|
||||
*/
|
||||
private const CORE_CATEGORIES = [
|
||||
'mail',
|
||||
'general',
|
||||
'security',
|
||||
'debug',
|
||||
'drivers',
|
||||
'cache',
|
||||
'upload',
|
||||
'core_update',
|
||||
'geoip',
|
||||
'seo',
|
||||
'identity',
|
||||
'pagination',
|
||||
];
|
||||
// 코어 설정 카테고리 목록은 ExtensionSettingsMirror::CORE_CATEGORIES 가 단독 소유한다.
|
||||
// 여기에 사본을 두면 미러가 읽는 목록과 갈라져도 아무도 알아채지 못한다.
|
||||
|
||||
/**
|
||||
* 서비스를 등록합니다.
|
||||
@@ -41,6 +27,10 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// 미러 채움 소유자를 컨테이너 싱글톤으로 등록한다 — 부팅/저장/테스트가
|
||||
// 같은 인스턴스를 해석해야 교체(스텁 주입)가 모든 경로에 통한다.
|
||||
$this->app->singleton(ExtensionSettingsMirror::class);
|
||||
|
||||
// JsonConfigRepository를 직접 인스턴스화 (DI 컨테이너 사용 불가)
|
||||
$configRepository = new JsonConfigRepository;
|
||||
|
||||
@@ -67,16 +57,9 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
*/
|
||||
private function loadCoreSettingsToConfig(JsonConfigRepository $configRepository): void
|
||||
{
|
||||
$coreSettings = [];
|
||||
|
||||
foreach (self::CORE_CATEGORIES as $category) {
|
||||
$settings = $configRepository->getCategory($category);
|
||||
if (! empty($settings)) {
|
||||
$coreSettings[$category] = $settings;
|
||||
}
|
||||
}
|
||||
|
||||
Config::set('g7_settings.core', $coreSettings);
|
||||
// 미러 채움 로직은 ExtensionSettingsMirror 가 단일 소유한다 —
|
||||
// 저장 시점 재채움(공개이슈 #109)과 같은 코드를 써야 부팅과 저장의 결과가 갈리지 않는다.
|
||||
$this->app->make(ExtensionSettingsMirror::class)->refreshCore($configRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,6 +171,14 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
// 환경설정의 timezone은 사용자 표시용 기본 타임존
|
||||
// app.timezone(서버 저장 타임존)은 항상 UTC 유지
|
||||
Config::set('app.default_user_timezone', $generalSettings['timezone']);
|
||||
|
||||
// 예약 작업의 시각 해석 기준도 사이트 설정 시간대를 따른다.
|
||||
// Laravel 의 Kernel::scheduleTimezone() 이 이 키를 읽어 Schedule 인스턴스
|
||||
// 전체에 일괄 적용하므로, 코어·확장이 등록한 모든 예약이 같은 기준을 공유한다.
|
||||
// (이벤트마다 ->timezone() 을 붙이는 방식은 나중에 추가되는 예약이
|
||||
// 조용히 UTC 기준으로 돌아가므로 채택하지 않는다.)
|
||||
// 미설정 시에는 Laravel 기본 폴백(app.timezone = UTC)이 그대로 적용된다.
|
||||
Config::set('app.schedule_timezone', $generalSettings['timezone']);
|
||||
}
|
||||
|
||||
if (! empty($generalSettings['language'])) {
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Helpers\PermissionHelper;
|
||||
use App\Helpers\TimezoneHelper;
|
||||
use App\Http\Resources\BaseApiCollection;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Repositories\Concerns\DeletesInBatches;
|
||||
use App\Repositories\Concerns\HasMultipleSearchFilters;
|
||||
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
|
||||
use App\Repositories\Concerns\ResolvesSortSpec;
|
||||
@@ -23,6 +24,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class ActivityLogRepository implements ActivityLogRepositoryInterface
|
||||
{
|
||||
use DeletesInBatches;
|
||||
use HasMultipleSearchFilters;
|
||||
use PaginatesWithDeferredJoin;
|
||||
use ResolvesSortSpec;
|
||||
@@ -316,4 +318,20 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
|
||||
{
|
||||
return ActivityLog::where('user_id', $userId)->update(['user_id' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 활동 로그를 삭제합니다.
|
||||
*
|
||||
* 보존 기간 하한(1일)은 이 계층이 소유한다 — 파기는 되돌릴 수 없으므로 호출자마다
|
||||
* 다시 막지 않고 실제로 지우는 자리에서 한 번 막는다.
|
||||
*
|
||||
* @param int $days 보존 기간 (일)
|
||||
* @return int 삭제된 건수
|
||||
*/
|
||||
public function deleteOlderThan(int $days): int
|
||||
{
|
||||
return $this->deleteInBatches(
|
||||
ActivityLog::where('created_at', '<', now()->subDays(max(1, $days)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* 보존 기간 초과 행을 배치로 나눠 삭제하는 트레이트
|
||||
*
|
||||
* 정리 예약은 도입 직후 첫 실행에서 이미 쌓여 있던 과거 데이터를 전부 지운다.
|
||||
* 수년치가 한 문장으로 삭제되면 그 한 건이 테이블을 오래 잠그고, 트랜잭션 로그도
|
||||
* 그만큼 부풀어 정리 배치가 사이트를 멈추게 한다 — 정리하려던 문제를 정리 작업이
|
||||
* 다시 만드는 셈이다.
|
||||
*
|
||||
* 그래서 기본키 배치로 끊어 지운다. 각 DELETE 는 최대 batchSize 건의 기본키 IN 절이라
|
||||
* 잠금 구간이 짧고, 중간에 중단돼도 이미 지운 만큼은 확정된다(정리는 멱등이라
|
||||
* 다음 실행이 남은 몫을 이어서 처리한다).
|
||||
*/
|
||||
trait DeletesInBatches
|
||||
{
|
||||
/**
|
||||
* 기본 배치 크기
|
||||
*/
|
||||
protected int $deleteBatchSize = 1000;
|
||||
|
||||
/**
|
||||
* 조건에 맞는 행을 배치로 나눠 삭제합니다.
|
||||
*
|
||||
* 기본키를 먼저 모아 그 키로만 지운다 — `DELETE ... LIMIT` 은 드라이버마다 지원이
|
||||
* 갈리지만 기본키 IN 절은 어디서나 같게 동작한다.
|
||||
*
|
||||
* @param Builder $query 삭제 대상 조건이 적용된 쿼리 (정렬·제한 미적용 상태)
|
||||
* @param int|null $batchSize 배치 크기 (미지정 시 $deleteBatchSize)
|
||||
* @return int 삭제된 총 건수
|
||||
*/
|
||||
protected function deleteInBatches(Builder $query, ?int $batchSize = null): int
|
||||
{
|
||||
$batchSize = max(1, $batchSize ?? $this->deleteBatchSize);
|
||||
$model = $query->getModel();
|
||||
$keyName = $model->getKeyName();
|
||||
$total = 0;
|
||||
|
||||
while (true) {
|
||||
// audit:allow query-repeated-execution 배치 삭제는 같은 술어를 의도적으로 반복 평가한다 — 매 회차의 모집단이 직전 삭제로 줄어든다
|
||||
$keys = (clone $query)->orderBy($keyName)->limit($batchSize)->pluck($keyName);
|
||||
|
||||
if ($keys->isEmpty()) {
|
||||
return $total;
|
||||
}
|
||||
|
||||
$total += $model->newQuery()->whereIn($keyName, $keys)->delete();
|
||||
|
||||
// 마지막 배치 — 더 조회할 것이 없다
|
||||
if ($keys->count() < $batchSize) {
|
||||
return $total;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Enums\IdentityVerificationStatus;
|
||||
use App\Helpers\TimezoneHelper;
|
||||
use App\Models\IdentityPolicy;
|
||||
use App\Models\IdentityVerificationLog;
|
||||
use App\Repositories\Concerns\DeletesInBatches;
|
||||
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
|
||||
use App\Support\Query\PaginationLimits;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -19,6 +20,7 @@ use Illuminate\Support\Carbon;
|
||||
*/
|
||||
class IdentityVerificationLogRepository implements IdentityVerificationLogRepositoryInterface
|
||||
{
|
||||
use DeletesInBatches;
|
||||
use PaginatesWithDeferredJoin;
|
||||
|
||||
/**
|
||||
@@ -127,9 +129,10 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
|
||||
*/
|
||||
public function purgeOlderThan(int $days): int
|
||||
{
|
||||
return IdentityVerificationLog::query()
|
||||
->where('created_at', '<', Carbon::now()->subDays(max(1, $days)))
|
||||
->delete();
|
||||
return $this->deleteInBatches(
|
||||
IdentityVerificationLog::query()
|
||||
->where('created_at', '<', Carbon::now()->subDays(max(1, $days)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Repositories\NotificationLogRepositoryInterface;
|
||||
use App\Enums\NotificationLogStatus;
|
||||
use App\Models\NotificationLog;
|
||||
use App\Models\User;
|
||||
use App\Repositories\Concerns\DeletesInBatches;
|
||||
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
|
||||
use App\Repositories\Concerns\ResolvesSortSpec;
|
||||
use App\Support\Query\KeysetPaginator;
|
||||
@@ -17,6 +18,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class NotificationLogRepository implements NotificationLogRepositoryInterface
|
||||
{
|
||||
use DeletesInBatches;
|
||||
use PaginatesWithDeferredJoin;
|
||||
use ResolvesSortSpec;
|
||||
|
||||
@@ -98,6 +100,22 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface
|
||||
return NotificationLog::whereIn('id', $ids)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 발송 이력을 삭제합니다.
|
||||
*
|
||||
* 보존 기간 하한(1일)은 이 계층이 소유한다 — 파기는 되돌릴 수 없으므로 호출자마다
|
||||
* 다시 막지 않고 실제로 지우는 자리에서 한 번 막는다.
|
||||
*
|
||||
* @param int $days 보존 기간 (일)
|
||||
* @return int 삭제된 건수
|
||||
*/
|
||||
public function deleteOlderThan(int $days): int
|
||||
{
|
||||
return $this->deleteInBatches(
|
||||
NotificationLog::where('created_at', '<', now()->subDays(max(1, $days)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지네이션 목록 조회.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Repositories;
|
||||
|
||||
use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface;
|
||||
use App\Models\ScheduleHistory;
|
||||
use App\Repositories\Concerns\DeletesInBatches;
|
||||
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
|
||||
use App\Repositories\Concerns\ResolvesSortSpec;
|
||||
use App\Support\Query\PaginationLimits;
|
||||
@@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
|
||||
{
|
||||
use DeletesInBatches;
|
||||
use PaginatesWithDeferredJoin;
|
||||
use ResolvesSortSpec;
|
||||
|
||||
@@ -169,6 +171,10 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
|
||||
*/
|
||||
public function deleteOlderThan(int $days): int
|
||||
{
|
||||
return ScheduleHistory::where('started_at', '<', now()->subDays($days))->delete();
|
||||
// 보존 기간 하한(1일)은 이 계층이 소유한다 — 파기는 되돌릴 수 없으므로
|
||||
// 호출자마다 다시 막지 않고 실제로 지우는 자리에서 한 번 막는다.
|
||||
return $this->deleteInBatches(
|
||||
ScheduleHistory::where('started_at', '<', now()->subDays(max(1, $days)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Repositories;
|
||||
|
||||
use App\Contracts\Repositories\SeoCacheStatRepositoryInterface;
|
||||
use App\Models\SeoCacheStat;
|
||||
use App\Repositories\Concerns\DeletesInBatches;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Database\Query\Expression;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -16,6 +17,8 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class SeoCacheStatRepository implements SeoCacheStatRepositoryInterface
|
||||
{
|
||||
use DeletesInBatches;
|
||||
|
||||
/**
|
||||
* 그룹 집계에 허용된 컬럼 목록
|
||||
*
|
||||
@@ -96,7 +99,7 @@ class SeoCacheStatRepository implements SeoCacheStatRepositoryInterface
|
||||
*/
|
||||
public function deleteOlderThan(Carbon $cutoff): int
|
||||
{
|
||||
return SeoCacheStat::where('created_at', '<', $cutoff)->delete();
|
||||
return $this->deleteInBatches(SeoCacheStat::where('created_at', '<', $cutoff));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,6 +127,10 @@ class SeoCacheStatsService
|
||||
*/
|
||||
public function cleanup(int $daysToKeep = 30): int
|
||||
{
|
||||
// 보존 기간 하한(1일)은 이 계층이 소유한다 — 파기는 되돌릴 수 없으므로
|
||||
// 호출자마다 다시 막지 않고 실제로 지우는 경로에서 한 번 막는다.
|
||||
$daysToKeep = max(1, $daysToKeep);
|
||||
|
||||
$deleted = $this->statRepository->deleteOlderThan(Carbon::now()->subDays($daysToKeep));
|
||||
|
||||
Log::info('[SEO] 캐시 통계 정리 완료', [
|
||||
|
||||
@@ -83,4 +83,26 @@ class ActivityLogService
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 활동 로그를 정리합니다 (자동 파기).
|
||||
*
|
||||
* 운영자가 고른 ID 를 지우는 deleteMany 와는 별개의 훅을 발행한다. 자동 파기는
|
||||
* 대상을 ID 로 지목하지 않고(기간으로 정한다), 사람이 없는 예약 실행이라
|
||||
* 본인인증 같은 대화형 가드를 태울 수 없기 때문이다. 확장은 이 훅으로
|
||||
* 외부 보관 등 자기 처리를 붙인다.
|
||||
*
|
||||
* @param int $days 보존 기간 (일)
|
||||
* @return int 삭제된 건수
|
||||
*/
|
||||
public function prune(int $days): int
|
||||
{
|
||||
HookManager::doAction('core.activity_log.before_prune', $days);
|
||||
|
||||
$count = $this->repository->deleteOlderThan($days);
|
||||
|
||||
HookManager::doAction('core.activity_log.after_prune', $days, $count);
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
@@ -181,23 +182,40 @@ class AttachmentService
|
||||
$attachmentableId = $attachment->attachmentable_id;
|
||||
$collection = $attachment->collection;
|
||||
|
||||
// Before 훅
|
||||
HookManager::doAction('core.attachment.before_delete', $attachment);
|
||||
// DB 를 먼저 지우고 파일은 커밋 후에 지운다.
|
||||
// 순서를 반대로 두면 DB 삭제가 실패했을 때 파일 없는 행이 남아 다운로드가 404 가 된다.
|
||||
// 반대로 이 순서에서는 최악이어도 참조되지 않는 고아 파일만 남고 데이터는 온전하다.
|
||||
$result = DB::transaction(function () use ($id, $attachment, $attachmentableType, $attachmentableId, $collection) {
|
||||
// Before 훅
|
||||
HookManager::doAction('core.attachment.before_delete', $attachment);
|
||||
|
||||
// 스토리지에서 파일 삭제
|
||||
$this->storage->withDisk($attachment->disk)->delete('', $attachment->path);
|
||||
// DB에서 영구 삭제
|
||||
$deleted = $this->repository->forceDelete($id);
|
||||
|
||||
// DB에서 영구 삭제
|
||||
$result = $this->repository->forceDelete($id);
|
||||
// 삭제 후 남은 파일들의 순서 재정렬
|
||||
if ($deleted && $attachmentableType && $attachmentableId) {
|
||||
$this->repository->reorderAfterDelete($attachmentableType, $attachmentableId, $collection);
|
||||
}
|
||||
|
||||
Log::info('첨부파일 삭제 완료', [
|
||||
'attachment_id' => $id,
|
||||
'hash' => $attachment->hash,
|
||||
]);
|
||||
return $deleted;
|
||||
});
|
||||
|
||||
// 삭제 후 남은 파일들의 순서 재정렬
|
||||
if ($result && $attachmentableType && $attachmentableId) {
|
||||
$this->repository->reorderAfterDelete($attachmentableType, $attachmentableId, $collection);
|
||||
if ($result) {
|
||||
// 커밋 후 스토리지 파일 삭제 (실패해도 DB 는 되돌리지 않는다)
|
||||
try {
|
||||
$this->storage->withDisk($attachment->disk)->delete('', $attachment->path);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('첨부파일 스토리지 삭제 실패 (고아 파일 잔존)', [
|
||||
'attachment_id' => $id,
|
||||
'path' => $attachment->path,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
Log::info('첨부파일 삭제 완료', [
|
||||
'attachment_id' => $id,
|
||||
'hash' => $attachment->hash,
|
||||
]);
|
||||
}
|
||||
|
||||
// After 훅
|
||||
@@ -378,18 +396,29 @@ class AttachmentService
|
||||
// Before 훅
|
||||
HookManager::doAction('core.attachment.before_bulk_delete', $identifier);
|
||||
|
||||
// 해당 첨부파일들의 스토리지 파일 삭제
|
||||
// 삭제 대상 스냅샷 + 파일 경로를 먼저 수집한다 (DB 삭제 후에는 조회할 수 없다).
|
||||
$attachments = $this->repository->getBySourceIdentifier($identifier);
|
||||
$attachmentIds = $attachments->pluck('id')->toArray();
|
||||
$snapshots = $attachments->keyBy('id')->map(fn ($a) => $a->toArray())->toArray();
|
||||
$files = $attachments->map(fn ($a) => ['disk' => $a->disk, 'path' => $a->path])->all();
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
$this->storage->withDisk($attachment->disk)->delete('', $attachment->path);
|
||||
// DB 일괄 삭제를 먼저 커밋한다 — 루프 중간에 실패하면 파일만 일부 사라지고
|
||||
// DB 행은 전부 남아 다운로드가 404 가 되기 때문이다.
|
||||
$count = DB::transaction(fn () => $this->repository->deleteBySourceIdentifier($identifier));
|
||||
|
||||
// 커밋 후 파일 정리 (실패해도 DB 는 되돌리지 않는다 — 고아 파일은 비파괴)
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$this->storage->withDisk($file['disk'])->delete('', $file['path']);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('첨부파일 스토리지 일괄 삭제 실패 (고아 파일 잔존)', [
|
||||
'source_identifier' => $identifier,
|
||||
'path' => $file['path'],
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// DB에서 삭제
|
||||
$count = $this->repository->deleteBySourceIdentifier($identifier);
|
||||
|
||||
Log::info('소스 식별자 기준 첨부파일 일괄 삭제', [
|
||||
'source_identifier' => $identifier,
|
||||
'deleted_count' => $count,
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -347,19 +348,27 @@ class AuthService
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
$user = $this->userRepository->create($userData);
|
||||
// 계정 생성 이후 단계에서 실패하면 이메일만 점유한 유령 계정이 남아
|
||||
// 같은 이메일로 재가입조차 불가능해진다. 생성~토큰 발급을 하나로 묶는다.
|
||||
[$user, $token] = DB::transaction(function () use ($userData, $data, $now) {
|
||||
$user = $this->userRepository->create($userData);
|
||||
|
||||
// 약관 동의 이력 기록
|
||||
$this->recordConsents($user, $data, $now);
|
||||
// 약관 동의 이력 기록
|
||||
$this->recordConsents($user, $data, $now);
|
||||
|
||||
// 'user' 역할 자동 할당 (UserService 패턴과 동일)
|
||||
$userRole = $this->roleRepository->findByIdentifier('user');
|
||||
if ($userRole) {
|
||||
$user->roles()->sync([$userRole->id]);
|
||||
$user->flushPermissionCaches();
|
||||
}
|
||||
// 'user' 역할 자동 할당 (UserService 패턴과 동일)
|
||||
$userRole = $this->roleRepository->findByIdentifier('user');
|
||||
if ($userRole) {
|
||||
$user->roles()->sync([$userRole->id]);
|
||||
}
|
||||
|
||||
$token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
|
||||
$token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
|
||||
|
||||
return [$user, $token];
|
||||
});
|
||||
|
||||
// 커밋 후 부수효과 (캐시는 DB 트랜잭션의 롤백 대상이 아니다)
|
||||
$user->flushPermissionCaches();
|
||||
|
||||
// Hook 발생 (회원가입 완료) — 알림 발송은 NotificationHookListener,
|
||||
// signup_after_create 정책이 enabled 면 InitiateIdentityChallengeAfterRegister 가 challenge 발행.
|
||||
@@ -612,16 +621,20 @@ class AuthService
|
||||
]);
|
||||
}
|
||||
|
||||
// Hook 발생 (비밀번호 재설정 시작)
|
||||
HookManager::doAction('core.auth.before_reset_password', $user);
|
||||
// 비밀번호를 바꾼 뒤 토큰 삭제가 실패하면 그 재설정 토큰이 계속 유효한 채로
|
||||
// 남아 재사용된다(보안). 두 단계를 하나로 묶는다.
|
||||
DB::transaction(function () use ($user, $password, $record) {
|
||||
// Hook 발생 (비밀번호 재설정 시작)
|
||||
HookManager::doAction('core.auth.before_reset_password', $user);
|
||||
|
||||
// 비밀번호 업데이트
|
||||
$this->userRepository->update($user, [
|
||||
'password' => Hash::make($password),
|
||||
]);
|
||||
// 비밀번호 업데이트
|
||||
$this->userRepository->update($user, [
|
||||
'password' => Hash::make($password),
|
||||
]);
|
||||
|
||||
// 사용된 토큰 삭제
|
||||
$record->delete();
|
||||
// 사용된 토큰 삭제
|
||||
$record->delete();
|
||||
});
|
||||
|
||||
// Hook 발생 (비밀번호 변경 완료) — 알림 발송은 NotificationHookListener가 처리
|
||||
HookManager::doAction('core.auth.after_password_changed', $user);
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Menu;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class MenuService
|
||||
@@ -182,13 +183,16 @@ class MenuService
|
||||
]);
|
||||
}
|
||||
|
||||
// Hook: 메뉴 삭제 전
|
||||
HookManager::doAction('core.menu.before_delete', $menu);
|
||||
// 역할 연결만 끊기고 메뉴는 남는 상태를 막기 위해 두 단계를 하나로 묶는다.
|
||||
$result = DB::transaction(function () use ($menu) {
|
||||
// Hook: 메뉴 삭제 전
|
||||
HookManager::doAction('core.menu.before_delete', $menu);
|
||||
|
||||
// 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$menu->roles()->detach();
|
||||
// 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$menu->roles()->detach();
|
||||
|
||||
$result = $this->menuRepository->delete($menu);
|
||||
return $this->menuRepository->delete($menu);
|
||||
});
|
||||
|
||||
// Hook: 메뉴 삭제 후
|
||||
HookManager::doAction('core.menu.after_delete', $menu->id);
|
||||
|
||||
@@ -93,6 +93,27 @@ class NotificationLogService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 발송 이력을 정리합니다 (자동 파기).
|
||||
*
|
||||
* 운영자가 고른 ID 를 지우는 bulkDelete 와는 별개의 훅을 발행한다 — 자동 파기는
|
||||
* 대상을 기간으로 정하고 사람 없이 예약 실행되므로, 대화형 가드를 전제한 훅에
|
||||
* 얹으면 예약이 그 가드에 걸린다. 확장은 이 훅으로 자기 처리를 붙인다.
|
||||
*
|
||||
* @param int $days 보존 기간 (일)
|
||||
* @return int 삭제된 건수
|
||||
*/
|
||||
public function prune(int $days): int
|
||||
{
|
||||
HookManager::doAction('core.notification_log.before_prune', $days);
|
||||
|
||||
$count = $this->repository->deleteOlderThan($days);
|
||||
|
||||
HookManager::doAction('core.notification_log.after_prune', $days, $count);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다건 삭제합니다.
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Support\ExtensionSettingsMirror;
|
||||
use App\Support\SensitiveSettingMask;
|
||||
use App\Traits\FiltersFrontendSchema;
|
||||
use App\Traits\NormalizesSettingsData;
|
||||
@@ -189,6 +190,10 @@ class PluginSettingsService
|
||||
// 캐시 초기화
|
||||
if ($result) {
|
||||
unset($this->settingsCache[$identifier]);
|
||||
|
||||
// 같은 프로세스의 config 미러도 즉시 다시 채운다 (공개이슈 #109) —
|
||||
// 이 호출이 없으면 상주 프로세스가 저장 후에도 옛 값을 계속 읽는다.
|
||||
app(ExtensionSettingsMirror::class)->refreshPlugin($identifier);
|
||||
}
|
||||
|
||||
// After 훅
|
||||
@@ -247,6 +252,10 @@ class PluginSettingsService
|
||||
// 캐시 초기화
|
||||
unset($this->settingsCache[$identifier]);
|
||||
|
||||
// 초기화도 값을 바꾸는 쓰기다 — 미러를 두면 같은 프로세스가 초기화 전 값을
|
||||
// 계속 읽는다 (저장 경로와 동일 결함, 공개이슈 #109).
|
||||
app(ExtensionSettingsMirror::class)->refreshPlugin($identifier);
|
||||
|
||||
// After 훅
|
||||
HookManager::doAction('core.plugin_settings.after_reset', $identifier);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RoleService
|
||||
@@ -183,16 +184,20 @@ class RoleService
|
||||
throw new ExtensionOwnedRoleDeleteException;
|
||||
}
|
||||
|
||||
// 훅: 삭제 전
|
||||
HookManager::doAction('core.role.before_delete', $role);
|
||||
// 관계 해제 후 삭제가 실패하면 그 역할을 가진 사용자 전원이 권한만 잃고
|
||||
// 역할은 그대로 남는다. 전 단계를 하나로 묶는다.
|
||||
$result = DB::transaction(function () use ($role) {
|
||||
// 훅: 삭제 전
|
||||
HookManager::doAction('core.role.before_delete', $role);
|
||||
|
||||
// 관계 해제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$this->roleRepository->detachAllPermissions($role);
|
||||
$role->menus()->detach();
|
||||
$role->users()->detach();
|
||||
// 관계 해제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$this->roleRepository->detachAllPermissions($role);
|
||||
$role->menus()->detach();
|
||||
$role->users()->detach();
|
||||
|
||||
// 역할 삭제
|
||||
$result = $this->roleRepository->delete($role);
|
||||
// 역할 삭제
|
||||
return $this->roleRepository->delete($role);
|
||||
});
|
||||
|
||||
// 훅: 삭제 후
|
||||
HookManager::doAction('core.role.after_delete', $role->id);
|
||||
|
||||
@@ -122,13 +122,16 @@ class ScheduleService
|
||||
public function delete(Schedule $schedule): bool
|
||||
{
|
||||
try {
|
||||
// before_delete 훅 실행
|
||||
HookManager::doAction('core.schedule.before_delete', $schedule);
|
||||
// 이력만 지워지고 스케줄은 남는 상태를 막기 위해 두 단계를 하나로 묶는다.
|
||||
$result = DB::transaction(function () use ($schedule) {
|
||||
// before_delete 훅 실행
|
||||
HookManager::doAction('core.schedule.before_delete', $schedule);
|
||||
|
||||
// 실행 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$schedule->histories()->delete();
|
||||
// 실행 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$schedule->histories()->delete();
|
||||
|
||||
$result = $this->scheduleRepository->delete($schedule);
|
||||
return $this->scheduleRepository->delete($schedule);
|
||||
});
|
||||
|
||||
// after_delete 훅 실행
|
||||
HookManager::doAction('core.schedule.after_delete', $schedule->id);
|
||||
@@ -488,6 +491,26 @@ class ScheduleService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 실행 이력을 정리합니다 (자동 파기).
|
||||
*
|
||||
* 운영자가 고른 ID 를 지우는 bulkDeleteHistory 와 달리 대상을 기간으로 정하고
|
||||
* 사람 없이 예약 실행되므로 별개의 훅을 발행합니다.
|
||||
*
|
||||
* @param int $days 보존 기간 (일)
|
||||
* @return int 삭제된 건수
|
||||
*/
|
||||
public function pruneHistory(int $days): int
|
||||
{
|
||||
HookManager::doAction('core.schedule.before_prune_history', $days);
|
||||
|
||||
$deletedCount = $this->historyRepository->deleteOlderThan($days);
|
||||
|
||||
HookManager::doAction('core.schedule.after_prune_history', $days, $deletedCount);
|
||||
|
||||
return $deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 스케줄들을 조회합니다.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Extension\HookManager;
|
||||
use App\Http\Resources\AttachmentResource;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use App\Support\ExtensionSettingsMirror;
|
||||
use App\Support\OpcacheStatus;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -43,6 +44,11 @@ class SettingsService
|
||||
{
|
||||
$this->cache->forget('settings.system');
|
||||
ConfigCacheHelper::rebuild();
|
||||
|
||||
// 같은 프로세스의 in-memory 미러도 즉시 다시 채운다 (공개이슈 #109).
|
||||
// 이 호출이 없으면 상주 프로세스(큐 워커·schedule:work·Reverb)는 저장 후에도
|
||||
// 부팅 시점의 옛 값을 영원히 읽는다 — FPM 에서만 드러나지 않는 결함이다.
|
||||
app(ExtensionSettingsMirror::class)->refreshCore();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -703,7 +709,9 @@ class SettingsService
|
||||
$result = $this->configRepository->set($key, $value);
|
||||
|
||||
if ($result) {
|
||||
$this->cache->forget('settings.system');
|
||||
// 인라인 무효화 대신 공통 경로를 탄다 — saveSettings/saveAdvancedSettings 와
|
||||
// 같은 처리를 받아야 미러 재채움·디스크 config 캐시 재생성이 빠지지 않는다.
|
||||
$this->invalidateSettingsCache();
|
||||
}
|
||||
|
||||
// After 훅
|
||||
@@ -738,7 +746,9 @@ class SettingsService
|
||||
$result = $this->configRepository->restore($backupPath);
|
||||
|
||||
if ($result) {
|
||||
$this->cache->forget('settings.system');
|
||||
// 복원도 설정 전체를 갈아엎는 쓰기다 — 캐시만 비우고 미러를 두면
|
||||
// 같은 프로세스가 복원 전 값을 계속 읽는다 (저장 경로와 동일 결함, 공개이슈 #109).
|
||||
$this->invalidateSettingsCache();
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
+255
-52
@@ -10,6 +10,7 @@ use App\Extension\HookManager;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Helpers\TimezoneHelper;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UserService
|
||||
@@ -170,6 +172,23 @@ class UserService
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자가 상태만 '탈퇴'로 바꾸는 경로도 정식 탈퇴 로직으로 통일한다.
|
||||
//
|
||||
// 여기서 status/withdrawn_at 을 먼저 저장하면 withdraw() 의 멱등 가드가
|
||||
// 이미 탈퇴한 계정으로 판정해 익명화와 훅이 조용히 생략된다.
|
||||
// 따라서 상태 필드는 제거하고, 다른 필드 갱신을 마친 뒤 withdrawUser() 에 위임한다.
|
||||
$withdrawViaStatus = isset($data['status'])
|
||||
&& $data['status'] === UserStatus::Withdrawn->value
|
||||
&& ! $user->isWithdrawn();
|
||||
|
||||
if ($withdrawViaStatus) {
|
||||
unset($data['status']);
|
||||
|
||||
// 차단 대상(관리자/수퍼관리자)이면 다른 필드도 저장되지 않아야 하므로
|
||||
// 갱신 전에 가드를 평가한다.
|
||||
$this->assertWithdrawable($user);
|
||||
}
|
||||
|
||||
// 상태 변경 감지 및 타임스탬프 자동 설정
|
||||
$oldStatus = $user->status;
|
||||
$newStatus = $data['status'] ?? null;
|
||||
@@ -178,7 +197,8 @@ class UserService
|
||||
$newStatusEnum = UserStatus::from($newStatus);
|
||||
$data = match ($newStatusEnum) {
|
||||
UserStatus::Blocked => array_merge($data, ['blocked_at' => now()]),
|
||||
UserStatus::Withdrawn => array_merge($data, ['withdrawn_at' => now()]),
|
||||
// 탈퇴는 위 위임 경로(withdrawUser)가 전담하므로 여기로는 도달하지 않는다.
|
||||
UserStatus::Withdrawn => $data,
|
||||
UserStatus::Active => array_merge($data, ['blocked_at' => null, 'withdrawn_at' => null]),
|
||||
UserStatus::Inactive => $data,
|
||||
};
|
||||
@@ -187,22 +207,45 @@ class UserService
|
||||
// 스냅샷 캡처 (ChangeDetector용)
|
||||
$snapshot = $user->toArray();
|
||||
|
||||
$this->userRepository->update($user, $data);
|
||||
// 탈퇴 위임 시 아바타는 커밋 후에 지운다 (트랜잭션 안에서 지우면 롤백돼도 파일은 안 돌아온다).
|
||||
$avatarAttachment = $withdrawViaStatus ? $user->avatarAttachment : null;
|
||||
|
||||
// 상태가 Active 외로 변경되었으면 토큰 삭제 (즉시 로그아웃)
|
||||
if ($newStatus && $newStatus !== $oldStatus && $newStatus !== UserStatus::Active->value) {
|
||||
$user->tokens()->delete();
|
||||
}
|
||||
// 프로필 갱신·토큰 정리·역할 동기화가 서로 어긋나지 않도록 하나로 묶는다.
|
||||
//
|
||||
// 상태 변경 경유 탈퇴는 **탈퇴까지 같은 트랜잭션**에 넣는다 — 프로필 갱신을
|
||||
// 먼저 커밋하고 탈퇴를 따로 커밋하면, 탈퇴가 실패했을 때 이름·이메일 변경만
|
||||
// 남아 "전부 성공하거나 전부 취소" 가 그 경로에서만 깨진다.
|
||||
DB::transaction(function () use ($user, $data, $newStatus, $oldStatus, $roleIds, $withdrawViaStatus) {
|
||||
$this->userRepository->update($user, $data);
|
||||
|
||||
// 역할 동기화
|
||||
// 상태가 Active 외로 변경되었으면 토큰 삭제 (즉시 로그아웃)
|
||||
if ($newStatus && $newStatus !== $oldStatus && $newStatus !== UserStatus::Active->value) {
|
||||
$user->tokens()->delete();
|
||||
}
|
||||
|
||||
// 역할 동기화
|
||||
if ($roleIds !== null) {
|
||||
$user->roles()->sync($roleIds);
|
||||
}
|
||||
|
||||
// 익명화 + before_withdraw 훅 + 연계 데이터 정리 (본인 탈퇴와 동일 코드)
|
||||
if ($withdrawViaStatus) {
|
||||
$this->performWithdrawWrites($user);
|
||||
}
|
||||
});
|
||||
|
||||
// 커밋 후 부수효과 (캐시는 롤백 대상이 아니다)
|
||||
if ($roleIds !== null) {
|
||||
$user->roles()->sync($roleIds);
|
||||
$user->flushPermissionCaches();
|
||||
}
|
||||
|
||||
// After 훅: 사용자 객체와 원본 데이터, 스냅샷 전달
|
||||
HookManager::doAction('core.user.after_update', $user, $originalData, $snapshot);
|
||||
|
||||
if ($withdrawViaStatus) {
|
||||
$this->runWithdrawAfterEffects($user, $avatarAttachment);
|
||||
}
|
||||
|
||||
// 비밀번호 변경 시 알림 훅 발화 — 발송은 NotificationHookListener가 처리
|
||||
if ($passwordChanged) {
|
||||
HookManager::doAction('core.auth.after_password_changed', $user);
|
||||
@@ -214,12 +257,93 @@ class UserService
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($e instanceof CannotDeleteSuperAdminException) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'general' => [__('user.update_failed', ['error' => $e->getMessage()])],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탈퇴 가능한 계정인지 검사합니다.
|
||||
*
|
||||
* 탈퇴 경로(본인 탈퇴 / 관리자 상태 변경 / 일괄 상태 변경)가 모두 같은 기준을
|
||||
* 쓰도록 한 곳에 둡니다 — 한쪽만 느슨하면 그 경로가 우회로가 됩니다.
|
||||
*
|
||||
* @param User $user 검사 대상 사용자
|
||||
*
|
||||
* @throws CannotDeleteSuperAdminException 슈퍼 관리자인 경우
|
||||
* @throws ValidationException 관리자 역할을 가진 경우
|
||||
*/
|
||||
private function assertWithdrawable(User $user): void
|
||||
{
|
||||
// 슈퍼 관리자는 탈퇴 불가
|
||||
if ($user->isSuperAdmin()) {
|
||||
throw new CannotDeleteSuperAdminException;
|
||||
}
|
||||
|
||||
// 관리자 역할을 가진 계정은 탈퇴 방지
|
||||
if ($user->isAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'general' => [__('user.withdraw_admin_forbidden')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탈퇴의 DB 변경만 수행합니다 (호출자가 연 트랜잭션 안에서 실행).
|
||||
*
|
||||
* 관리자가 상태 변경으로 탈퇴시키는 경로는 프로필 갱신과 탈퇴를 **한 트랜잭션**으로
|
||||
* 묶어야 한다 — 두 번에 나눠 커밋하면 탈퇴가 실패했을 때 프로필 변경만 남는다.
|
||||
* 그래서 DB 변경분을 이 메서드로 떼어 두 경로가 같은 코드를 공유한다.
|
||||
*
|
||||
* @param User $user 탈퇴 처리할 사용자 모델
|
||||
* @return bool 탈퇴 처리 성공 여부
|
||||
*/
|
||||
private function performWithdrawWrites(User $user): bool
|
||||
{
|
||||
// 훅 실행 (탈퇴 전)
|
||||
HookManager::doAction('core.user.before_withdraw', $user);
|
||||
|
||||
// 약관 동의 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$user->consents()->delete();
|
||||
|
||||
// 토큰 삭제 (로그아웃 처리)
|
||||
$user->tokens()->delete();
|
||||
|
||||
// 탈퇴 처리 (suffix 추가 및 상태 변경)
|
||||
return $user->withdraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* 탈퇴 커밋 후 부수효과를 수행합니다.
|
||||
*
|
||||
* @param User $user 탈퇴 처리된 사용자 모델
|
||||
* @param Attachment|null $avatarAttachment 탈퇴 전 캡처한 아바타 첨부
|
||||
*/
|
||||
private function runWithdrawAfterEffects(User $user, $avatarAttachment): void
|
||||
{
|
||||
// 아바타 파일 삭제는 실패해도 탈퇴를 되돌리지 않는다
|
||||
// (고아 파일은 비파괴이고, 탈퇴를 되살리는 쪽이 훨씬 해롭다).
|
||||
if ($avatarAttachment) {
|
||||
try {
|
||||
$this->attachmentService->delete($avatarAttachment->id);
|
||||
} catch (Exception $e) {
|
||||
Log::warning('탈퇴 아바타 삭제 실패', [
|
||||
'user_id' => $user->id,
|
||||
'attachment_id' => $avatarAttachment->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 훅 실행 (탈퇴 후)
|
||||
HookManager::doAction('core.user.after_withdraw', $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자를 탈퇴 처리합니다.
|
||||
*
|
||||
@@ -235,39 +359,19 @@ class UserService
|
||||
public function withdrawUser(User $user): bool
|
||||
{
|
||||
try {
|
||||
// 슈퍼 관리자는 탈퇴 불가
|
||||
if ($user->isSuperAdmin()) {
|
||||
throw new CannotDeleteSuperAdminException;
|
||||
}
|
||||
// 슈퍼 관리자 / 관리자 탈퇴 차단 (전 탈퇴 경로 공통 기준)
|
||||
$this->assertWithdrawable($user);
|
||||
|
||||
// 관리자 역할을 가진 계정은 탈퇴 방지
|
||||
if ($user->isAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'general' => [__('user.withdraw_admin_forbidden')],
|
||||
]);
|
||||
}
|
||||
|
||||
// 훅 실행 (탈퇴 전)
|
||||
HookManager::doAction('core.user.before_withdraw', $user);
|
||||
|
||||
// 약관 동의 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$user->consents()->delete();
|
||||
|
||||
// 아바타 Attachment 삭제 (다형성 관계)
|
||||
// 아바타는 파일 삭제를 동반하므로 트랜잭션 밖(커밋 후)에서 처리한다 —
|
||||
// 트랜잭션 안에서 지우면 롤백되어도 파일은 돌아오지 않는다.
|
||||
$avatarAttachment = $user->avatarAttachment;
|
||||
if ($avatarAttachment) {
|
||||
$this->attachmentService->delete($avatarAttachment->id);
|
||||
}
|
||||
|
||||
// 토큰 삭제 (로그아웃 처리)
|
||||
$user->tokens()->delete();
|
||||
// DB 변경은 전부 성공하거나 전부 취소된다 (공개이슈 #112).
|
||||
// 마지막 단계인 withdraw() 가 실패하면 동의 이력·토큰 삭제도 함께 되돌아간다.
|
||||
$result = DB::transaction(fn () => $this->performWithdrawWrites($user));
|
||||
|
||||
// 탈퇴 처리 (suffix 추가 및 상태 변경)
|
||||
$result = $user->withdraw();
|
||||
|
||||
// 훅 실행 (탈퇴 후)
|
||||
if ($result) {
|
||||
HookManager::doAction('core.user.after_withdraw', $user);
|
||||
$this->runWithdrawAfterEffects($user, $avatarAttachment);
|
||||
}
|
||||
|
||||
return $result;
|
||||
@@ -310,27 +414,42 @@ class UserService
|
||||
// 삭제 전 사용자 데이터 보관 (after_delete 훅에서 사용)
|
||||
$userData = $user->only(['id', 'uuid', 'name', 'email']);
|
||||
|
||||
// Before 훅
|
||||
HookManager::doAction('core.user.before_delete', $user);
|
||||
// 아바타는 파일 삭제를 동반하므로 커밋 후에 처리한다 (롤백되어도 파일은 안 돌아온다).
|
||||
$avatarAttachment = $user->avatarAttachment;
|
||||
|
||||
// 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$user->roles()->detach();
|
||||
// 마지막 delete 가 FK 등으로 실패하면 역할·동의·토큰만 사라지고 계정은 남는
|
||||
// 껍데기 활성 계정이 된다 (실회귀 이력 존재). 전 단계를 하나로 묶는다.
|
||||
$result = DB::transaction(function () use ($user) {
|
||||
// Before 훅
|
||||
HookManager::doAction('core.user.before_delete', $user);
|
||||
|
||||
// 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$user->roles()->detach();
|
||||
|
||||
// 약관 동의 이력 삭제
|
||||
$user->consents()->delete();
|
||||
|
||||
// API 토큰 삭제
|
||||
$user->tokens()->delete();
|
||||
|
||||
return $this->userRepository->delete($user);
|
||||
});
|
||||
|
||||
// 커밋 성공 후 부수효과
|
||||
$user->flushPermissionCaches();
|
||||
|
||||
// 약관 동의 이력 삭제
|
||||
$user->consents()->delete();
|
||||
|
||||
// API 토큰 삭제
|
||||
$user->tokens()->delete();
|
||||
|
||||
// 아바타 Attachment 삭제 (다형성 관계)
|
||||
$avatarAttachment = $user->avatarAttachment;
|
||||
if ($avatarAttachment) {
|
||||
$this->attachmentService->delete($avatarAttachment->id);
|
||||
try {
|
||||
$this->attachmentService->delete($avatarAttachment->id);
|
||||
} catch (Exception $e) {
|
||||
Log::warning('사용자 삭제 아바타 정리 실패', [
|
||||
'user_id' => $userData['id'] ?? null,
|
||||
'attachment_id' => $avatarAttachment->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->userRepository->delete($user);
|
||||
|
||||
// After 훅: 삭제된 사용자 데이터 전달
|
||||
HookManager::doAction('core.user.after_delete', $userData);
|
||||
|
||||
@@ -579,6 +698,12 @@ class UserService
|
||||
|
||||
$statusEnum = UserStatus::from($status);
|
||||
|
||||
// 일괄 '탈퇴'는 건별 정식 탈퇴로 전환한다 — 상태 컬럼만 바꾸면 익명화와
|
||||
// before/after_withdraw 훅이 통째로 생략되어, 본인 탈퇴와 결과가 달라진다.
|
||||
if ($statusEnum === UserStatus::Withdrawn) {
|
||||
return $this->bulkWithdraw($uuids, $status);
|
||||
}
|
||||
|
||||
// UUID → 정수 ID 변환 (내부 쿼리용)
|
||||
$userIds = $this->userRepository->getIdsByUuids($uuids);
|
||||
|
||||
@@ -588,7 +713,8 @@ class UserService
|
||||
$updateData = ['status' => $statusEnum->value];
|
||||
$updateData = match ($statusEnum) {
|
||||
UserStatus::Blocked => array_merge($updateData, ['blocked_at' => now()]),
|
||||
UserStatus::Withdrawn => array_merge($updateData, ['withdrawn_at' => now()]),
|
||||
// 탈퇴는 위에서 건별 withdrawUser() 로 분기하므로 여기로는 도달하지 않는다.
|
||||
UserStatus::Withdrawn => $updateData,
|
||||
UserStatus::Active => array_merge($updateData, ['blocked_at' => null, 'withdrawn_at' => null]),
|
||||
UserStatus::Inactive => $updateData,
|
||||
};
|
||||
@@ -608,6 +734,83 @@ class UserService
|
||||
|
||||
return [
|
||||
'updated_count' => $updatedCount,
|
||||
'failed_count' => 0,
|
||||
'failed_reasons' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 탈퇴 실패 예외를 운영자에게 보여줄 한 줄 사유로 바꿉니다.
|
||||
*
|
||||
* @param Exception $e 탈퇴 시도에서 발생한 예외
|
||||
* @return string 운영자에게 보여줄 실패 사유
|
||||
*/
|
||||
private function describeWithdrawFailure(Exception $e): string
|
||||
{
|
||||
if ($e instanceof ValidationException) {
|
||||
$first = collect($e->errors())->flatten()->first();
|
||||
|
||||
if (is_string($first) && $first !== '') {
|
||||
return $first;
|
||||
}
|
||||
}
|
||||
|
||||
if ($e instanceof CannotDeleteSuperAdminException) {
|
||||
return __('exceptions.cannot_delete_super_admin');
|
||||
}
|
||||
|
||||
// 알 수 없는 예외의 원문 메시지는 사유로 쓰지 않는다 — 내부 사정(SQL·경로·클래스명)이
|
||||
// 운영자 화면으로 새고, 화면 문구 조립에서 파라미터 구분자로 쓰이는 문자(`|` `&` `=`)가
|
||||
// 섞이면 안내가 잘린다. 사유는 언어 파일이 소유하는 문구로만 구성한다.
|
||||
return __('user.withdraw_failed_unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 탈퇴를 건별 정식 탈퇴로 수행합니다.
|
||||
*
|
||||
* @param array<int, string> $uuids 대상 사용자 UUID 배열
|
||||
* @param string $status 요청 상태값 (훅 전달용)
|
||||
* @return array{updated_count: int, failed_count: int, failed_reasons: array<int, string>} 처리 결과
|
||||
*/
|
||||
private function bulkWithdraw(array $uuids, string $status): array
|
||||
{
|
||||
$updatedCount = 0;
|
||||
$failedCount = 0;
|
||||
$failedReasons = [];
|
||||
|
||||
foreach ($uuids as $uuid) {
|
||||
$user = $this->userRepository->findByUuid($uuid);
|
||||
|
||||
if (! $user) {
|
||||
$failedCount++;
|
||||
$failedReasons[] = __('user.not_found');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->withdrawUser($user);
|
||||
$updatedCount++;
|
||||
} catch (Exception $e) {
|
||||
$failedCount++;
|
||||
$failedReasons[] = $this->describeWithdrawFailure($e);
|
||||
|
||||
Log::warning('일괄 탈퇴 건 실패', [
|
||||
'uuid' => $uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// after_bulk_update 훅 실행
|
||||
HookManager::doAction('sirsoft-core.user.after_bulk_update', $uuids, $status, $updatedCount);
|
||||
|
||||
// 사유를 함께 돌려주지 않으면 화면은 "몇 건 실패" 까지만 말할 수 있다 —
|
||||
// 운영자는 왜 안 됐는지 알 방법이 없어 같은 조작을 반복하게 된다.
|
||||
return [
|
||||
'updated_count' => $updatedCount,
|
||||
'failed_count' => $failedCount,
|
||||
'failed_reasons' => array_values(array_unique($failedReasons)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,13 @@ use Illuminate\Support\Facades\Log;
|
||||
* 한 번 비워진 뒤 재생성되지 않아 성능 이점이 영구히 사라진다 — 이를 단일 SSoT 로 막는다.
|
||||
*
|
||||
* 정책: 환경 무관 항상 재생성. config:cache 는 그 자체로 부팅 비용을
|
||||
* 절감하고, G7 설정은 config 캐시에 박제되지 않고 매 요청 SettingsServiceProvider /
|
||||
* CoreServiceProvider 의 런타임 Config::set() 으로 재주입되므로(설정 stale 없음),
|
||||
* 항상 켜두는 것이 이득이다. local 개발 시 config/*.php 수정이 즉시 반영되지 않는 점은
|
||||
* 절감하고, G7 설정은 config 캐시에 박제되지 않고 부팅 때 SettingsServiceProvider /
|
||||
* CoreServiceProvider 의 런타임 Config::set() 으로 재주입되므로 항상 켜두는 것이 이득이다.
|
||||
*
|
||||
* 주의: "매 요청 재주입되므로 stale 없음" 은 FPM 전제였다. 큐 워커·schedule:work·Reverb
|
||||
* 처럼 프로세스가 상주하는 환경에서는 부팅이 한 번뿐이라 저장 후에도 옛 값이 남는다.
|
||||
* 그래서 저장 경로가 `ExtensionSettingsMirror` 로 in-memory 미러를 직접 다시 채운다
|
||||
* (docs/backend/admin-settings-access.md "config 미러 갱신 시점" 참조). local 개발 시 config/*.php 수정이 즉시 반영되지 않는 점은
|
||||
* 개발자가 `php artisan config:clear` 로 대응하는 개발자 책임 영역이다.
|
||||
*/
|
||||
class ConfigCacheHelper
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Contracts\Extension\ModuleSettingsInterface;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Repositories\JsonConfigRepository;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 설정 config 미러(`g7_settings.*`) 관리자
|
||||
*
|
||||
* `g7_core_settings()` / `g7_module_settings()` / `g7_plugin_settings()` 가 읽는
|
||||
* in-memory config 미러를 채웁니다.
|
||||
*
|
||||
* 종전에는 이 미러가 **부팅 때 한 번만** 채워졌습니다. FPM 은 요청마다 부팅하므로
|
||||
* 문제가 드러나지 않지만, 큐 워커·schedule:work·Reverb 처럼 프로세스가 상주하는
|
||||
* 환경에서는 관리자가 설정을 저장해도 그 프로세스의 미러가 영원히 옛 값으로 남습니다
|
||||
* (공개이슈 #109). 저장 경로에서 이 클래스의 refresh* 를 호출해 같은 프로세스의
|
||||
* 미러를 즉시 다시 채웁니다.
|
||||
*
|
||||
* 민감 항목은 미러에 담지 않습니다 — 민감값이 필요한 서버 코드는 전용 게터
|
||||
* (`plugin_setting()` 등)를 씁니다. 자세한 근거는 docs/backend/admin-settings-access.md 참조.
|
||||
*/
|
||||
class ExtensionSettingsMirror
|
||||
{
|
||||
/**
|
||||
* 코어 설정 카테고리 목록
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const CORE_CATEGORIES = [
|
||||
'mail',
|
||||
'general',
|
||||
'security',
|
||||
'debug',
|
||||
'drivers',
|
||||
'cache',
|
||||
'upload',
|
||||
'core_update',
|
||||
'geoip',
|
||||
'seo',
|
||||
'identity',
|
||||
'pagination',
|
||||
];
|
||||
|
||||
/**
|
||||
* 코어 설정 미러(`g7_settings.core`)를 다시 채웁니다.
|
||||
*
|
||||
* @param JsonConfigRepository|null $configRepository 설정 저장소 (미지정 시 새로 생성)
|
||||
*/
|
||||
public function refreshCore(?JsonConfigRepository $configRepository = null): void
|
||||
{
|
||||
// 이 메서드는 SettingsServiceProvider::register() 에서도 호출된다 —
|
||||
// 그 시점은 DI 컨테이너 사용 전이므로 직접 인스턴스화가 유일한 선택지다.
|
||||
$configRepository ??= new JsonConfigRepository;
|
||||
|
||||
$coreSettings = [];
|
||||
|
||||
foreach (self::CORE_CATEGORIES as $category) {
|
||||
$settings = $configRepository->getCategory($category);
|
||||
|
||||
if (! empty($settings)) {
|
||||
$coreSettings[$category] = $settings;
|
||||
}
|
||||
}
|
||||
|
||||
Config::set('g7_settings.core', $coreSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 모듈 전체의 설정 미러를 다시 채웁니다.
|
||||
*/
|
||||
public function refreshAllModules(): void
|
||||
{
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
$moduleSettings = [];
|
||||
|
||||
foreach (array_keys($moduleManager->getActiveModules()) as $identifier) {
|
||||
$settings = $this->resolveModuleSettings($identifier);
|
||||
|
||||
if (! empty($settings)) {
|
||||
$moduleSettings[$identifier] = $settings;
|
||||
}
|
||||
}
|
||||
|
||||
Config::set('g7_settings.modules', $moduleSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 하나의 설정 미러를 다시 채웁니다.
|
||||
*
|
||||
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
|
||||
*/
|
||||
public function refreshModule(string $identifier): void
|
||||
{
|
||||
$settings = $this->resolveModuleSettings($identifier);
|
||||
|
||||
if (empty($settings)) {
|
||||
Config::set('g7_settings.modules.'.$identifier, []);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Config::set('g7_settings.modules.'.$identifier, $settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 플러그인 전체의 설정 미러를 다시 채웁니다.
|
||||
*/
|
||||
public function refreshAllPlugins(): void
|
||||
{
|
||||
$pluginManager = app(PluginManager::class);
|
||||
$pluginSettings = [];
|
||||
|
||||
foreach (array_keys($pluginManager->getActivePlugins()) as $identifier) {
|
||||
$settings = $this->resolvePluginSettings($identifier);
|
||||
|
||||
if (! empty($settings)) {
|
||||
$pluginSettings[$identifier] = $settings;
|
||||
}
|
||||
}
|
||||
|
||||
Config::set('g7_settings.plugins', $pluginSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 하나의 설정 미러를 다시 채웁니다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자 (예: sirsoft-gdpr)
|
||||
*/
|
||||
public function refreshPlugin(string $identifier): void
|
||||
{
|
||||
Config::set('g7_settings.plugins.'.$identifier, $this->resolvePluginSettings($identifier));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 설정 서비스에서 미러에 담을 값을 만듭니다.
|
||||
*
|
||||
* 확장 하나가 실패해도 부팅/저장을 중단시키지 않습니다.
|
||||
*
|
||||
* @param string $identifier 모듈 식별자
|
||||
* @return array<string, mixed> 미러 값 (민감 항목 제외)
|
||||
*/
|
||||
private function resolveModuleSettings(string $identifier): array
|
||||
{
|
||||
try {
|
||||
$settingsService = $this->resolveModuleSettingsService($identifier);
|
||||
|
||||
if (! $settingsService instanceof ModuleSettingsInterface) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->stripSensitive(
|
||||
$settingsService->getAllSettings(),
|
||||
$this->resolveModuleSchema($identifier),
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("모듈 환경설정 로딩 실패: {$identifier}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정에서 미러에 담을 값을 만듭니다.
|
||||
*
|
||||
* 종전에는 setting.json 을 raw 로 읽어 defaults 병합도 정규화도 없었고,
|
||||
* 암호문이 그대로 실렸습니다. 이제 PluginSettingsService 를 경유해 값의 형태를
|
||||
* 전용 게터와 맞추되, 민감 항목은 **키 자체를 담지 않습니다**.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @return array<string, mixed> 미러 값 (민감 항목 제외)
|
||||
*/
|
||||
private function resolvePluginSettings(string $identifier): array
|
||||
{
|
||||
try {
|
||||
$pluginManager = app(PluginManager::class);
|
||||
$plugin = $pluginManager->getPlugin($identifier);
|
||||
|
||||
if (! $plugin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$settings = app(PluginSettingsService::class)->get($identifier);
|
||||
|
||||
if (! is_array($settings)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->stripSensitive($settings, $plugin->getSettingsSchema());
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("플러그인 환경설정 로딩 실패: {$identifier}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스키마에서 `sensitive: true` 로 선언된 필드를 제거합니다.
|
||||
*
|
||||
* 미러는 봇 전용 화면 생성기가 키 제한 없이 조회하는 경로이므로, 민감값이 실리면
|
||||
* 레이아웃이 그 키를 참조하는 순간 평문이 봇 HTML 로 나갑니다. 브라우저 경로는
|
||||
* 애초에 제외 중이라 화면만 봐서는 드러나지 않는 비대칭입니다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 설정 배열
|
||||
* @param array<string, mixed> $schema 설정 스키마
|
||||
* @return array<string, mixed> 민감 항목이 제거된 설정
|
||||
*/
|
||||
private function stripSensitive(array $settings, array $schema): array
|
||||
{
|
||||
foreach ($schema as $field => $config) {
|
||||
if (! is_array($config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($config['sensitive'] ?? false) {
|
||||
unset($settings[$field]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 카테고리형 스키마 (category.field) 지원
|
||||
if (! isset($settings[$field]) || ! is_array($settings[$field])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nested = array_filter($config, 'is_array');
|
||||
|
||||
if ($nested !== []) {
|
||||
$settings[$field] = $this->stripSensitive($settings[$field], $nested);
|
||||
}
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈의 설정 스키마를 조회합니다.
|
||||
*
|
||||
* @param string $identifier 모듈 식별자
|
||||
* @return array<string, mixed> 설정 스키마 (없으면 빈 배열)
|
||||
*/
|
||||
private function resolveModuleSchema(string $identifier): array
|
||||
{
|
||||
$module = app(ModuleManager::class)->getModule($identifier);
|
||||
|
||||
if (! $module || ! method_exists($module, 'getSettingsSchema')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$schema = $module->getSettingsSchema();
|
||||
|
||||
return is_array($schema) ? $schema : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈의 환경설정 서비스를 찾아 인스턴스화합니다.
|
||||
*
|
||||
* 다음 순서로 설정 서비스를 찾습니다:
|
||||
* 1. 인터페이스 바인딩: Modules\Vendor\Module\Contracts\ModuleSettingsServiceInterface
|
||||
* 2. 구체 클래스: Modules\Vendor\Module\Services\ModuleSettingsService
|
||||
*
|
||||
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
|
||||
* @return ModuleSettingsInterface|null 설정 서비스 인스턴스
|
||||
*/
|
||||
private function resolveModuleSettingsService(string $identifier): ?ModuleSettingsInterface
|
||||
{
|
||||
// vendor-module 형식을 네임스페이스로 변환
|
||||
$parts = explode('-', $identifier);
|
||||
|
||||
if (count($parts) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$vendor = ucfirst($parts[0]);
|
||||
$moduleName = ucfirst($parts[1]);
|
||||
|
||||
// 1. 인터페이스 바인딩 확인
|
||||
$interfaceClass = "Modules\\{$vendor}\\{$moduleName}\\Contracts\\{$moduleName}SettingsServiceInterface";
|
||||
|
||||
if (app()->bound($interfaceClass)) {
|
||||
$service = app($interfaceClass);
|
||||
|
||||
if ($service instanceof ModuleSettingsInterface) {
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 구체 클래스 확인
|
||||
$concreteClass = "Modules\\{$vendor}\\{$moduleName}\\Services\\{$moduleName}SettingsService";
|
||||
|
||||
if (class_exists($concreteClass)) {
|
||||
$service = app($concreteClass);
|
||||
|
||||
if ($service instanceof ModuleSettingsInterface) {
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ return [
|
||||
'notification:cleanup' => ['options' => []],
|
||||
'layout-previews:cleanup' => ['options' => []],
|
||||
'ext-bundles:cleanup' => ['options' => []],
|
||||
'seo:prune-stats' => ['options' => ['days']],
|
||||
'schedules:prune-history' => ['options' => ['days']],
|
||||
'identity:expire-challenges' => ['options' => []],
|
||||
'identity:prune-logs' => ['options' => ['days']],
|
||||
'activity-log:prune' => ['options' => ['days']],
|
||||
'notification-log:prune' => ['options' => ['days']],
|
||||
|
||||
// SEO
|
||||
'seo:warmup' => ['options' => ['layout']],
|
||||
|
||||
@@ -94,6 +94,40 @@ g7_core_settings('drivers.queue_driver'); // dev 공유 drivers.json 의 datab
|
||||
|
||||
---
|
||||
|
||||
## config 미러 갱신 시점 (부팅 + 저장)
|
||||
|
||||
`g7_settings.core` / `g7_settings.modules.{id}` / `g7_settings.plugins.{id}` 는 in-memory config 미러다. `g7_core_settings()` / `g7_module_settings()` / `g7_plugin_settings()` 가 이 미러를 읽는다.
|
||||
|
||||
미러는 **부팅 때와 저장 때** 모두 채워진다. 채움 로직은 `App\Support\ExtensionSettingsMirror` 가 단일 소유하며, Provider 와 저장 경로가 같은 코드를 호출한다.
|
||||
|
||||
| 축 | 채우는 지점 |
|
||||
|----|-----------|
|
||||
| 코어 | 부팅 `SettingsServiceProvider::register()` / 저장 `SettingsService::invalidateSettingsCache()` |
|
||||
| 모듈 | 부팅 `CoreServiceProvider` / 저장 각 모듈 SettingsService 의 캐시 초기화 지점에서 `g7_refresh_module_settings_config($id)` |
|
||||
| 플러그인 | 부팅 `CoreServiceProvider` / 저장 `PluginSettingsService::save()` |
|
||||
|
||||
부팅만으로 채우면 FPM 에서는 요청마다 재부팅되어 드러나지 않지만, 큐 워커·`schedule:work`·Reverb 처럼 프로세스가 상주하는 환경에서는 저장 후에도 그 프로세스가 옛 값을 영원히 읽는다. 새 저장 경로를 만들면 그 자리에서 미러 재채움을 함께 호출한다.
|
||||
|
||||
> 다중 상주 워커 간 브로드캐스트 동기화는 범위 밖이다 — 저장을 수행한 프로세스와 이후 새로 부팅되는 프로세스가 정합하면 충분하고, 워커 재기동은 드라이버 탭의 큐 재시작이 담당한다.
|
||||
|
||||
### 미러는 민감 항목을 담지 않는다 — 민감값은 전용 게터로
|
||||
|
||||
스키마에 `sensitive: true` 로 선언된 필드는 미러에서 **키 자체가 제거**된다 (암호문도 싣지 않는다).
|
||||
|
||||
미러는 봇 전용 화면 생성기가 레이아웃 표현식에서 참조한 키를 **제한 없이** 조회하는 경로다. 민감값이 실려 있으면 레이아웃이 그 키를 참조하는 순간 평문이 봇 HTML 로 나간다. 브라우저용 설정 전달 경로는 애초에 민감 항목을 제외하므로 화면만 봐서는 드러나지 않는 비대칭이다.
|
||||
|
||||
민감값이 필요한 서버 코드는 전용 게터를 쓴다.
|
||||
|
||||
```php
|
||||
// 표시·분기용 (민감 항목 제외)
|
||||
g7_plugin_settings('sirsoft-gdpr', 'duplicate_block_enabled');
|
||||
|
||||
// 민감값 (복호화 포함)
|
||||
plugin_setting('sirsoft-pay_kginicis', 'api_key');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 새 admin 환경설정 키 추가 시 점검
|
||||
|
||||
새 카테고리/키를 `storage/app/settings/*.json` 에 추가할 때:
|
||||
|
||||
@@ -62,6 +62,30 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
||||
|
||||
현재 로그인한 사용자가 자신의 계정을 탈퇴한다. 프론트의 회원 탈퇴 모달(`_modal_withdraw.json`)이 호출한다. 별도 요청 파라미터 없이 인증 토큰의 사용자를 대상으로 하며, `UserService::withdrawUser()` 가 아바타·토큰 삭제와 개인정보 익명화를 수행한다. 되돌릴 수 없는 작업이므로 호출 전 사용자 확인 절차를 두는 것을 권장한다.
|
||||
|
||||
탈퇴는 원자적으로 처리된다 — 약관 동의 이력 삭제·토큰 삭제·익명화가 하나의 트랜잭션이며, 어느 단계에서 실패해도 전부 취소된다. 아바타 파일 삭제는 커밋이 확정된 뒤에 수행되고, 파일 삭제가 실패해도 탈퇴는 되돌리지 않는다(참조되지 않는 파일만 남는다).
|
||||
|
||||
익명화된 이메일에는 사용자 ID 가 포함되어 구조적으로 유일하다 — 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 충돌하지 않는다.
|
||||
|
||||
**추가 오류 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Validation Error | 관리자 역할 보유 계정 또는 수퍼관리자 계정의 탈퇴 시도 (`errors.general` 에 사유) |
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "회원 탈퇴에 실패했습니다.",
|
||||
"errors": {
|
||||
"general": [
|
||||
"관리자 계정은 탈퇴할 수 없습니다."
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 차단 응답은 종전에 500 이었으나, 잘못된 요청이지 서버 오류가 아니므로 422 로 바뀌었다. 활동 로그도 탈퇴가 성공한 경우에만 기록된다.
|
||||
|
||||
|
||||
### GET /api/me
|
||||
<!-- @generated:start:api.me.show -->
|
||||
|
||||
@@ -428,6 +428,8 @@ _단건 응답: `data` 객체의 필드 (UserService::bulkUpdateStatus() 반환
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| updated_count | integer | `3` | 실제로 상태가 변경된 사용자 수 (요청자 본인 제외 규칙 적용 후 갱신된 행 수) |
|
||||
| failed_count | integer | `1` | 처리하지 못한 사용자 수 (탈퇴 전환에서 관리자·수퍼관리자 등 차단 대상이 섞인 경우). 탈퇴 외 상태 전환은 항상 `0` |
|
||||
| failed_reasons | array\<string\> | `["관리자 계정은 탈퇴할 수 없습니다."]` | 처리하지 못한 사유 (중복 제거). 탈퇴 외 상태 전환은 항상 `[]` |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -440,7 +442,9 @@ HTTP/1.1 200
|
||||
"success": true,
|
||||
"message": ":count명의 사용자 상태가 변경되었습니다.",
|
||||
"data": {
|
||||
"updated_count": 3
|
||||
"updated_count": 3,
|
||||
"failed_count": 0,
|
||||
"failed_reasons": []
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -462,6 +466,10 @@ HTTP/1.1 200
|
||||
|
||||
여러 사용자의 계정 상태를 한 번에 변경합니다. `ids` 는 대상 사용자 UUID 배열이며, `status` 는 `active`/`inactive`/`blocked`/`withdrawn`/`pending_verification`(UserStatus Enum 값) 중 하나이다. `ExcludeCurrentUser` 규칙으로 요청자 본인은 대상에서 제외된다. 목록 화면의 다중 선택 후 일괄 상태 변경에 사용한다.
|
||||
|
||||
`status=withdrawn` 요청은 상태 컬럼만 바꾸지 않고 **건별로 정식 탈퇴 처리**를 수행한다 (익명화 + 연계 데이터 정리 + 탈퇴 훅). 회원이 직접 탈퇴한 경우와 결과가 같다. 관리자 역할을 가진 대상은 건너뛰고 `failed_count` 로 집계되며, 나머지 대상의 처리는 계속된다.
|
||||
|
||||
전건이 차단되어도 응답은 200 이다. 소비자는 `updated_count` 와 `failed_count` 를 함께 읽어 결과를 판정해야 한다 — 200 만 보고 성공으로 안내하면 아무 일도 일어나지 않은 요청이 처리된 것으로 표시된다. 사유는 `failed_reasons` 로 전달되므로 그대로 노출하면 된다.
|
||||
|
||||
|
||||
### POST /api/admin/users/check-email
|
||||
<!-- @generated:start:api.admin.users.check-email -->
|
||||
@@ -1003,7 +1011,17 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "사용자가 삭제되었습니다.",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -1020,6 +1038,8 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
||||
|
||||
지정한 사용자(경로 파라미터는 UUID 로 바인딩)를 삭제합니다. 슈퍼관리자 계정은 삭제할 수 없으며 시도 시 422(`exceptions.cannot_delete_super_admin`)를 반환한다. 그 외 삭제 실패 시에는 실패 상세 사유가 담긴 422 를, 나머지 오류는 500 을 반환한다. 삭제는 Service 계층에서 관련 데이터 정리와 훅을 거쳐 처리된다.
|
||||
|
||||
삭제는 원자적으로 처리된다 — 역할 연결 해제·약관 동의 이력 삭제·토큰 삭제·계정 삭제가 하나의 트랜잭션이며, 마지막 삭제가 외래키 제약 등으로 실패하면 앞 단계도 전부 취소된다(연결만 끊긴 활성 계정이 남지 않는다). 아바타 파일 삭제는 커밋 후에 수행된다.
|
||||
|
||||
|
||||
### GET /api/admin/users/{user}
|
||||
<!-- @generated:start:api.admin.users.show -->
|
||||
@@ -1091,7 +1111,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| withdrawn_at | null | `null` | withdrawn 일시 |
|
||||
| blocked_at | null | `null` | blocked 일시 |
|
||||
| failed_login_attempts | integer | `0` | 연속 로그인 실패 횟수 |
|
||||
| locked_permanently | boolean | `false` | <!-- TODO: 설명 --> |
|
||||
| locked_permanently | boolean | `false` | 무기한 잠금 여부 (보안 설정의 잠금 시간이 `0` 이면 자동 해제 없이 관리자가 직접 풀어야 한다) |
|
||||
| locked_until | null | `null` | 계정 잠금 해제 시각 (NULL = 잠금 없음) |
|
||||
| is_locked | boolean | `false` | locked 여부 |
|
||||
| notify_post_complete | boolean | `false` | 게시글 작성 완료 알림 수신 여부 (게시판 모듈 알림 설정) |
|
||||
|
||||
@@ -125,6 +125,14 @@ core.attachment.delete
|
||||
# 컨텍스트 6키는 아래 "스토리지·드라이버 확장 훅 페이로드" 표 참조
|
||||
core.storage.filter_url
|
||||
|
||||
# 보존 기간 자동 파기 (7.0.7) — 운영자 일괄 삭제 훅과 별개로 발행한다.
|
||||
# 운영자 삭제 훅에는 본인인증 같은 대화형 가드가 물려 있어 무인 예약이 탈 수 없고,
|
||||
# 그렇다고 훅 없이 지우면 확장이 가장 큰 삭제 경로(첫 실행의 누적분)를 볼 수 없다.
|
||||
# 인자는 (보존일) / (보존일, 삭제건수) — 대상을 ID 로 지목하지 않는다.
|
||||
core.activity_log.before_prune core.activity_log.after_prune
|
||||
core.notification_log.before_prune core.notification_log.after_prune
|
||||
core.schedule.before_prune_history core.schedule.after_prune_history
|
||||
|
||||
# 업로드 트라이어드 — 사용자 첨부 업로드 지점의 표준 3훅 패턴
|
||||
# before_upload(액션) → filter_upload_file(필터: UploadedFile 을 받아 변형본을 반환.
|
||||
# 저장 파일명·MIME·크기가 모두 반환 파일 기준이 된다) → after_upload(액션)
|
||||
|
||||
@@ -527,6 +527,38 @@ $timing = $this->settingsService->getStockDeductionTiming($method);
|
||||
|
||||
> **판단 기준**: `getSetting()` 단일 호출만 필요하면 `module_setting()` 헬퍼 사용, 모듈 전용 메서드가 필요하면 서비스 직접 주입
|
||||
|
||||
### 8.4 저장하면 config 미러를 다시 채운다
|
||||
|
||||
`g7_module_settings()` 가 읽는 값은 in-memory config 미러(`g7_settings.modules.{id}`)다. 이 미러는 부팅 때 채워지므로, 저장 경로가 아무것도 하지 않으면 **그 프로세스는 저장 후에도 옛 값을 계속 읽는다.**
|
||||
|
||||
php-fpm 은 요청마다 부팅해 문제가 드러나지 않는다. 큐 워커 · `schedule:work` · Reverb 처럼 프로세스가 상주하는 환경에서만 나타나고, 예외도 경고도 로그도 남지 않는다 — 운영자는 저장했는데 배경 작업만 옛 설정으로 도는 상태가 된다.
|
||||
|
||||
모듈 설정 저장에는 코어 공통 지점이 없다(각 모듈의 설정 서비스가 직접 저장한다). 그래서 **자기 캐시를 비우는 모든 자리에서** 미러 재채움을 함께 호출한다.
|
||||
|
||||
```php
|
||||
public function saveSettings(array $settings): bool
|
||||
{
|
||||
// ... 저장 ...
|
||||
|
||||
$this->settings = null; // 자기 캐시 비우기
|
||||
g7_refresh_module_settings_config('vendor-module'); // config 미러 다시 채우기
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->settings = null;
|
||||
g7_refresh_module_settings_config('vendor-module');
|
||||
}
|
||||
```
|
||||
|
||||
호출 지점은 저장 메서드 하나가 아니다 — 초기화·복원·부분 저장 등 **값을 바꾸는 자리 전부**다. 한 곳만 빠지면 그 경로로 저장했을 때만 미러가 옛 값으로 남는다.
|
||||
|
||||
- 이 헬퍼는 코어 7.0.7 부터 제공된다. 사용하는 모듈은 `module.json` 의 `requires.g7_version` 을 그에 맞춰 올린다.
|
||||
- 스키마에 `sensitive: true` 로 선언한 필드는 미러에 담기지 않는다. 민감값은 자체 설정 서비스(복호화 경로)로 읽는다.
|
||||
- 배경과 코어 축(코어/플러그인)의 처리는 [admin-settings-access.md](../backend/admin-settings-access.md) "config 미러 갱신 시점" 참조.
|
||||
|
||||
---
|
||||
|
||||
## 9. 카탈로그 병합 설정의 공개 응답
|
||||
@@ -641,3 +673,4 @@ GET /api/modules/{id}/settings/payment # 고아 제외 (정상)
|
||||
- [ ] 레이아웃에 `data_sources` 추가
|
||||
- [ ] 저장 버튼 액션 연결
|
||||
- [ ] `frontend_schema`로 민감정보 필터링 설정
|
||||
- [ ] 값을 바꾸는 모든 자리에서 `g7_refresh_module_settings_config()` 호출 (8.4)
|
||||
|
||||
@@ -367,6 +367,7 @@ export interface DataGridProps {
|
||||
rowKey?: string; // 행 고유 키 필드 (기본: 'id')
|
||||
selectable?: boolean; // 행 선택 기능
|
||||
selectedRows?: any[]; // 선택된 행 배열
|
||||
selectionScope?: 'page' | 'free'; // 선택의 유효 범위 (아래 "선택의 유효 범위" 참조)
|
||||
loading?: boolean; // 로딩 상태
|
||||
className?: string; // CSS 클래스
|
||||
|
||||
@@ -394,9 +395,43 @@ export interface DataGridProps {
|
||||
| `rowKey` | `string` | ❌ | `'id'` | 행 고유 키로 사용할 필드명 |
|
||||
| `selectable` | `boolean` | ❌ | `false` | 체크박스 선택 기능 활성화 |
|
||||
| `selectedRows` | `any[]` | ❌ | `[]` | 선택된 행 배열 (ID 배열) |
|
||||
| `selectionScope` | `'page' \| 'free'` | ❌ | `'free'` | 선택의 유효 범위 (아래 참조) |
|
||||
| `loading` | `boolean` | ❌ | `false` | 로딩 상태 표시 |
|
||||
| `className` | `string` | ❌ | - | CSS 클래스 |
|
||||
|
||||
### 선택의 유효 범위 (selectionScope)
|
||||
|
||||
체크박스 선택은 컴포넌트 밖(전역/로컬 상태)에 저장된다. 그래서 검색·필터·페이지 이동으로 행이 목록에서 빠져도 그 행의 선택은 남는다. 그 상태에서 일괄 처리를 실행하면 사용자가 보고 있지도, 체크하지도 않은 행이 대상이 된다 — 확인 모달은 건수만 말하므로 실행 전에 알아챌 방법이 없고, 처리는 정상 성공하므로 오류도 남지 않는다.
|
||||
|
||||
`onSelectionChange` 를 배선하는 DataGrid 는 둘 중 하나를 반드시 명시한다 (기본값에 기대지 않는다).
|
||||
|
||||
| 값 | 동작 | 쓰는 곳 |
|
||||
|----|------|--------|
|
||||
| `"page"` | 지금 그려진 행에 없는 선택을 버린다. 대상은 언제나 "보이고 체크된 행" | 일괄 처리 버튼이 달린 목록 화면 |
|
||||
| `"free"` | 선택을 컴포넌트가 건드리지 않는다 | 선택 자체가 저장 대상인 폼(적용 대상 고르기 등) — 여러 페이지에 걸쳐 고르는 선택기 포함 |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "DataGrid",
|
||||
"props": {
|
||||
"selectable": true,
|
||||
"selectionScope": "page",
|
||||
"selectedIds": "{{_global.selectedIds_admin_users || []}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"event": "onSelectionChange",
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "selectedIds_admin_users": "{{$args[0]}}" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
값은 리터럴로 고정한다. 표현식으로 분기하면 분기마다 보존 여부가 갈려 한쪽이 조용히 대상 밖 행을 싣는다. `selectable` 이 꺼진 화면이라고 안전한 것도 아니다 — 체크박스가 없으면 남은 선택이 더 안 보이므로, 범위 판정은 `selectable` 과 무관하게 적용된다.
|
||||
|
||||
### SubRow Props (engine-v1.6.0+)
|
||||
|
||||
행 아래에 추가 정보를 표시하는 서브 행 기능입니다.
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
|
||||
- 참조처가 사라진 S3 리전 선택 검증 메시지(`validation.settings.s3_region_required`)를 원본(ko/en)과 함께 정리했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 범용 입력 항목의 검증 문구가 특정 화면 전용 이름(SMTP 비밀번호·기본 언어·기본 통화 등)으로 표시되던 문제를 수정했습니다 — 전역 라벨을 범용 문구로 되돌리고 SMTP·사용자 목록 등 도메인 전용 라벨을 별도 키로 분리했습니다. (#113 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
|
||||
## [1.0.5] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -123,4 +123,5 @@ return [
|
||||
'exclude_user_id_uuid' => '除外するユーザーIDは有効なUUIDである必要があります。',
|
||||
'exclude_user_id_exists' => '存在しないユーザーです。',
|
||||
],
|
||||
'withdraw_failed_unknown' => '不明なエラーにより退会処理に失敗しました。',
|
||||
];
|
||||
|
||||
@@ -1029,7 +1029,8 @@ return [
|
||||
'ses_key' => 'SES アクセスキー',
|
||||
'ses_secret' => 'SES シークレットキー',
|
||||
'ses_region' => 'SES リージョン',
|
||||
'ids' => 'ユーザーID一覧',
|
||||
'ids' => 'ID一覧',
|
||||
'user_ids' => 'ユーザーID一覧',
|
||||
'user_id' => 'ユーザーID',
|
||||
'status' => 'ステータス',
|
||||
'site_name' => 'サイト名',
|
||||
@@ -1037,7 +1038,8 @@ return [
|
||||
'site_description' => 'サイト説明',
|
||||
'admin_email' => '管理者メール',
|
||||
'timezone' => 'タイムゾーン',
|
||||
'language' => 'デフォルト言語',
|
||||
'language' => '言語',
|
||||
'default_language' => 'デフォルト言語',
|
||||
'identity_default_provider' => 'デフォルトプロバイダー',
|
||||
'identity_purpose_providers' => '目的別プロバイダー',
|
||||
'identity_challenge_ttl_minutes' => 'Challenge有効期限',
|
||||
@@ -1052,10 +1054,14 @@ return [
|
||||
'identity_policy_applies_to' => '適用対象',
|
||||
'identity_policy_fail_mode' => '失敗モード',
|
||||
'mailer' => 'メーラー',
|
||||
'host' => 'SMTPホスト',
|
||||
'port' => 'SMTPポート',
|
||||
'username' => 'SMTPユーザー名',
|
||||
'password' => 'SMTPパスワード',
|
||||
'host' => 'ホスト',
|
||||
'port' => 'ポート',
|
||||
'username' => 'ユーザー名',
|
||||
'password' => 'パスワード',
|
||||
'smtp_host' => 'SMTPホスト',
|
||||
'smtp_port' => 'SMTPポート',
|
||||
'smtp_username' => 'SMTPユーザー名',
|
||||
'smtp_password' => 'SMTPパスワード',
|
||||
'encryption' => '暗号化',
|
||||
'from_address' => '送信元メールアドレス',
|
||||
'from_name' => '送信元名',
|
||||
@@ -1096,8 +1102,10 @@ return [
|
||||
'websocket_port' => 'WebSocket ポート',
|
||||
'websocket_scheme' => 'WebSocket プロトコル',
|
||||
'mailgun_endpoint' => 'Mailgun エンドポイント',
|
||||
'channels' => '通知チャンネル',
|
||||
'currency' => 'デフォルト通貨',
|
||||
'channels' => 'チャンネル',
|
||||
'notification_channels' => '通知チャンネル',
|
||||
'currency' => '通貨',
|
||||
'default_currency' => 'デフォルト通貨',
|
||||
'maintenance_mode' => 'メンテナンスモード',
|
||||
'asset_url_mode' => 'アセットURL方式',
|
||||
'site_logo' => 'サイトロゴ',
|
||||
@@ -1110,7 +1118,8 @@ return [
|
||||
'twitter_default_card' => 'Twitter デフォルトカード種類',
|
||||
'twitter_default_site' => 'Twitter デフォルトアカウント',
|
||||
'seo_page_cache_enabled' => 'SEO ページキャッシュの使用',
|
||||
'cache_ttl' => 'SEO ページキャッシュ保持時間',
|
||||
'cache_ttl' => 'キャッシュ保持時間',
|
||||
'seo_page_cache_ttl' => 'SEO ページキャッシュ保持時間',
|
||||
'sitemap_enabled' => 'サイトマップの使用',
|
||||
'sitemap_cache_ttl' => 'サイトマップキャッシュ保持時間',
|
||||
'sitemap_urls_per_file' => 'サイトマップ1ファイルあたりのURL数',
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"identifier": "g7-module-sirsoft-ecommerce-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 모듈 (sirsoft-ecommerce) 일본어 언어팩",
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 모듈 (sirsoft-ecommerce) 일본어 언어팩 (번들)",
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-ecommerce",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
"identifier": "g7-module-sirsoft-ecommerce-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 모듈 (sirsoft-ecommerce) 일본어 언어팩",
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 모듈 (sirsoft-ecommerce) 일본어 언어팩 (번들)",
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-ecommerce",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"identifier": "g7-plugin-sirsoft-pay_kginicis-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_kginicis) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_kginicis) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_kginicis",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
"identifier": "g7-plugin-sirsoft-pay_kginicis-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_kginicis) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_kginicis) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_kginicis",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- 환경설정 > 드라이버 > 파일 스토리지의 S3 신설 항목 일본어 번역 추가 (`settings.drivers.storage.s3_endpoint*`, `s3_use_path_style*`, `s3_region_desc`) — 엔드포인트 URL·Path-style 토글·리전 입력 도움말이 일본어 로케일에서 자연스럽게 표시됩니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- 환경설정 드라이버 탭의 "공개 자산 스토리지" 카드 문구 일본어 번역을 추가했습니다.
|
||||
- 회원 정보 수정 화면의 탈퇴 확인 창 문구 일본어 번역 추가 (`admin.users.modals.withdraw_confirm_*`) — 되돌릴 수 없는 처리라는 안내와 함께 처리되는 항목 목록이 일본어 로케일에서 표시됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -208,7 +208,16 @@
|
||||
"bulk_block_success": "ユーザーがブロックされました。",
|
||||
"bulk_withdraw_title": "ユーザー退会処理",
|
||||
"bulk_withdraw_confirm": "{{count}}人のユーザーを退会処理します。この操作は取り消すが難しい場合があります。",
|
||||
"bulk_withdraw_success": "ユーザーが退会処理されました。"
|
||||
"bulk_withdraw_success": "ユーザーが退会処理されました。",
|
||||
"withdraw_confirm_title": "退会処理の確認",
|
||||
"withdraw_confirm_lead": "ステータスを「退会」に保存すると、この会員は実際に退会処理されます。元に戻すことはできません。",
|
||||
"withdraw_confirm_effects_title": "一緒に処理される項目",
|
||||
"withdraw_confirm_effect_anonymize": "メールアドレス·名前·ニックネームが匿名化されます。",
|
||||
"withdraw_confirm_effect_sessions": "この会員のすべてのログインセッションが解除されます。",
|
||||
"withdraw_confirm_effect_linked": "この会員の規約同意履歴·プロフィール画像が削除され、マイレージ·本人認証記録など連携データが整理されます。",
|
||||
"withdraw_confirm_submit": "退会処理",
|
||||
"bulk_withdraw_partial": "{updated}名を退会処理しました。{failed}名は処理できませんでした — {reason}",
|
||||
"bulk_withdraw_all_failed": "退会処理できませんでした — {reason}"
|
||||
},
|
||||
"form": {
|
||||
"page_title_create": "ユーザー登録",
|
||||
|
||||
@@ -71,6 +71,7 @@ return [
|
||||
'withdraw_success' => 'Account withdrawal completed successfully.',
|
||||
'withdraw_failed' => 'Failed to withdraw account: :error',
|
||||
'withdraw_admin_forbidden' => 'Administrator account cannot be withdrawn.',
|
||||
'withdraw_failed_unknown' => 'Withdrawal failed due to an unknown error.',
|
||||
|
||||
// Validation messages
|
||||
'validation' => [
|
||||
|
||||
+18
-9
@@ -1153,7 +1153,8 @@ return [
|
||||
|
||||
// Validation attribute names (validation.attributes)
|
||||
'attributes' => [
|
||||
'ids' => 'user ID list',
|
||||
'ids' => 'ID list',
|
||||
'user_ids' => 'user ID list',
|
||||
'user_id' => 'user ID',
|
||||
'status' => 'status',
|
||||
// Settings fields
|
||||
@@ -1162,7 +1163,8 @@ return [
|
||||
'site_description' => 'site description',
|
||||
'admin_email' => 'admin email',
|
||||
'timezone' => 'timezone',
|
||||
'language' => 'default language',
|
||||
'language' => 'language',
|
||||
'default_language' => 'default language',
|
||||
// Identity verification (IDV) fields
|
||||
'identity_default_provider' => 'default provider',
|
||||
'identity_purpose_providers' => 'purpose-to-provider mapping',
|
||||
@@ -1180,10 +1182,14 @@ return [
|
||||
'identity_policy_fail_mode' => 'fail mode',
|
||||
// Mail settings
|
||||
'mailer' => 'mailer',
|
||||
'host' => 'SMTP host',
|
||||
'port' => 'SMTP port',
|
||||
'username' => 'SMTP username',
|
||||
'password' => 'SMTP password',
|
||||
'host' => 'host',
|
||||
'port' => 'port',
|
||||
'username' => 'username',
|
||||
'password' => 'password',
|
||||
'smtp_host' => 'SMTP host',
|
||||
'smtp_port' => 'SMTP port',
|
||||
'smtp_username' => 'SMTP username',
|
||||
'smtp_password' => 'SMTP password',
|
||||
'encryption' => 'encryption',
|
||||
'from_address' => 'sender email',
|
||||
'from_name' => 'sender name',
|
||||
@@ -1237,8 +1243,10 @@ return [
|
||||
'ses_region' => 'SES region',
|
||||
'mailgun_endpoint' => 'Mailgun endpoint',
|
||||
// General settings (additional)
|
||||
'channels' => 'notification channels',
|
||||
'currency' => 'default currency',
|
||||
'channels' => 'channels',
|
||||
'notification_channels' => 'notification channels',
|
||||
'currency' => 'currency',
|
||||
'default_currency' => 'default currency',
|
||||
'maintenance_mode' => 'maintenance mode',
|
||||
'asset_url_mode' => 'asset URL mode',
|
||||
'site_logo' => 'site logo',
|
||||
@@ -1252,7 +1260,8 @@ return [
|
||||
'twitter_default_card' => 'Twitter default card type',
|
||||
'twitter_default_site' => 'Twitter default account',
|
||||
'seo_page_cache_enabled' => 'SEO page cache',
|
||||
'cache_ttl' => 'SEO page cache lifetime',
|
||||
'cache_ttl' => 'cache lifetime',
|
||||
'seo_page_cache_ttl' => 'SEO page cache lifetime',
|
||||
'sitemap_enabled' => 'sitemap',
|
||||
'sitemap_cache_ttl' => 'sitemap cache lifetime',
|
||||
'sitemap_urls_per_file' => 'sitemap URLs per file',
|
||||
|
||||
@@ -71,6 +71,7 @@ return [
|
||||
'withdraw_success' => '회원 탈퇴가 완료되었습니다.',
|
||||
'withdraw_failed' => '회원 탈퇴에 실패했습니다: :error',
|
||||
'withdraw_admin_forbidden' => '관리자 계정은 탈퇴할 수 없습니다.',
|
||||
'withdraw_failed_unknown' => '알 수 없는 오류로 탈퇴 처리에 실패했습니다.',
|
||||
|
||||
// 유효성 검사 메시지
|
||||
'validation' => [
|
||||
|
||||
+18
-9
@@ -1146,7 +1146,8 @@ return [
|
||||
|
||||
// 검증 속성명 (validation.attributes)
|
||||
'attributes' => [
|
||||
'ids' => '사용자 ID 목록',
|
||||
'ids' => 'ID 목록',
|
||||
'user_ids' => '사용자 ID 목록',
|
||||
'user_id' => '사용자 ID',
|
||||
'status' => '상태',
|
||||
// 일반 설정 필드
|
||||
@@ -1155,7 +1156,8 @@ return [
|
||||
'site_description' => '사이트 설명',
|
||||
'admin_email' => '관리자 이메일',
|
||||
'timezone' => '시간대',
|
||||
'language' => '기본 언어',
|
||||
'language' => '언어',
|
||||
'default_language' => '기본 언어',
|
||||
// 본인인증(IDV) 필드
|
||||
'identity_default_provider' => '기본 프로바이더',
|
||||
'identity_purpose_providers' => '목적별 프로바이더',
|
||||
@@ -1173,10 +1175,14 @@ return [
|
||||
'identity_policy_fail_mode' => '실패 모드',
|
||||
// 메일 설정 필드
|
||||
'mailer' => '메일러',
|
||||
'host' => 'SMTP 호스트',
|
||||
'port' => 'SMTP 포트',
|
||||
'username' => 'SMTP 사용자명',
|
||||
'password' => 'SMTP 비밀번호',
|
||||
'host' => '호스트',
|
||||
'port' => '포트',
|
||||
'username' => '사용자명',
|
||||
'password' => '비밀번호',
|
||||
'smtp_host' => 'SMTP 호스트',
|
||||
'smtp_port' => 'SMTP 포트',
|
||||
'smtp_username' => 'SMTP 사용자명',
|
||||
'smtp_password' => 'SMTP 비밀번호',
|
||||
'encryption' => '암호화',
|
||||
'from_address' => '발신자 이메일',
|
||||
'from_name' => '발신자 이름',
|
||||
@@ -1230,8 +1236,10 @@ return [
|
||||
'ses_region' => 'SES 리전',
|
||||
'mailgun_endpoint' => 'Mailgun 엔드포인트',
|
||||
// 일반 설정 (추가)
|
||||
'channels' => '알림 채널',
|
||||
'currency' => '기본 통화',
|
||||
'channels' => '채널',
|
||||
'notification_channels' => '알림 채널',
|
||||
'currency' => '통화',
|
||||
'default_currency' => '기본 통화',
|
||||
'maintenance_mode' => '점검 모드',
|
||||
'asset_url_mode' => '자산 주소 방식',
|
||||
'site_logo' => '사이트 로고',
|
||||
@@ -1245,7 +1253,8 @@ return [
|
||||
'twitter_default_card' => '트위터 기본 카드 유형',
|
||||
'twitter_default_site' => '트위터 기본 계정',
|
||||
'seo_page_cache_enabled' => 'SEO 페이지 캐시 사용',
|
||||
'cache_ttl' => 'SEO 페이지 캐시 유지시간',
|
||||
'cache_ttl' => '캐시 유지시간',
|
||||
'seo_page_cache_ttl' => 'SEO 페이지 캐시 유지시간',
|
||||
'sitemap_enabled' => '사이트맵 사용',
|
||||
'sitemap_cache_ttl' => '사이트맵 캐시 유지시간',
|
||||
'sitemap_urls_per_file' => '사이트맵 파일당 URL 수',
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
### Fixed
|
||||
|
||||
- 신고 반려 누적 제한 안내 문구를 실제 동작에 맞게 정정했습니다. "설정 건수를 초과하면 차단"으로 적혀 있었지만 실제로는 설정 건수에 도달하는 순간부터 차단됩니다 — 5건으로 설정하면 5번째 반려부터 신고가 막힙니다.
|
||||
- 관리자가 게시판 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 신고 현황 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
|
||||
|
||||
## [1.0.3] - 2026-08-10
|
||||
|
||||
|
||||
@@ -2099,6 +2099,7 @@
|
||||
"serverTotalPages": "{{reports?.data?.pagination?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{reports?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"selectable": true,
|
||||
"selectionScope": "page",
|
||||
"selectedIds": "{{_global.selectedIds || []}}",
|
||||
"idField": "id",
|
||||
"showColumnSelector": false,
|
||||
|
||||
@@ -186,6 +186,9 @@ class BoardSettingsService implements ModuleSettingsInterface
|
||||
// 캐시 초기화
|
||||
$this->settings = null;
|
||||
|
||||
// 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
|
||||
g7_refresh_module_settings_config('sirsoft-board');
|
||||
|
||||
// report_policy 설정 변경 시 알림 정의 활성 상태 동기화
|
||||
// 저장된 최종 설정을 사용 (boolean 보정 후 값)
|
||||
if (isset($settings['report_policy'])) {
|
||||
@@ -323,6 +326,9 @@ class BoardSettingsService implements ModuleSettingsInterface
|
||||
{
|
||||
$this->defaults = null;
|
||||
$this->settings = null;
|
||||
|
||||
// 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
|
||||
g7_refresh_module_settings_config('sirsoft-board');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- 같은 모달의 "다운로드 기록" 목록이 항상 비어 보이던 문제를 수정했습니다. 이제 누가 언제 몇 번째로 내려받았는지 기록이 표시됩니다.
|
||||
- 같은 모달의 "처리중"·"실패"·"만료" 상태 배지에서 아이콘이 표시되지 않던 문제를 수정했습니다.
|
||||
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
|
||||
- 관리자 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다. 주문·상품·쿠폰·배송정책·마일리지 내역·리뷰 목록에 적용됩니다. 쿠폰 적용 대상 고르기처럼 여러 페이지에 걸쳐 선택하는 화면은 종전대로 선택이 유지됩니다.
|
||||
|
||||
#### 배송비 구간
|
||||
|
||||
@@ -49,6 +50,8 @@
|
||||
- 도서산간 우편번호는 "63000", "63000-63999", "63*" 세 형식만 저장됩니다. 다른 형식은 어떤 우편번호에도 맞지 않아 추가배송비가 빠진 채로 주문이 완료됐습니다. 자릿수가 다른 우편번호가 범위 조건에 잘못 걸리던 문제도 함께 수정했습니다.
|
||||
- 도서산간 추가배송비의 "중복 부과"가 기본 배송비와 같은 배송 단위 수를 기준으로 계산됩니다. 이전에는 주문 수량을 기준으로 삼아 무게당·부피당 정책에서 기본 배송비와 어긋난 금액이 부과됐습니다.
|
||||
- 배송비에 소수점이 있는 경우 버리지 않고 반올림합니다.
|
||||
- 관리자가 상점 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 주문서 작성 중 만들어지는 임시 주문 정보가 만료된 뒤에도 정리되지 않고 계속 쌓이던 문제를 수정했습니다 — 이제 매시간 자동으로 정리됩니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
|
||||
## [1.1.0] - 2026-08-10
|
||||
|
||||
|
||||
@@ -2176,6 +2176,13 @@ class Module extends AbstractModule
|
||||
// cart_expiry_days < 1 시 커맨드 내부 self-guard 로 비활성 (별도 토글 없음)
|
||||
'enabled_config' => null,
|
||||
],
|
||||
[
|
||||
'command' => 'sirsoft-ecommerce:prune-temp-orders',
|
||||
'schedule' => 'hourly',
|
||||
'description' => '만료 임시 주문 자동 삭제',
|
||||
// 만료 판정은 임시 주문 자체의 TTL 이 정하므로 별도 토글 없음
|
||||
'enabled_config' => null,
|
||||
],
|
||||
[
|
||||
'command' => 'sirsoft-ecommerce:earn-mileage',
|
||||
'schedule' => 'hourly',
|
||||
|
||||
+1
@@ -1590,6 +1590,7 @@
|
||||
"data": "{{reviews.data?.data ?? []}}",
|
||||
"idField": "id",
|
||||
"selectable": true,
|
||||
"selectionScope": "page",
|
||||
"selectedIds": "{{_local.selectedReviews ?? []}}",
|
||||
"expandable": true,
|
||||
"expandedRowIds": "{{_local.expandedRows ?? []}}",
|
||||
|
||||
+1
@@ -18,6 +18,7 @@
|
||||
"data": "{{transactions?.data?.data || []}}",
|
||||
"loading": "{{transactions?.loading}}",
|
||||
"selectable": true,
|
||||
"selectionScope": "page",
|
||||
"selectedIds": "{{_local.selectedItems || []}}",
|
||||
"expandable": true,
|
||||
"expandedRowIds": "{{_local.expandedRows || []}}",
|
||||
|
||||
+1
@@ -271,6 +271,7 @@
|
||||
"data": "{{order.data?.options ?? []}}",
|
||||
"idField": "id",
|
||||
"selectable": true,
|
||||
"selectionScope": "free",
|
||||
"selectedIds": "{{_local.selectedProducts || []}}",
|
||||
"pagination": false,
|
||||
"showColumnSelector": false,
|
||||
|
||||
+1
@@ -730,6 +730,7 @@
|
||||
"data": "{{orders?.data?.data || []}}",
|
||||
"loading": "{{orders?.loading}}",
|
||||
"selectable": true,
|
||||
"selectionScope": "page",
|
||||
"selectedIds": "{{_local.selectedItems || []}}",
|
||||
"showColumnSelector": false,
|
||||
"selectedCountText": "",
|
||||
|
||||
+1
@@ -551,6 +551,7 @@
|
||||
"idField": "id",
|
||||
"sortable": true,
|
||||
"selectable": true,
|
||||
"selectionScope": "page",
|
||||
"selectedIds": "{{_local.selectedItems || []}}",
|
||||
"expandable": true,
|
||||
"expandedRowIds": "{{_local.expandedRows || []}}",
|
||||
|
||||
+3
@@ -382,6 +382,7 @@
|
||||
}
|
||||
],
|
||||
"selectable": true,
|
||||
"selectionScope": "free",
|
||||
"selectedIds": "{{_local.productSearchSelected ?? []}}",
|
||||
"emptyMessage": "$t:sirsoft-ecommerce.admin.promotion_coupon.form.search.no_results"
|
||||
},
|
||||
@@ -532,6 +533,7 @@
|
||||
}
|
||||
],
|
||||
"selectable": true,
|
||||
"selectionScope": "free",
|
||||
"selectedIds": "{{_local.selectedProductIds ?? []}}",
|
||||
"emptyMessage": "$t:sirsoft-ecommerce.admin.promotion_coupon.form.search.no_selected"
|
||||
},
|
||||
@@ -791,6 +793,7 @@
|
||||
}
|
||||
],
|
||||
"selectable": true,
|
||||
"selectionScope": "free",
|
||||
"selectedIds": "{{_local.selectedCategoryIds ?? []}}",
|
||||
"emptyMessage": "$t:sirsoft-ecommerce.admin.promotion_coupon.form.search.no_selected"
|
||||
},
|
||||
|
||||
+1
@@ -237,6 +237,7 @@
|
||||
"pageSize": "{{+(query.per_page || 10)}}",
|
||||
"alwaysShowPagination": true,
|
||||
"selectable": true,
|
||||
"selectionScope": "page",
|
||||
"selectedIds": "{{_local.selectedItems || []}}",
|
||||
"visibleColumns": "{{_local.visibleColumns}}",
|
||||
"selectedCountText": "",
|
||||
|
||||
+1
@@ -10,6 +10,7 @@
|
||||
"data": "{{shipping_policies?.data?.data || []}}",
|
||||
"idField": "id",
|
||||
"selectable": true,
|
||||
"selectionScope": "page",
|
||||
"selectedIds": "{{_global.bulkSelectedItems || []}}",
|
||||
"selectedCountText": "",
|
||||
"loading": "{{shipping_policies?.loading}}",
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\Sirsoft\Ecommerce\Services\TempOrderService;
|
||||
|
||||
/**
|
||||
* 만료 임시 주문 자동 정리 커맨드
|
||||
*
|
||||
* 임시 주문은 체크아웃 진입마다 생성되고 30분 TTL 로 만료되지만, 만료된 행을
|
||||
* 실제로 삭제하는 주체가 없어 무한히 누적됩니다. 스케줄러에서 주기적으로 실행합니다.
|
||||
*
|
||||
* @example php artisan sirsoft-ecommerce:prune-temp-orders
|
||||
*/
|
||||
class PruneExpiredTempOrdersCommand extends Command
|
||||
{
|
||||
/**
|
||||
* 커맨드 이름 및 시그니처
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'sirsoft-ecommerce:prune-temp-orders';
|
||||
|
||||
/**
|
||||
* 커맨드 설명
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '만료된 임시 주문을 삭제합니다.';
|
||||
|
||||
/**
|
||||
* 커맨드 실행
|
||||
*
|
||||
* @param TempOrderService $tempOrderService 임시 주문 서비스
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(TempOrderService $tempOrderService): int
|
||||
{
|
||||
$deleted = $tempOrderService->cleanupExpiredTempOrders();
|
||||
|
||||
$this->info("만료된 임시 주문 {$deleted}건이 삭제되었습니다.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use Modules\Sirsoft\Ecommerce\Console\Commands\EarnMileageCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\ExpireMileageCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\NotifyExpiringMileageCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\PruneExpiredCartsCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\PruneExpiredTempOrdersCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\ReconcileMileageBalanceCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\BrandRepository;
|
||||
@@ -196,6 +197,7 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider
|
||||
ExpireMileageCommand::class,
|
||||
NotifyExpiringMileageCommand::class,
|
||||
PruneExpiredCartsCommand::class,
|
||||
PruneExpiredTempOrdersCommand::class,
|
||||
ReconcileMileageBalanceCommand::class,
|
||||
];
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
// 파일 저장 후 캐시 초기화 (다음 조회 시 재계산)
|
||||
$this->settings = null;
|
||||
|
||||
// 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
|
||||
g7_refresh_module_settings_config('sirsoft-ecommerce');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -287,6 +290,9 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
// 캐시 초기화
|
||||
$this->settings = null;
|
||||
|
||||
// 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
|
||||
g7_refresh_module_settings_config('sirsoft-ecommerce');
|
||||
|
||||
// 통화 목록이 바뀌었을 수 있으므로 요청 단위 통화 캐시도 함께 비운다.
|
||||
// (리소스 계층이 이 캐시로 다통화 금액을 만들기 때문에, 비우지 않으면 같은 요청 안에서
|
||||
// 저장 전 통화 구성으로 금액이 계산된다)
|
||||
@@ -383,6 +389,9 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
// 캐시 초기화
|
||||
$this->settings = null;
|
||||
|
||||
// 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
|
||||
g7_refresh_module_settings_config('sirsoft-ecommerce');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -1159,6 +1168,9 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
{
|
||||
$this->defaults = null;
|
||||
$this->settings = null;
|
||||
|
||||
// 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
|
||||
g7_refresh_module_settings_config('sirsoft-ecommerce');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Console;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||||
use Modules\Sirsoft\Ecommerce\Module;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 만료 임시 주문 자동 정리 커맨드 테스트 (공개이슈 #110 전수 해소분, B5)
|
||||
*
|
||||
* 임시 주문은 체크아웃 진입마다 생성되고 TTL 로 만료되지만, 이 커맨드가 없으면
|
||||
* 만료 행을 실제로 지우는 주체가 없어 무한히 누적됩니다.
|
||||
*
|
||||
* @scenario target=ecommerce_temp_orders, row_age=beyond_retention
|
||||
*
|
||||
* @effects rows_beyond_retention_are_deleted, rows_within_retention_are_kept, all_gc_commands_are_registered_in_schedule
|
||||
*/
|
||||
class PruneExpiredTempOrdersCommandTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 만료된 임시 주문만 삭제되고 유효한 임시 주문은 보존된다.
|
||||
*/
|
||||
public function test_prunes_only_expired_temp_orders(): void
|
||||
{
|
||||
$expired = TempOrder::create([
|
||||
'cart_key' => 'expired-key',
|
||||
'items' => [],
|
||||
'calculation_input' => [],
|
||||
'calculation_result' => [],
|
||||
'expires_at' => Carbon::now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$alive = TempOrder::create([
|
||||
'cart_key' => 'alive-key',
|
||||
'items' => [],
|
||||
'calculation_input' => [],
|
||||
'calculation_result' => [],
|
||||
'expires_at' => Carbon::now()->addMinutes(10),
|
||||
]);
|
||||
|
||||
$this->artisan('sirsoft-ecommerce:prune-temp-orders')->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseMissing('ecommerce_temp_orders', ['id' => $expired->id]);
|
||||
$this->assertDatabaseHas('ecommerce_temp_orders', ['id' => $alive->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈이 이 커맨드를 스케줄로 등록한다 (등록 누락 회귀 차단).
|
||||
*/
|
||||
public function test_command_is_registered_in_module_schedules(): void
|
||||
{
|
||||
$entry = collect((new Module)->getSchedules())
|
||||
->firstWhere('command', 'sirsoft-ecommerce:prune-temp-orders');
|
||||
|
||||
$this->assertNotNull($entry, '만료 임시 주문 정리 스케줄이 등록되어야 합니다.');
|
||||
$this->assertSame('hourly', $entry['schedule']);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Services;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 이커머스 설정 미러 재채움 테스트 (공개이슈 #109, A3)
|
||||
*
|
||||
* 모듈 설정 저장에는 코어 공통 지점이 없어 각 모듈 서비스가 직접 저장한다.
|
||||
* 그 지점에서 미러를 갱신하지 않으면 상주 프로세스(큐 워커 등)가 옛 값을 계속 읽는다.
|
||||
*
|
||||
* @scenario scope=module, trigger=save, trigger=cache_clear
|
||||
*
|
||||
* @effects module_mirror_refreshed_after_save, module_mirror_refreshed_after_cache_clear
|
||||
*/
|
||||
class EcommerceSettingsMirrorTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 설정 저장 후 같은 프로세스의 미러가 신값을 읽는다.
|
||||
*/
|
||||
public function test_module_settings_mirror_is_refreshed_after_save(): void
|
||||
{
|
||||
$service = app(EcommerceSettingsService::class);
|
||||
|
||||
$basic = $service->getSettings('basic_info');
|
||||
$newName = '미러 확인 상점 '.uniqid();
|
||||
|
||||
$service->saveSettings(['basic_info' => array_merge($basic, ['shop_name' => $newName])]);
|
||||
|
||||
$this->assertSame(
|
||||
$newName,
|
||||
g7_module_settings('sirsoft-ecommerce', 'basic_info.shop_name'),
|
||||
'저장 후에도 미러가 옛 값을 유지합니다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 초기화도 미러를 함께 갱신한다.
|
||||
*/
|
||||
public function test_clear_cache_also_refreshes_mirror(): void
|
||||
{
|
||||
$service = app(EcommerceSettingsService::class);
|
||||
|
||||
config(['g7_settings.modules.sirsoft-ecommerce' => null]);
|
||||
|
||||
$service->clearCache();
|
||||
|
||||
$this->assertIsArray(
|
||||
g7_module_settings('sirsoft-ecommerce'),
|
||||
'캐시 초기화 후 미러가 다시 채워지지 않았습니다'
|
||||
);
|
||||
$this->assertNotEmpty(g7_module_settings('sirsoft-ecommerce'));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,13 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- 페이지 첨부 파일의 최대 개수·용량·허용 형식 설정이 저장해도 반영되지 않던 문제를 수정했습니다. 설정값을 읽어 오는 경로 자체가 없어 항상 내장 기본값이 쓰였습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 페이지 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 발행·발행 취소 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
|
||||
|
||||
## [1.0.2] - 2026-08-10
|
||||
|
||||
### Security
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-page",
|
||||
"description": "Page module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"ko": "페이지",
|
||||
"en": "Page"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
|
||||
"en": "Static page (info/policy/guide) management module"
|
||||
},
|
||||
"g7_version": ">=7.0.6",
|
||||
"g7_version": ">=7.0.7",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -769,6 +769,7 @@
|
||||
"serverTotalPages": "{{pages?.data?.meta?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{pages?.data?.meta?.has_more_pages ?? false}}",
|
||||
"selectable": true,
|
||||
"selectionScope": "page",
|
||||
"selectedCountText": "",
|
||||
"selectedIds": "{{_global.selectedIds_admin_pages || []}}",
|
||||
"idField": "id",
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Services;
|
||||
|
||||
use App\Contracts\Extension\ModuleSettingsInterface;
|
||||
use App\Traits\NormalizesSettingsData;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 페이지 모듈 환경설정 서비스
|
||||
*
|
||||
* 이 서비스가 없던 동안 `g7_settings.modules.sirsoft-page` 미러는 **영구 미존재**였고,
|
||||
* 소비자(`PageAttachmentService`, `UploadPageAttachmentRequest`)는 항상 인자 기본값으로
|
||||
* 폴백했습니다 — 운영자가 설정을 바꿔도 첨부 상한이 반영되지 않았습니다 (공개이슈 #109).
|
||||
*
|
||||
* defaults.json 은 카테고리 맵(`{"attachment": {...}}`) 한 단계 구조입니다.
|
||||
*/
|
||||
class PageSettingsService implements ModuleSettingsInterface
|
||||
{
|
||||
use NormalizesSettingsData;
|
||||
|
||||
/**
|
||||
* 모듈 식별자
|
||||
*/
|
||||
private const MODULE_IDENTIFIER = 'sirsoft-page';
|
||||
|
||||
/**
|
||||
* 설정 기본값 (캐시)
|
||||
*/
|
||||
private ?array $defaults = null;
|
||||
|
||||
/**
|
||||
* 현재 설정값 (캐시)
|
||||
*/
|
||||
private ?array $settings = null;
|
||||
|
||||
/**
|
||||
* 모듈 설정 기본값 파일 경로 반환
|
||||
*
|
||||
* @return string|null defaults.json 파일의 절대 경로, 없으면 null
|
||||
*/
|
||||
public function getSettingsDefaultsPath(): ?string
|
||||
{
|
||||
$path = base_path('modules/'.self::MODULE_IDENTIFIER).'/config/settings/defaults.json';
|
||||
|
||||
return file_exists($path) ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정값 조회
|
||||
*
|
||||
* @param string $key 설정 키 (예: 'attachment.max_count')
|
||||
* @param mixed $default 기본값
|
||||
* @return mixed 설정값
|
||||
*/
|
||||
public function getSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return Arr::get($this->getAllSettings(), $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정값 저장
|
||||
*
|
||||
* @param string $key 설정 키
|
||||
* @param mixed $value 저장할 값
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
public function setSetting(string $key, mixed $value): bool
|
||||
{
|
||||
$settings = $this->getAllSettings();
|
||||
Arr::set($settings, $key, $value);
|
||||
|
||||
$category = explode('.', $key)[0];
|
||||
|
||||
return $this->saveSettings([$category => $settings[$category] ?? []]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 설정 조회
|
||||
*
|
||||
* @return array 모든 카테고리의 설정값
|
||||
*/
|
||||
public function getAllSettings(): array
|
||||
{
|
||||
if ($this->settings !== null) {
|
||||
return $this->settings;
|
||||
}
|
||||
|
||||
$defaultValues = $this->getDefaults();
|
||||
$settings = [];
|
||||
|
||||
foreach ($defaultValues as $category => $categoryDefaults) {
|
||||
if (! is_array($categoryDefaults)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$settings[$category] = array_merge($categoryDefaults, $this->loadCategorySettings($category));
|
||||
}
|
||||
|
||||
$settings = $this->normalizeSettingsData($settings, $defaultValues);
|
||||
|
||||
$this->settings = $settings;
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 설정 조회
|
||||
*
|
||||
* @param string $category 카테고리명
|
||||
* @return array 카테고리의 설정값
|
||||
*/
|
||||
public function getSettings(string $category): array
|
||||
{
|
||||
return $this->getAllSettings()[$category] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 저장
|
||||
*
|
||||
* @param array $settings 저장할 설정 배열
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
public function saveSettings(array $settings): bool
|
||||
{
|
||||
$success = true;
|
||||
$defaultValues = $this->getDefaults();
|
||||
|
||||
foreach ($settings as $category => $categorySettings) {
|
||||
if (str_starts_with((string) $category, '_') || ! is_array($categorySettings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryDefaults = $defaultValues[$category] ?? [];
|
||||
|
||||
// 토글이 꺼진 채 전송되지 않는 boolean 필드는 false 로 채운다
|
||||
foreach ($categoryDefaults as $key => $defaultValue) {
|
||||
if (is_bool($defaultValue) && ! array_key_exists($key, $categorySettings)) {
|
||||
$categorySettings[$key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
$processed = $this->normalizeCategoryData($categorySettings, $categoryDefaults);
|
||||
|
||||
if (! $this->saveCategorySettings($category, $processed)) {
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
|
||||
$this->clearCache();
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프론트엔드용 설정 조회 (민감정보 제외)
|
||||
*
|
||||
* defaults.json 에 frontend_schema 가 없으므로 노출 대상이 없습니다.
|
||||
*
|
||||
* @return array 프론트엔드에 노출 가능한 설정값
|
||||
*/
|
||||
public function getFrontendSettings(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 초기화
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->defaults = null;
|
||||
$this->settings = null;
|
||||
|
||||
// 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
|
||||
g7_refresh_module_settings_config(self::MODULE_IDENTIFIER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본값 조회
|
||||
*
|
||||
* @return array defaults.json 의 카테고리 맵
|
||||
*/
|
||||
private function getDefaults(): array
|
||||
{
|
||||
if ($this->defaults !== null) {
|
||||
return $this->defaults;
|
||||
}
|
||||
|
||||
$path = $this->getSettingsDefaultsPath();
|
||||
|
||||
if ($path === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode(File::get($path), true) ?? [];
|
||||
|
||||
// `_meta`/`defaults` 래퍼 형식(다른 모듈 관례)도 함께 받아 준다
|
||||
$this->defaults = is_array($decoded['defaults'] ?? null) ? $decoded['defaults'] : $decoded;
|
||||
|
||||
return $this->defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 설정 로드
|
||||
*
|
||||
* @param string $category 카테고리명
|
||||
* @return array 저장된 설정값
|
||||
*/
|
||||
private function loadCategorySettings(string $category): array
|
||||
{
|
||||
$path = $this->getCategoryFilePath($category);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return json_decode(File::get($path), true) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 설정 저장
|
||||
*
|
||||
* @param string $category 카테고리명
|
||||
* @param array $settings 설정값
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
private function saveCategorySettings(string $category, array $settings): bool
|
||||
{
|
||||
$storagePath = $this->getStoragePath();
|
||||
|
||||
if (! File::isDirectory($storagePath)) {
|
||||
File::makeDirectory($storagePath, 0755, true);
|
||||
}
|
||||
|
||||
$content = json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return File::put($this->getCategoryFilePath($category), $content) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 설정 파일 경로 반환
|
||||
*
|
||||
* @param string $category 카테고리명
|
||||
* @return string 설정 파일 경로
|
||||
*/
|
||||
private function getCategoryFilePath(string $category): string
|
||||
{
|
||||
return $this->getStoragePath().'/'.$category.'.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 저장 경로 반환
|
||||
*
|
||||
* testing 환경에서는 운영 설정(storage/app/modules/.../settings)을 보호하기 위해
|
||||
* 별도 경로를 사용합니다 — 테스트가 개발/운영 설정을 덮어쓰지 않게 합니다.
|
||||
*
|
||||
* @return string 설정 파일 저장 디렉토리 경로
|
||||
*/
|
||||
private function getStoragePath(): string
|
||||
{
|
||||
if (app()->runningUnitTests()) {
|
||||
return storage_path('framework/testing/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
||||
}
|
||||
|
||||
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Modules\Sirsoft\Page\Http\Requests\UploadPageAttachmentRequest;
|
||||
use Modules\Sirsoft\Page\Models\PageAttachment;
|
||||
use Modules\Sirsoft\Page\Module;
|
||||
use Modules\Sirsoft\Page\Services\PageAttachmentService;
|
||||
use Modules\Sirsoft\Page\Services\PageSettingsService;
|
||||
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
@@ -22,20 +23,25 @@ use Modules\Sirsoft\Page\Tests\ModuleTestCase;
|
||||
class PageAttachmentPolicyTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 첨부 설정을 주입합니다.
|
||||
* 첨부 설정을 저장합니다.
|
||||
*
|
||||
* 모듈 설정은 SettingsServiceProvider 가 `g7_settings.modules.*` config 로 올려 두고
|
||||
* `g7_module_settings()` 로 조회한다 (board 관례).
|
||||
* config 를 직접 주입하지 않고 **설정 서비스를 경유**한다 — 직접 주입하면
|
||||
* 설정이 미러에 도달하는 경로가 통째로 우회되어, 서비스가 없어 미러가 영구
|
||||
* 미존재였던 결함(공개이슈 #109)이 테스트에서 드러나지 않는다.
|
||||
*
|
||||
* @param array $overrides 덮어쓸 설정 값
|
||||
*/
|
||||
private function writeAttachmentSettings(array $overrides = []): void
|
||||
{
|
||||
config(['g7_settings.modules.sirsoft-page.attachment' => array_merge([
|
||||
'max_count' => 5,
|
||||
'max_size_mb' => 10,
|
||||
'allowed_types' => ['image/jpeg', 'image/png', 'application/pdf'],
|
||||
], $overrides)]);
|
||||
config(['g7_settings.modules.sirsoft-page' => null]);
|
||||
|
||||
app(PageSettingsService::class)->saveSettings([
|
||||
'attachment' => array_merge([
|
||||
'max_count' => 5,
|
||||
'max_size_mb' => 10,
|
||||
'allowed_types' => ['image/jpeg', 'image/png', 'application/pdf'],
|
||||
], $overrides),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Tests\Feature;
|
||||
|
||||
use App\Support\ExtensionSettingsMirror;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Modules\Sirsoft\Page\Services\PageSettingsService;
|
||||
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 페이지 모듈 설정 미러 테스트 (공개이슈 #109)
|
||||
*
|
||||
* 이 모듈에는 `ModuleSettingsInterface` 구현체가 없어 `g7_settings.modules.sirsoft-page`
|
||||
* 미러가 영구히 존재하지 않았고, 소비자는 항상 인자 기본값으로 폴백했습니다.
|
||||
* 기존 정책 테스트는 config 를 직접 주입해 우회하고 있어 이 결함이 검출되지 않았습니다 —
|
||||
* 여기서는 **주입 없이** 설정 서비스 경유로 값이 도달하는지를 본다.
|
||||
*
|
||||
* @scenario scope=module, trigger=save, trigger=cache_clear, value_origin=defaults_only
|
||||
*
|
||||
* @effects page_module_mirror_exists, page_module_consumers_read_configured_values, module_mirror_refreshed_after_save
|
||||
*/
|
||||
class PageSettingsMirrorTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 설정 서비스가 defaults.json 을 읽어 전체 설정을 만든다.
|
||||
*/
|
||||
public function test_settings_service_reads_defaults(): void
|
||||
{
|
||||
$settings = app(PageSettingsService::class)->getAllSettings();
|
||||
|
||||
$this->assertArrayHasKey('attachment', $settings);
|
||||
$this->assertArrayHasKey('max_count', $settings['attachment']);
|
||||
$this->assertIsInt($settings['attachment']['max_count']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미러가 채워지고, 소비자가 config 직접 주입 없이 그 값을 읽는다.
|
||||
*/
|
||||
public function test_mirror_is_populated_without_direct_config_injection(): void
|
||||
{
|
||||
// 미러가 비어 있는 상태에서 출발한다
|
||||
Config::set('g7_settings.modules.sirsoft-page', null);
|
||||
$this->assertSame(0, (int) g7_module_settings('sirsoft-page', 'attachment.max_count', 0));
|
||||
|
||||
app(ExtensionSettingsMirror::class)->refreshModule('sirsoft-page');
|
||||
|
||||
$expected = (int) app(PageSettingsService::class)->getSetting('attachment.max_count');
|
||||
|
||||
$this->assertGreaterThan(0, $expected, 'defaults.json 의 max_count 가 0 입니다');
|
||||
$this->assertSame(
|
||||
$expected,
|
||||
(int) g7_module_settings('sirsoft-page', 'attachment.max_count', 0),
|
||||
'미러가 채워지지 않아 소비자가 기본값 폴백을 탑니다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 저장이 같은 프로세스의 미러를 즉시 갱신한다.
|
||||
*/
|
||||
public function test_save_refreshes_the_mirror_in_the_same_process(): void
|
||||
{
|
||||
$service = app(PageSettingsService::class);
|
||||
$current = (int) $service->getSetting('attachment.max_count');
|
||||
$next = $current + 3;
|
||||
|
||||
$service->saveSettings(['attachment' => array_merge(
|
||||
$service->getSettings('attachment'),
|
||||
['max_count' => $next]
|
||||
)]);
|
||||
|
||||
$this->assertSame(
|
||||
$next,
|
||||
(int) g7_module_settings('sirsoft-page', 'attachment.max_count', 0),
|
||||
'저장 후에도 미러가 옛 값을 유지합니다'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
### Fixed
|
||||
|
||||
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
|
||||
- 회원 탈퇴·삭제 시 동의 이력 정리가 중간에 실패하면 일부 항목만 처리된 채 남던 문제를 수정했습니다 — 이제 전부 처리되거나 전부 취소됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
|
||||
## [1.0.2] - 2026-08-10
|
||||
|
||||
|
||||
@@ -295,9 +295,12 @@ class GdprConsentService
|
||||
{
|
||||
$activeConsents = $this->statusRepository->getActiveByUserId($userId);
|
||||
|
||||
foreach ($activeConsents as $consent) {
|
||||
$this->updateConsent($userId, null, $consent->consent_key, false, 'withdraw');
|
||||
}
|
||||
// 루프 중간에 실패하면 일부 항목만 철회된 채 남는다 — 전부 철회되거나 전부 취소되게 한다.
|
||||
DB::transaction(function () use ($userId, $activeConsents) {
|
||||
foreach ($activeConsents as $consent) {
|
||||
$this->updateConsent($userId, null, $consent->consent_key, false, 'withdraw');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,8 +315,11 @@ class GdprConsentService
|
||||
*/
|
||||
public function purgeOnUserDelete(int $userId): void
|
||||
{
|
||||
$this->statusRepository->deleteByUserId($userId);
|
||||
$this->historyRepository->anonymizeForUser($userId);
|
||||
// 삭제만 되고 익명화가 실패하면 감사 이력에 회원 식별자가 그대로 남는다.
|
||||
DB::transaction(function () use ($userId) {
|
||||
$this->statusRepository->deleteByUserId($userId);
|
||||
$this->historyRepository->anonymizeForUser($userId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Gdpr\Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Mockery;
|
||||
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface;
|
||||
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface;
|
||||
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
|
||||
use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 동의 이력 정리 원자성 테스트 (공개이슈 #112)
|
||||
*
|
||||
* 탈퇴 시 항목별 철회는 루프이고, 삭제 시 status 삭제와 history 익명화는 2단계다.
|
||||
* 중간에 실패하면 일부 항목만 철회되거나, 삭제만 되고 감사 이력에 식별자가 남는다.
|
||||
*
|
||||
* @scenario trigger=withdraw, trigger=delete, failure_injection=mid_loop, failure_injection=second_step
|
||||
*
|
||||
* @effects partial_revoke_rolled_back, delete_and_anonymize_are_atomic
|
||||
*/
|
||||
class GdprConsentAtomicityTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 탈퇴 철회 루프가 중간에 실패하면 앞서 철회된 항목도 되돌아간다.
|
||||
*/
|
||||
public function test_revoke_all_on_withdraw_rolls_back_partial_loop(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$service = app(GdprConsentService::class);
|
||||
|
||||
// 동의 2건을 만든 뒤 철회 루프를 중간에서 실패시킨다
|
||||
$service->updateConsents($user->id, null, ['marketing' => true, 'analytics' => true], 'signup');
|
||||
|
||||
$activeBefore = app(GdprUserConsentRepositoryInterface::class)
|
||||
->getActiveByUserId($user->id)
|
||||
->count();
|
||||
|
||||
$this->assertGreaterThanOrEqual(2, $activeBefore, '철회 대상 동의가 2건 이상이어야 합니다');
|
||||
|
||||
// history 기록을 실패시켜 루프 중간에서 예외를 유발한다
|
||||
$failing = Mockery::mock(app(GdprUserConsentHistoryRepositoryInterface::class));
|
||||
$failing->shouldReceive('record')->andThrow(new RuntimeException('이력 기록 실패'));
|
||||
$this->app->instance(GdprUserConsentHistoryRepositoryInterface::class, $failing);
|
||||
|
||||
try {
|
||||
app(GdprConsentService::class)->revokeAllOnWithdraw($user->id);
|
||||
$this->fail('철회 루프 실패가 전파되어야 합니다.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->assertStringContainsString('이력 기록 실패', $e->getMessage());
|
||||
}
|
||||
|
||||
// 실패 시: 앞 항목만 철회되고 뒤 항목은 그대로인 반쪽 상태
|
||||
$this->app->forgetInstance(GdprUserConsentHistoryRepositoryInterface::class);
|
||||
$activeAfter = app(GdprUserConsentRepositoryInterface::class)
|
||||
->getActiveByUserId($user->id)
|
||||
->count();
|
||||
|
||||
$this->assertSame($activeBefore, $activeAfter, '철회가 일부만 반영된 채 남았습니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 시 history 익명화가 실패하면 status 삭제도 되돌아간다.
|
||||
*/
|
||||
public function test_purge_on_user_delete_rolls_back_when_anonymize_fails(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$service = app(GdprConsentService::class);
|
||||
|
||||
$service->updateConsents($user->id, null, ['marketing' => true], 'signup');
|
||||
|
||||
$before = app(GdprUserConsentRepositoryInterface::class)->getActiveByUserId($user->id)->count();
|
||||
$this->assertGreaterThan(0, $before);
|
||||
|
||||
$failing = Mockery::mock(app(GdprUserConsentHistoryRepositoryInterface::class));
|
||||
$failing->shouldReceive('anonymizeForUser')->andThrow(new RuntimeException('익명화 실패'));
|
||||
$this->app->instance(GdprUserConsentHistoryRepositoryInterface::class, $failing);
|
||||
|
||||
try {
|
||||
app(GdprConsentService::class)->purgeOnUserDelete($user->id);
|
||||
$this->fail('익명화 실패가 전파되어야 합니다.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->assertStringContainsString('익명화 실패', $e->getMessage());
|
||||
}
|
||||
|
||||
// 실패 시: 동의 status 는 지워졌는데 감사 이력에는 회원 식별자가 남는 상태
|
||||
$this->app->forgetInstance(GdprUserConsentHistoryRepositoryInterface::class);
|
||||
$this->assertSame(
|
||||
$before,
|
||||
app(GdprUserConsentRepositoryInterface::class)->getActiveByUserId($user->id)->count()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
### Fixed
|
||||
|
||||
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
|
||||
- 회원 탈퇴·삭제 시 본인확인 기록 파기와 이력 익명화 중 한쪽만 처리된 채 남을 수 있던 문제를 수정했습니다 — 이제 두 처리가 함께 완료됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
|
||||
## [1.0.2] - 2026-08-10
|
||||
|
||||
|
||||
+7
-2
@@ -4,6 +4,7 @@ namespace Plugins\Sirsoft\VerificationKginicis\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityLogQueryRepositoryInterface;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityRecordRepositoryInterface;
|
||||
|
||||
@@ -69,7 +70,11 @@ class CleanInicisRecordOnUserDelete implements HookListenerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recordRepository->deleteByUserId($userId);
|
||||
$this->logQueryRepository->anonymizeUserId($userId);
|
||||
// record 파기와 log 익명화는 함께 성공해야 한다 — 한쪽만 끝나면
|
||||
// 파기된 것으로 보이는데 이력에 식별자가 남거나, 그 반대가 된다.
|
||||
DB::transaction(function () use ($userId) {
|
||||
$this->recordRepository->deleteByUserId($userId);
|
||||
$this->logQueryRepository->anonymizeUserId($userId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -4,6 +4,7 @@ namespace Plugins\Sirsoft\VerificationKginicis\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityLogQueryRepositoryInterface;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityRecordRepositoryInterface;
|
||||
|
||||
@@ -60,7 +61,11 @@ class CleanInicisRecordOnUserWithdraw implements HookListenerInterface
|
||||
|
||||
$userId = (int) $user->id;
|
||||
|
||||
$this->recordRepository->deleteByUserId($userId);
|
||||
$this->logQueryRepository->anonymizeUserId($userId);
|
||||
// record 파기와 log 익명화는 함께 성공해야 한다 — 한쪽만 끝나면
|
||||
// 파기된 것으로 보이는데 이력에 식별자가 남거나, 그 반대가 된다.
|
||||
DB::transaction(function () use ($userId) {
|
||||
$this->recordRepository->deleteByUserId($userId);
|
||||
$this->logQueryRepository->anonymizeUserId($userId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\VerificationKginicis\Tests\Feature\Listeners;
|
||||
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\IdentityVerificationLog;
|
||||
use App\Models\User;
|
||||
use App\Services\IdentityLogService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Mockery;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityLogQueryRepositoryInterface;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityRecordRepositoryInterface;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Tests\PluginTestCase;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 본인확인 정리 원자성 + 연쇄 삭제 테스트 (공개이슈 #112 / #110)
|
||||
*
|
||||
* record 파기와 로그 익명화는 함께 성공해야 한다 — 한쪽만 끝나면 파기된 것으로 보이는데
|
||||
* 이력에 식별자가 남거나, 그 반대가 된다.
|
||||
*
|
||||
* 함께, 보관주기 파기(identity:prune-logs)가 challenge 매핑까지 연쇄 삭제하는지 고정한다.
|
||||
*
|
||||
* @scenario trigger=withdraw, trigger=delete, trigger=prune_logs, failure_injection=log_anonymize_fails
|
||||
*
|
||||
* @effects record_purge_rolled_back, half_done_state_prevented, prune_logs_cascades_to_challenge_mappings
|
||||
*/
|
||||
class CleanInicisRecordAtomicityTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 로그 익명화가 실패하면 record 파기도 되돌아간다.
|
||||
*/
|
||||
public function test_record_purge_rolls_back_when_log_anonymize_fails(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->seedRecordForUser($user);
|
||||
|
||||
$this->assertDatabaseHas('inicis_identity_records', ['user_id' => $user->id]);
|
||||
|
||||
// 두 번째 단계(로그 익명화)만 실패시킨다
|
||||
$failing = Mockery::mock(app(InicisIdentityLogQueryRepositoryInterface::class));
|
||||
$failing->shouldReceive('anonymizeUserId')->andThrow(new RuntimeException('익명화 실패'));
|
||||
$this->app->instance(InicisIdentityLogQueryRepositoryInterface::class, $failing);
|
||||
|
||||
try {
|
||||
HookManager::doAction('core.user.after_withdraw', $user);
|
||||
$this->fail('로그 익명화 실패가 전파되어야 합니다.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->assertStringContainsString('익명화 실패', $e->getMessage());
|
||||
}
|
||||
|
||||
// 선행 단계(record 파기)가 원상 복구되어야 한다
|
||||
// (실패 시: record 만 파기되고 이력 익명화는 실패한 반쪽 상태)
|
||||
$this->assertDatabaseHas('inicis_identity_records', ['user_id' => $user->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 훅에서도 같은 원자성이 보장된다.
|
||||
*/
|
||||
public function test_delete_hook_rolls_back_when_log_anonymize_fails(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->seedRecordForUser($user);
|
||||
|
||||
$failing = Mockery::mock(app(InicisIdentityLogQueryRepositoryInterface::class));
|
||||
$failing->shouldReceive('anonymizeUserId')->andThrow(new RuntimeException('익명화 실패'));
|
||||
$this->app->instance(InicisIdentityLogQueryRepositoryInterface::class, $failing);
|
||||
|
||||
try {
|
||||
HookManager::doAction('core.user.before_delete', $user);
|
||||
$this->fail('로그 익명화 실패가 전파되어야 합니다.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->assertStringContainsString('익명화 실패', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->assertDatabaseHas('inicis_identity_records', ['user_id' => $user->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B6 연쇄: 보관주기 파기가 challenge 매핑까지 함께 지운다.
|
||||
*/
|
||||
public function test_prune_logs_cascades_to_challenge_mappings(): void
|
||||
{
|
||||
$expiredLog = $this->seedChallengeLog(now()->subDays(200));
|
||||
$recentLog = $this->seedChallengeLog(now()->subDays(10));
|
||||
|
||||
$this->assertDatabaseHas('inicis_challenge_mappings', ['challenge_id' => $expiredLog]);
|
||||
$this->assertDatabaseHas('inicis_challenge_mappings', ['challenge_id' => $recentLog]);
|
||||
|
||||
app(IdentityLogService::class)->purge(180);
|
||||
|
||||
$this->assertDatabaseMissing('identity_verification_logs', ['id' => $expiredLog]);
|
||||
// 실패 시: 이력은 파기됐는데 challenge 매핑이 고아로 남은 상태
|
||||
$this->assertDatabaseMissing('inicis_challenge_mappings', ['challenge_id' => $expiredLog]);
|
||||
|
||||
// 보관기간 이내 이력과 그 매핑은 보존된다
|
||||
$this->assertDatabaseHas('identity_verification_logs', ['id' => $recentLog]);
|
||||
$this->assertDatabaseHas('inicis_challenge_mappings', ['challenge_id' => $recentLog]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 본인확인 record 를 시드합니다.
|
||||
*
|
||||
* @param User $user 대상 사용자
|
||||
*/
|
||||
private function seedRecordForUser(User $user): void
|
||||
{
|
||||
app(InicisIdentityRecordRepositoryInterface::class)->upsertForUser((int) $user->id, [
|
||||
'name_encrypted' => Crypt::encryptString('홍길동'),
|
||||
'phone_encrypted' => Crypt::encryptString('01012345678'),
|
||||
'birthday_encrypted' => Crypt::encryptString('19900101'),
|
||||
'di_encrypted' => Crypt::encryptString('DI-VAL-'.$user->id),
|
||||
'di_hash' => hash('sha256', 'DI-VAL-'.$user->id),
|
||||
'gender' => 'M',
|
||||
'is_foreigner' => false,
|
||||
'is_adult' => true,
|
||||
'provider_dev_cd' => 'SKT',
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* challenge 로그 + mTxId 매핑을 시드합니다.
|
||||
*
|
||||
* @param Carbon $createdAt 생성 시각
|
||||
* @return string 생성된 challenge UUID
|
||||
*/
|
||||
private function seedChallengeLog($createdAt): string
|
||||
{
|
||||
$log = IdentityVerificationLog::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'provider_id' => 'inicis',
|
||||
'purpose' => 'signup',
|
||||
'channel' => 'sms',
|
||||
'target_hash' => hash('sha256', Str::random()),
|
||||
'status' => IdentityVerificationStatus::Verified->value,
|
||||
'expires_at' => $createdAt,
|
||||
]);
|
||||
$log->forceFill(['created_at' => $createdAt])->saveQuietly();
|
||||
|
||||
DB::table('inicis_challenge_mappings')->insert([
|
||||
'mtxid' => Str::random(18),
|
||||
'challenge_id' => $log->id,
|
||||
'created_at' => $createdAt,
|
||||
]);
|
||||
|
||||
return $log->id;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
### Fixed
|
||||
|
||||
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
|
||||
- 회원 탈퇴·삭제 시 본인확인 기록 파기와 이력 익명화 중 한쪽만 처리된 채 남을 수 있던 문제를 수정했습니다 — 이제 두 처리가 함께 완료됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
|
||||
## [1.0.0] - 2026-08-10
|
||||
|
||||
|
||||
+7
-2
@@ -4,6 +4,7 @@ namespace Plugins\Sirsoft\VerificationNhnkcp\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityLogQueryRepositoryInterface;
|
||||
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityRecordRepositoryInterface;
|
||||
|
||||
@@ -61,7 +62,11 @@ class CleanKcpRecordOnUserDelete implements HookListenerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recordRepository->deleteByUserId($userId);
|
||||
$this->logQueryRepository->anonymizeUserId($userId);
|
||||
// record 파기와 log 익명화는 함께 성공해야 한다 — 한쪽만 끝나면
|
||||
// 파기된 것으로 보이는데 이력에 식별자가 남거나, 그 반대가 된다.
|
||||
DB::transaction(function () use ($userId) {
|
||||
$this->recordRepository->deleteByUserId($userId);
|
||||
$this->logQueryRepository->anonymizeUserId($userId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -4,6 +4,7 @@ namespace Plugins\Sirsoft\VerificationNhnkcp\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityLogQueryRepositoryInterface;
|
||||
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityRecordRepositoryInterface;
|
||||
|
||||
@@ -59,7 +60,11 @@ class CleanKcpRecordOnUserWithdraw implements HookListenerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recordRepository->deleteByUserId($userId);
|
||||
$this->logQueryRepository->anonymizeUserId($userId);
|
||||
// record 파기와 log 익명화는 함께 성공해야 한다 — 한쪽만 끝나면
|
||||
// 파기된 것으로 보이는데 이력에 식별자가 남거나, 그 반대가 된다.
|
||||
DB::transaction(function () use ($userId) {
|
||||
$this->recordRepository->deleteByUserId($userId);
|
||||
$this->logQueryRepository->anonymizeUserId($userId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\VerificationNhnkcp\Tests\Feature\Listeners;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Mockery;
|
||||
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityLogQueryRepositoryInterface;
|
||||
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityRecordRepositoryInterface;
|
||||
use Plugins\Sirsoft\VerificationNhnkcp\Tests\PluginTestCase;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 본인확인 정리 원자성 테스트 (공개이슈 #112)
|
||||
*
|
||||
* record 파기와 로그 익명화는 함께 성공해야 한다 — 한쪽만 끝나면 파기된 것으로 보이는데
|
||||
* 이력에 식별자가 남거나, 그 반대가 된다.
|
||||
*
|
||||
* @scenario trigger=withdraw, trigger=delete, failure_injection=log_anonymize_fails
|
||||
*
|
||||
* @effects record_purge_rolled_back, half_done_state_prevented
|
||||
*/
|
||||
class CleanKcpRecordAtomicityTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 탈퇴 훅: 로그 익명화가 실패하면 record 파기도 되돌아간다.
|
||||
*/
|
||||
public function test_record_purge_rolls_back_when_log_anonymize_fails_on_withdraw(): void
|
||||
{
|
||||
$user = $this->seedUserWithRecord();
|
||||
|
||||
$this->failLogAnonymize();
|
||||
|
||||
try {
|
||||
HookManager::doAction('core.user.after_withdraw', $user);
|
||||
$this->fail('로그 익명화 실패가 전파되어야 합니다.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->assertStringContainsString('익명화 실패', $e->getMessage());
|
||||
}
|
||||
|
||||
// 실패 시: record 만 파기되고 이력에는 식별자가 남는 반쪽 상태
|
||||
$this->assertNotNull(
|
||||
app(KcpIdentityRecordRepositoryInterface::class)->findByUserId($user->id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 훅에서도 같은 원자성이 보장된다.
|
||||
*/
|
||||
public function test_record_purge_rolls_back_when_log_anonymize_fails_on_delete(): void
|
||||
{
|
||||
$user = $this->seedUserWithRecord();
|
||||
|
||||
$this->failLogAnonymize();
|
||||
|
||||
try {
|
||||
HookManager::doAction('core.user.before_delete', $user);
|
||||
$this->fail('로그 익명화 실패가 전파되어야 합니다.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->assertStringContainsString('익명화 실패', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->assertNotNull(
|
||||
app(KcpIdentityRecordRepositoryInterface::class)->findByUserId($user->id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* record 를 가진 사용자를 만듭니다.
|
||||
*
|
||||
* @return User 생성된 사용자
|
||||
*/
|
||||
private function seedUserWithRecord(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
app(KcpIdentityRecordRepositoryInterface::class)->upsertForUser($user->id, [
|
||||
'di_hash' => hash_hmac('sha256', 'DI-ATOMIC-'.$user->id, (string) config('app.key')),
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertNotNull(app(KcpIdentityRecordRepositoryInterface::class)->findByUserId($user->id));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 번째 단계(로그 익명화)만 실패시킵니다.
|
||||
*/
|
||||
private function failLogAnonymize(): void
|
||||
{
|
||||
$failing = Mockery::mock(app(KcpIdentityLogQueryRepositoryInterface::class));
|
||||
$failing->shouldReceive('anonymizeUserId')->andThrow(new RuntimeException('익명화 실패'));
|
||||
|
||||
$this->app->instance(KcpIdentityLogQueryRepositoryInterface::class, $failing);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,15 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.58.4] - 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
#### 레이아웃 렌더링 테스트 하네스가 `named_actions` 를 등록하지 않던 문제
|
||||
|
||||
- `__tests__/utils/layoutTestUtils.ts::createLayoutTest` — 레이아웃 최상위 `named_actions` 를 `ActionDispatcher.setNamedActions()` 로 등록한다. 기존에는 등록 단계가 없어 `actionRef` 참조가 해석되지 않았고, 그 액션은 **경고만 남기고 아무 일도 하지 않은 채** 테스트가 통과했다.
|
||||
- 하네스 자신이 검사 대상의 일부라, 이 누락은 `named_actions` 를 쓰는 레이아웃의 테스트를 통째로 무력화한다 — 액션이 붙지 않았는데도 렌더 단언은 전부 초록이므로 결함이 드러날 통로가 없다.
|
||||
- `setNamedActions` 는 선택 호출(`?.`)이다 — 이 API 가 없는 디스패처 스텁으로 만든 기존 테스트가 깨지지 않게 한다.
|
||||
## [engine-v1.58.3] - 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface LayoutJson {
|
||||
components?: ComponentDefinition[];
|
||||
// 모달 정의
|
||||
modals?: ComponentDefinition[];
|
||||
// 재사용 액션 정의 (actionRef 로 참조)
|
||||
named_actions?: Record<string, any>;
|
||||
// 데이터 소스
|
||||
data_sources?: Array<{
|
||||
id: string;
|
||||
@@ -580,6 +582,13 @@ export function createLayoutTest(
|
||||
state._global = { ...state._global, ...updates };
|
||||
});
|
||||
|
||||
// 레이아웃 최상위 named_actions 등록 (engine-v1.19.0+)
|
||||
// 미등록 시 actionRef 가 해석되지 않아 named_actions 를 쓰는 레이아웃은
|
||||
// 렌더링 테스트에서 액션이 통째로 무반응이 된다 (경고만 남고 실패하지 않는다).
|
||||
if (layoutJson.named_actions) {
|
||||
actionDispatcher.setNamedActions?.(layoutJson.named_actions);
|
||||
}
|
||||
|
||||
// 글로벌 fetch 모킹
|
||||
cleanupFetch = setupGlobalFetchMock(mockApiRegistry);
|
||||
|
||||
|
||||
@@ -1070,6 +1070,30 @@ if (isset($_GET['ajax_action'])) {
|
||||
<span>확장 번들 정리</span>
|
||||
<span class="text-[10px] opacity-60">(ext-bundles:cleanup)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('seo:prune-stats')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>SEO 캐시 통계 정리</span>
|
||||
<span class="text-[10px] opacity-60">(seo:prune-stats)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('schedules:prune-history')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>스케줄 이력 정리</span>
|
||||
<span class="text-[10px] opacity-60">(schedules:prune-history)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('activity-log:prune')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>활동 로그 정리</span>
|
||||
<span class="text-[10px] opacity-60">(activity-log:prune)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('notification-log:prune')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>알림 발송 이력 정리</span>
|
||||
<span class="text-[10px] opacity-60">(notification-log:prune)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('identity:expire-challenges')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>본인인증 challenge 만료 처리</span>
|
||||
<span class="text-[10px] opacity-60">(identity:expire-challenges)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('identity:prune-logs')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>본인인증 이력 파기</span>
|
||||
<span class="text-[10px] opacity-60">(identity:prune-logs)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('geoip:update')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>GeoIP DB 갱신</span>
|
||||
<span class="text-[10px] opacity-60">(geoip:update)</span>
|
||||
|
||||
@@ -32,6 +32,36 @@ Schedule::command('dashboard:broadcast-resources')
|
||||
// 만료된 레이아웃 미리보기 정리 (30분마다)
|
||||
Schedule::command('layout-previews:cleanup')->everyThirtyMinutes();
|
||||
|
||||
/*
|
||||
| 만료 데이터 정리 (일 1회 새벽 배치, 다중 서버 1대만)
|
||||
|
|
||||
| 시각 해석 기준은 사이트 설정 시간대(`general.timezone`)다 —
|
||||
| SettingsServiceProvider 가 `app.schedule_timezone` 을 세팅하고,
|
||||
| Laravel 이 그 값을 Schedule 인스턴스 전체에 일괄 적용한다.
|
||||
| 서머타임이 있는 시간대에서는 전환일에 특정 시각이 건너뛰거나 두 번 발생할 수 있으나,
|
||||
| 아래 정리 배치는 모두 멱등이라 이중 실행이 데이터를 해치지 않고
|
||||
| 건너뛴 날은 다음 날 배치가 흡수한다.
|
||||
|
|
||||
| 아래 begin/end 표식 사이가 정리 예약의 전부다. 등록 전수 회귀 테스트가 이 구간을
|
||||
| 읽어 대상 목록을 도출하므로, 정리 예약을 추가할 때는 반드시 이 안에 둔다.
|
||||
*/
|
||||
// gc-schedules:begin
|
||||
Schedule::command('sanctum:prune-expired', ['--hours' => 24])->dailyAt('04:00')->onOneServer();
|
||||
Schedule::command('auth:clear-resets')->dailyAt('04:05')->onOneServer();
|
||||
Schedule::command('queue:prune-failed')->dailyAt('04:10')->onOneServer();
|
||||
Schedule::command('queue:prune-batches')->dailyAt('04:15')->onOneServer();
|
||||
Schedule::command('notification:cleanup')->dailyAt('04:20')->onOneServer();
|
||||
Schedule::command('ext-bundles:cleanup')->dailyAt('04:25')->onOneServer();
|
||||
Schedule::command('seo:prune-stats')->dailyAt('04:30')->onOneServer();
|
||||
Schedule::command('schedules:prune-history')->dailyAt('04:35')->onOneServer();
|
||||
Schedule::command('identity:prune-logs')->dailyAt('04:40')->onOneServer();
|
||||
Schedule::command('activity-log:prune')->dailyAt('04:45')->onOneServer();
|
||||
Schedule::command('notification-log:prune')->dailyAt('04:50')->onOneServer();
|
||||
|
||||
// 만료 시각이 지난 본인인증 challenge 상태 전환 (비파괴 — 물리 파기는 identity:prune-logs)
|
||||
Schedule::command('identity:expire-challenges')->hourly()->onOneServer();
|
||||
// gc-schedules:end
|
||||
|
||||
// 언어팩 업데이트 확인 (주 1회, GitHub 기반 언어팩 latest_version 갱신)
|
||||
Schedule::command('language-pack:check-updates')
|
||||
->weekly()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user