fix(core,engine): OFFSET 순회 중 필터 컬럼 변경으로 인한 처리 누락 수정
Laravel chunk/each 는 OFFSET 기반이라, 콜백이 행의 필터 소속을 바꾸면 (필터 컬럼을 채우거나 매칭 행을 삭제하면) 다음 조회가 그만큼 앞으로 밀려 미처리 행을 조용히 건너뛴다. 250건/청크 100 이면 정확히 100건이 남는다. 코어·번들 확장 전수 조사로 확정한 6곳을 chunkById(키셋 순회)로 통일했다. 브라우저 검수(28축)에서 계획에 없던 결함 12건이 드러나 같은 사슬에서 수정했다. 이 중 layout_strategy=keep 무효화와 수정 감지 실패 시 "수정 없음" 오보는 사용자 작업 소실에 직결된다. 재발 방지로 audit 룰을 추가했고, 수정 6곳을 원복해 red 6건 / 복원 후 green 0건으로 판별력을 검증했다.
This commit is contained in:
1 parent
1866f0d566
commit
1eedbbdda1
55 files changed
+2764
-198
No files matched your search
@@ -53,6 +53,7 @@
|
||||
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
|
||||
- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
|
||||
- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다.
|
||||
- 게시판 모듈을 제거할 때 게시판별로 자동 생성된 관리자·승인 단계 역할이 1,000개를 넘으면 일부가 삭제되지 않고 남던 문제를 수정했습니다. 남은 역할은 사용하지 않는 게시판의 것이면서도 회원 권한 목록에 계속 노출됐습니다. 이제 개수와 무관하게 전부 정리됩니다. (#84 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
|
||||
## [1.0.2] - 2026-07-14
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ use App\Extension\AbstractModule;
|
||||
use App\Models\Role;
|
||||
use App\Seo\Concerns\LocalizesSeoValues;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Sirsoft\Board\Database\Seeders\BoardTypeSeeder;
|
||||
use Modules\Sirsoft\Board\Listeners\ActivityLogDescriptionResolver;
|
||||
use Modules\Sirsoft\Board\Listeners\BoardActivityLogListener;
|
||||
use Modules\Sirsoft\Board\Listeners\BoardCommentsCountSyncListener;
|
||||
use Modules\Sirsoft\Board\Listeners\BoardNotificationChannelListener;
|
||||
use Modules\Sirsoft\Board\Listeners\BoardNotificationDataListener;
|
||||
use Modules\Sirsoft\Board\Listeners\BoardCommentsCountSyncListener;
|
||||
use Modules\Sirsoft\Board\Listeners\BoardPostsCountSyncListener;
|
||||
use Modules\Sirsoft\Board\Listeners\CommentReplySyncListener;
|
||||
use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener;
|
||||
@@ -22,6 +23,7 @@ use Modules\Sirsoft\Board\Listeners\SearchPostsListener;
|
||||
use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener;
|
||||
use Modules\Sirsoft\Board\Listeners\SeoBoardSettingsCacheListener;
|
||||
use Modules\Sirsoft\Board\Listeners\UserNotificationSettingsListener;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
|
||||
class Module extends AbstractModule
|
||||
{
|
||||
@@ -39,12 +41,17 @@ class Module extends AbstractModule
|
||||
public function uninstall(): bool
|
||||
{
|
||||
// 게시판별 동적 역할 정리 (sirsoft-board.*.manager, sirsoft-board.*.step)
|
||||
// chunkById(키셋 순회) 필수 — each() 는 OFFSET 기반 청크(기본 1000건)라
|
||||
// 콜백이 순회 대상을 삭제하면 다음 페이지의 OFFSET 이 줄어든 결과 집합을 지나쳐
|
||||
// 1000건을 넘는 역할이 그대로 남는다.
|
||||
Role::where('extension_type', 'module')
|
||||
->where('extension_identifier', 'sirsoft-board')
|
||||
->each(function (Role $role) {
|
||||
$role->permissions()->detach();
|
||||
$role->users()->detach();
|
||||
$role->delete();
|
||||
->chunkById(100, function ($roles) {
|
||||
foreach ($roles as $role) {
|
||||
$role->permissions()->detach();
|
||||
$role->users()->detach();
|
||||
$role->delete();
|
||||
}
|
||||
});
|
||||
|
||||
return parent::uninstall();
|
||||
@@ -308,14 +315,14 @@ class Module extends AbstractModule
|
||||
*/
|
||||
public function getDynamicPermissionIdentifiers(): array
|
||||
{
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('boards')) {
|
||||
if (! Schema::hasTable('boards')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$actions = array_keys((array) config('sirsoft-board.board_permission_definitions', []));
|
||||
$module = $this->getIdentifier();
|
||||
$ids = [];
|
||||
foreach (\Modules\Sirsoft\Board\Models\Board::query()->select('slug')->get() as $board) {
|
||||
foreach (Board::query()->select('slug')->get() as $board) {
|
||||
$category = $module.'.'.$board->slug;
|
||||
$ids[] = $category;
|
||||
foreach ($actions as $action) {
|
||||
@@ -335,13 +342,13 @@ class Module extends AbstractModule
|
||||
*/
|
||||
public function getDynamicRoleIdentifiers(): array
|
||||
{
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('boards')) {
|
||||
if (! Schema::hasTable('boards')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$module = $this->getIdentifier();
|
||||
$ids = [];
|
||||
foreach (\Modules\Sirsoft\Board\Models\Board::query()->select('slug')->get() as $board) {
|
||||
foreach (Board::query()->select('slug')->get() as $board) {
|
||||
$ids[] = $module.'.'.$board->slug.'.manager';
|
||||
$ids[] = $module.'.'.$board->slug.'.step';
|
||||
}
|
||||
@@ -356,12 +363,12 @@ class Module extends AbstractModule
|
||||
*/
|
||||
public function getDynamicMenuSlugs(): array
|
||||
{
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('boards')) {
|
||||
if (! Schema::hasTable('boards')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$slugs = [];
|
||||
foreach (\Modules\Sirsoft\Board\Models\Board::query()->select('slug')->get() as $board) {
|
||||
foreach (Board::query()->select('slug')->get() as $board) {
|
||||
$slugs[] = 'board-'.$board->slug;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature;
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Board\Module;
|
||||
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 모듈 제거 시 동적 역할 정리 누락 회귀 테스트.
|
||||
*
|
||||
* 재현 대상 결함: `Role::where(...)->each(callback)` 은 Eloquent 의 OFFSET 기반
|
||||
* chunk 순회(기본 1000건)다. 콜백이 순회 대상 행을 삭제하므로 두 번째 페이지의
|
||||
* OFFSET 1000 이 이미 줄어든 결과 집합을 지나쳐 남은 행을 건너뛴다.
|
||||
*
|
||||
* 따라서 동적 역할이 1000건을 넘는 사이트에서 모듈을 제거하면 초과분이 그대로
|
||||
* 남는다. `chunkById()`(키셋 순회) 로 바꾸면 삭제와 무관하게 전건이 처리된다.
|
||||
*
|
||||
* 시드 건수는 Eloquent `each()` 의 기본 청크 크기(1000)보다 커야 결함이 드러나므로
|
||||
* 1100건을 사용한다.
|
||||
*/
|
||||
class UninstallDynamicRoleCleanupTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 시드 건수 (Eloquent each() 기본 청크 1000 초과 필수)
|
||||
*/
|
||||
private const SEED_COUNT = 1100;
|
||||
|
||||
/**
|
||||
* 권한/사용자 피벗을 연결할 역할 수 (뒤쪽 = OFFSET skip 구간)
|
||||
*/
|
||||
private const PIVOT_TAIL_COUNT = 50;
|
||||
|
||||
/**
|
||||
* 시드 역할 식별자 프리픽스 (정리 대상 식별용)
|
||||
*/
|
||||
private const IDENTIFIER_PREFIX = 'chunkskip';
|
||||
|
||||
private Module $module;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->module = new Module;
|
||||
$this->purgeSeededRoles();
|
||||
}
|
||||
|
||||
/**
|
||||
* ModuleTestCase 는 프로세스당 1회 `migrate:fresh`(DDL) 를 실행하므로 그 케이스의
|
||||
* 트랜잭션이 암묵적으로 커밋된다 — 시드 행이 롤백되지 않고 다음 케이스로 새어
|
||||
* identifier unique 충돌을 일으킨다. 케이스 전후로 명시 정리한다.
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->purgeSeededRoles();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 테스트가 시드한 역할과 그 피벗을 제거합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function purgeSeededRoles(): void
|
||||
{
|
||||
$roleIds = DB::table('roles')
|
||||
->where('identifier', 'LIKE', '%.'.self::IDENTIFIER_PREFIX.'%')
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
if ($roleIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('role_permissions')->whereIn('role_id', $roleIds)->delete();
|
||||
DB::table('user_roles')->whereIn('role_id', $roleIds)->delete();
|
||||
DB::table('roles')->whereIn('id', $roleIds)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판별 동적 역할을 시드합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function seedDynamicRoles(): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
for ($i = 0; $i < self::SEED_COUNT; $i++) {
|
||||
$rows[] = [
|
||||
'identifier' => 'sirsoft-board.chunkskip'.$i.'.manager',
|
||||
'name' => json_encode(['ko' => '게시판 관리자 '.$i, 'en' => 'Board Manager '.$i]),
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'sirsoft-board',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
foreach (array_chunk($rows, 200) as $batch) {
|
||||
DB::table('roles')->insert($batch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 역할 잔여 건수를 반환합니다.
|
||||
*
|
||||
* @return int 잔여 건수
|
||||
*/
|
||||
private function remainingDynamicRoleCount(): int
|
||||
{
|
||||
return Role::where('extension_type', 'module')
|
||||
->where('extension_identifier', 'sirsoft-board')
|
||||
->count();
|
||||
}
|
||||
|
||||
public function test_uninstall_removes_every_dynamic_role_beyond_the_first_chunk(): void
|
||||
{
|
||||
$this->seedDynamicRoles();
|
||||
|
||||
$this->assertSame(
|
||||
self::SEED_COUNT,
|
||||
$this->remainingDynamicRoleCount(),
|
||||
'시드 직후에는 동적 역할이 시드 건수만큼 존재해야 합니다.'
|
||||
);
|
||||
|
||||
$this->assertTrue($this->module->uninstall());
|
||||
|
||||
$this->assertSame(
|
||||
0,
|
||||
$this->remainingDynamicRoleCount(),
|
||||
'OFFSET 순회로 인해 삭제되지 않고 남은 동적 역할이 있습니다 (each → chunkById 필요).'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_uninstall_detaches_permissions_of_roles_in_the_skipped_range(): void
|
||||
{
|
||||
$this->seedDynamicRoles();
|
||||
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => 'sirsoft-board.chunkskip.test'],
|
||||
['name' => ['ko' => '테스트 권한', 'en' => 'Test Permission'], 'type' => 'admin']
|
||||
);
|
||||
|
||||
$tailRoleIds = Role::where('extension_identifier', 'sirsoft-board')
|
||||
->orderByDesc('id')
|
||||
->limit(self::PIVOT_TAIL_COUNT)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
foreach ($tailRoleIds as $roleId) {
|
||||
DB::table('role_permissions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'permission_id' => $permission->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->module->uninstall();
|
||||
|
||||
$this->assertSame(
|
||||
0,
|
||||
DB::table('role_permissions')->whereIn('role_id', $tailRoleIds)->count(),
|
||||
'삭제된 역할의 권한 피벗이 남아 있습니다.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_uninstall_keeps_roles_owned_by_other_extensions(): void
|
||||
{
|
||||
$this->seedDynamicRoles();
|
||||
|
||||
DB::table('roles')->insert([
|
||||
'identifier' => 'sirsoft-other.chunkskip.manager',
|
||||
'name' => json_encode(['ko' => '타 확장 역할', 'en' => 'Other Extension Role']),
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'sirsoft-other',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->module->uninstall();
|
||||
|
||||
$this->assertTrue(
|
||||
Role::where('extension_identifier', 'sirsoft-other')->exists(),
|
||||
'다른 확장이 소유한 역할은 삭제되지 않아야 합니다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
@@ -20,6 +20,9 @@ return new class extends Migration
|
||||
->whereNotNull($column)
|
||||
->where($column, '!=', '')
|
||||
->orderBy('id')
|
||||
// audit:allow offset-chunk-mutates-filter 갱신값 json_encode(['ko'=>…]) 은 항상
|
||||
// `{"ko":…}` 라 non-null·non-empty — 갱신 후에도 whereNotNull + != '' 소속이
|
||||
// 유지되어 결과 집합 크기가 변하지 않는다(OFFSET 밀림 없음).
|
||||
->chunk(500, function ($rows) use ($column) {
|
||||
foreach ($rows as $row) {
|
||||
$value = $row->{$column};
|
||||
@@ -60,6 +63,9 @@ return new class extends Migration
|
||||
DB::table('ecommerce_order_options')
|
||||
->whereNotNull($column)
|
||||
->orderBy('id')
|
||||
// audit:allow offset-chunk-mutates-filter 갱신값 $koValue 는 최악의 경우 빈
|
||||
// 문자열이며 NULL 이 아니다 — 갱신 후에도 whereNotNull 소속이 유지되어
|
||||
// 결과 집합 크기가 변하지 않는다(OFFSET 밀림 없음).
|
||||
->chunk(500, function ($rows) use ($column) {
|
||||
foreach ($rows as $row) {
|
||||
$value = $row->{$column};
|
||||
|
||||
Reference in new issue
Block a user