fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
공개이슈 ··· 및 그 전수 해소분. - 설정 저장·복원·초기화가 상주 프로세스 미러를 갱신하도록 단일 소유자화 - 만료 데이터 자동 정리 예약 12종 신설, 파기는 기본키 배치로 분할 - 회원 탈퇴를 전 경로 원자화하고 관리자 상태변경 경로를 정식 탈퇴로 통일 - 일괄 처리 목록의 선택을 화면에 보이는 행으로 한정(selectionScope) - 비밀번호 정책 문구를 도메인 중립 라벨로 정정 - 정적 검사 2종·검증 스킬 설계 정합성 축 신설
This commit is contained in:
1 parent
49c155509e
commit
4e1ec6e001
136 files changed
+7016
-504
No files matched your search
@@ -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),
|
||||
'저장 후에도 미러가 옛 값을 유지합니다'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user