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:
HeuJung committed 2026-07-31 21:50:45 +09:00
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
+18 -11
View File
@@ -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(),
'다른 확장이 소유한 역할은 삭제되지 않아야 합니다.'
);
}
}
@@ -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};