fix(page): 페이지·첨부 삭제를 물리 삭제로 전환해 슬러그 재사용 가능하도록 수정

페이지 삭제 후 동일 슬러그로 재생성 시 "이미 사용 중" 오류가 나던 문제 수정.
슬러그 중복 체크(SoftDeletes 스코프로 삭제 행 제외)와 생성 검증(Rule::unique로
삭제 행 포함)이 soft delete를 다르게 취급해 발생. Page/PageAttachment의
SoftDeletes를 제거하고 deleted_at 컬럼을 마이그레이션으로 정리해 두 경로를 정합화.
This commit is contained in:
chym1217
2026-07-06 09:37:01 +09:00
parent d6baa1c299
commit b03042b6d7
10 changed files with 218 additions and 15 deletions
@@ -22,6 +22,10 @@
- 페이지 등록 / 수정 화면의 입력 행 컨테이너를 sirsoft-admin_basic 표준 시맨틱 (.row-stack) 과 정합 — 행 사이 옅은 회색 구분선(1px) 이 자동 적용되어 페이지 폼의 행 묶음 가독성 개선.
### Fixed
- 페이지를 삭제한 뒤 같은 슬러그(주소)로 새 페이지를 다시 만들 수 있도록 수정했습니다. 이전에는 삭제한 페이지의 슬러그가 남아 "이미 사용 중"으로 표시되어 동일 주소로 페이지를 다시 만들 수 없었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
## [1.0.0-beta.3] - 2026-05-11
### Changed
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('pages') || ! Schema::hasColumn('pages', 'deleted_at')) {
return;
}
// 잔존 soft-delete 페이지 행을 물리 삭제한다.
// page_attachments.page_id 의 cascadeOnDelete FK 로 연관 첨부 행도 함께 제거된다.
// (첨부 물리 파일은 페이지 삭제 시점에 이미 제거된 상태 — 여기서는 DB 행만 정리)
DB::table('pages')->whereNotNull('deleted_at')->delete();
Schema::table('pages', function (Blueprint $table) {
// 소프트 삭제 컬럼 제거 (페이지는 물리 삭제 방식으로 전환)
$table->dropSoftDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasTable('pages') || Schema::hasColumn('pages', 'deleted_at')) {
return;
}
Schema::table('pages', function (Blueprint $table) {
// 소프트 삭제 컬럼 복원
$table->softDeletes();
});
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('page_attachments') || ! Schema::hasColumn('page_attachments', 'deleted_at')) {
return;
}
// 잔존 soft-delete 첨부 행을 물리 삭제한다.
// (물리 파일은 첨부 삭제 시점에 이미 제거된 상태 — 여기서는 DB 행만 정리)
DB::table('page_attachments')->whereNotNull('deleted_at')->delete();
Schema::table('page_attachments', function (Blueprint $table) {
// deleted_at 인덱스 제거 (소프트 삭제 미사용으로 불필요)
$table->dropIndex(['deleted_at']);
// 소프트 삭제 컬럼 제거 (첨부는 물리 삭제 방식으로 전환)
$table->dropSoftDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasTable('page_attachments') || Schema::hasColumn('page_attachments', 'deleted_at')) {
return;
}
Schema::table('page_attachments', function (Blueprint $table) {
// 소프트 삭제 컬럼 복원
$table->softDeletes()->comment('소프트 삭제 일시');
// deleted_at 인덱스 복원
$table->index('deleted_at');
});
}
};
@@ -11,7 +11,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
use Modules\Sirsoft\Page\Database\Factories\PageFactory;
@@ -19,7 +18,6 @@ class Page extends Model implements FulltextSearchable
{
use HasFactory;
use Searchable;
use SoftDeletes;
/** @var array<string, array> 활동 로그 추적 필드 */
public static array $activityLogFields = [
@@ -6,13 +6,11 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\Sirsoft\Page\Database\Factories\PageAttachmentFactory;
class PageAttachment extends Model
{
use HasFactory;
use SoftDeletes;
/**
* 팩토리 클래스를 반환합니다.
@@ -174,7 +174,7 @@ class PageAttachmentControllerTest extends FeatureTestCase
$response->assertStatus(200)
->assertJsonPath('success', true);
$this->assertSoftDeleted('page_attachments', ['id' => $attachment->id]);
$this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]);
}
/**
@@ -14,6 +14,7 @@ use App\Models\User;
use App\Search\Engines\DatabaseFulltextEngine;
use Laravel\Scout\EngineManager;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageAttachment;
use Modules\Sirsoft\Page\Models\PageVersion;
use Modules\Sirsoft\Page\Tests\FeatureTestCase;
@@ -383,7 +384,7 @@ class PageControllerTest extends FeatureTestCase
$response->assertStatus(200);
$this->assertDatabaseHas('pages', ['id' => $page->id, 'slug' => 'test-slug-new']);
$this->assertDatabaseMissing('pages', ['slug' => 'test-slug-old', 'deleted_at' => null]);
$this->assertDatabaseMissing('pages', ['slug' => 'test-slug-old']);
}
/**
@@ -593,7 +594,7 @@ class PageControllerTest extends FeatureTestCase
// ─── 삭제 (destroy) ────────────────────────────────
/**
* 페이지를 삭제(소프트 삭제)할 수 있는지 확인
* 페이지를 삭제(물리 삭제)할 수 있는지 확인
*/
public function test_admin_can_delete_page(): void
{
@@ -609,8 +610,8 @@ class PageControllerTest extends FeatureTestCase
$response->assertStatus(200)
->assertJsonPath('success', true);
// 소프트 삭제 확인
$this->assertSoftDeleted('pages', ['id' => $page->id]);
// 물리 삭제 확인 (soft delete 미사용 — 행이 실제로 제거됨)
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
}
/**
@@ -624,6 +625,117 @@ class PageControllerTest extends FeatureTestCase
$response->assertStatus(404);
}
/**
* 페이지 삭제 후 동일 슬러그로 재생성할 수 있는지 확인 (#424-20 회귀)
*
* soft delete 시절에는 삭제된 페이지의 slug 가 잔존하여 동일 slug 재생성 시
* Rule::unique 가 422 를 반환했다. hard delete 전환으로 재생성이 성공해야 한다.
*/
public function test_can_recreate_page_with_same_slug_after_delete(): void
{
$slug = 'test-reuse-slug';
// 1) 최초 생성
$first = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-page/admin/pages', [
'slug' => $slug,
'title' => ['ko' => '원본 페이지', 'en' => 'Original'],
'content' => ['ko' => '본문', 'en' => 'Content'],
'content_mode' => 'html',
]);
$first->assertStatus(201);
$firstId = $first->json('data.id');
// 2) 삭제
$this->actingAs($this->adminUser)
->deleteJson("/api/modules/sirsoft-page/admin/pages/{$firstId}")
->assertStatus(200);
$this->assertDatabaseMissing('pages', ['id' => $firstId]);
// 3) 동일 slug 로 재생성 — 성공해야 함
$second = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-page/admin/pages', [
'slug' => $slug,
'title' => ['ko' => '재생성 페이지', 'en' => 'Recreated'],
'content' => ['ko' => '본문', 'en' => 'Content'],
'content_mode' => 'html',
]);
$second->assertStatus(201)
->assertJsonPath('data.slug', $slug);
}
/**
* 페이지 삭제 후 슬러그 중복 체크와 생성 검증 결과가 일치하는지 확인 (#424-20 회귀)
*
* 삭제 후 check-slug 는 "사용 가능", 생성도 성공해야 한다 (두 경로 정합).
*/
public function test_check_slug_and_create_are_consistent_after_delete(): void
{
$slug = 'test-consistent-slug';
$first = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-page/admin/pages', [
'slug' => $slug,
'title' => ['ko' => '원본', 'en' => 'Original'],
'content_mode' => 'html',
]);
$first->assertStatus(201);
$firstId = $first->json('data.id');
$this->actingAs($this->adminUser)
->deleteJson("/api/modules/sirsoft-page/admin/pages/{$firstId}")
->assertStatus(200);
// check-slug 는 사용 가능(exists=false)으로 응답
$check = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-page/admin/pages/check-slug', [
'slug' => $slug,
]);
$check->assertStatus(200)
->assertJsonPath('data.exists', false);
// 생성도 성공 (check-slug 와 동일 결론)
$create = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-page/admin/pages', [
'slug' => $slug,
'title' => ['ko' => '재생성', 'en' => 'Recreated'],
'content_mode' => 'html',
]);
$create->assertStatus(201);
}
/**
* 첨부가 있는 페이지를 물리 삭제해도 삭제 흐름(첨부 정리 + 활동로그 기록)이 정상 완료되는지 확인 (#424-20)
*
* 활동로그 리스너는 삭제되는 페이지를 loggable 로 참조한다. hard delete 후 loggable 행이
* 사라지더라도 삭제 응답이 200 이고 페이지/첨부 행이 모두 물리 삭제되어야 한다.
*/
public function test_deleting_page_with_attachment_completes_without_error(): void
{
$page = Page::factory()->create([
'slug' => 'test-delete-with-attach',
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$attachment = PageAttachment::factory()->create([
'page_id' => $page->id,
'created_by' => $this->adminUser->id,
]);
$response = $this->actingAs($this->adminUser)
->deleteJson("/api/modules/sirsoft-page/admin/pages/{$page->id}");
$response->assertStatus(200)
->assertJsonPath('success', true);
// 페이지 + 첨부 모두 물리 삭제
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
$this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]);
}
// ─── 발행 토글 (publish) ───────────────────────────
/**
@@ -68,7 +68,7 @@ class PageRepositorySearchTest extends TestCase
*/
protected function tearDown(): void
{
Page::query()->withTrashed()->forceDelete();
Page::query()->forceDelete();
if (isset($this->user)) {
User::where('id', $this->user->id)->delete();
@@ -162,7 +162,7 @@ class PageAttachmentServiceTest extends ModuleTestCase
/**
* 첨부파일을 삭제할 수 있는지 확인
*/
public function test_delete_attachment_soft_deletes(): void
public function test_delete_attachment_hard_deletes(): void
{
$page = Page::factory()->create([
'slug' => 'test-svc-del-attach',
@@ -176,7 +176,7 @@ class PageAttachmentServiceTest extends ModuleTestCase
$result = $this->service->deleteAttachment($attachment);
$this->assertTrue($result);
$this->assertSoftDeleted('page_attachments', ['id' => $attachment->id]);
$this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]);
}
// ─── reorder ───────────────────────────────────────
@@ -144,16 +144,16 @@ class PageServiceTest extends ModuleTestCase
// ─── deletePage ────────────────────────────────────
/**
* 페이지를 소프트 삭제할 수 있는지 확인
* 페이지를 물리 삭제할 수 있는지 확인
*/
public function test_delete_page_soft_deletes(): void
public function test_delete_page_hard_deletes(): void
{
$page = $this->createTestPage('test-svc-delete');
$result = $this->service->deletePage($page);
$this->assertTrue($result);
$this->assertSoftDeleted('pages', ['id' => $page->id]);
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
}
// ─── changePublishStatus ───────────────────────────