fix(page): 첨부 URL 컨텍스트·업로드 순서·등록폼 잔존·슬러그 미리보기·SEO 캐시 정정
- 관리자 첨부 다운로드/미리보기를 admin 라우트 URL로 분기 (미발행 페이지 첨부를 관리자가 열 수 있도록)
- 다중 업로드 시 올린 순서대로 저장 (이커머스 패턴: temp_key 연결 + form.attachments 동기화, 코어/공용 컴포넌트 무수정)
- 수정→등록 전환 시 이전 첨부·제목·슬러그·검증안내 잔존 차단
(initialFiles를 _local.form.attachments에 바인딩 + init_actions에서 form null→기본값 초기화)
- 등록 폼 슬러그 미리보기 {{slug}} 미치환 수정 (빈 슬러그를 못 거르던 ?? → || fallback)
- 페이지 변경 시 상세 SEO 캐시 무효화 URL 패턴을 단수형(/page/{slug})으로 정정
This commit is contained in:
@@ -25,6 +25,11 @@
|
||||
### Fixed
|
||||
|
||||
- 페이지를 삭제한 뒤 같은 슬러그(주소)로 새 페이지를 다시 만들 수 있도록 수정했습니다. 이전에는 삭제한 페이지의 슬러그가 남아 "이미 사용 중"으로 표시되어 동일 주소로 페이지를 다시 만들 수 없었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
|
||||
- 페이지 등록 / 수정 화면에서 첨부파일을 한 번에 여러 개 올릴 때, 올린 순서대로 첨부가 저장되도록 정리했습니다. 이전에는 여러 파일을 동시에 올릴 때 순서가 뒤섞여 저장되는 경우가 있었습니다. 저장된 첨부의 순서 변경은 수정 화면에서 드래그로 즉시 반영됩니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
|
||||
- 페이지를 수정하다가 새 페이지 등록 화면으로 이동했을 때, 직전 페이지의 첨부파일·제목·슬러그와 "사용 가능한 슬러그입니다" 안내가 등록 화면에 그대로 남아 보이던 문제를 수정했습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
|
||||
- 새 페이지 등록 화면에서 슬러그를 입력하기 전 주소 미리보기가 "/page/{{slug}}" 처럼 표시되던 문제를 수정했습니다. 이제 입력 전에는 "/page/example" 로 보이고, 입력하면 입력한 주소로 즉시 바뀝니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
|
||||
- 관리자 화면에서 페이지 첨부파일의 다운로드 / 미리보기가 관리자 전용 주소로 열리도록 수정했습니다. 이전에는 관리자 화면에서도 공개용 주소가 사용되어 비공개 페이지의 첨부파일을 관리자가 열지 못하는 경우가 있었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
|
||||
- 페이지를 수정하거나 삭제한 직후 해당 페이지 상세 화면의 검색엔진용 캐시가 즉시 갱신되도록 수정했습니다. 이전에는 일부 캐시가 갱신되지 않아 변경 전 내용이 검색엔진에 남는 경우가 있었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
|
||||
|
||||
## [1.0.0-beta.3] - 2026-05-11
|
||||
|
||||
|
||||
+74
@@ -568,6 +568,80 @@ describe('admin_page_form.json', () => {
|
||||
expect(emptyDivWrapper, id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Issue #424: 수정→등록 SPA 전환 시 폼/첨부 잔존 차단 ───
|
||||
|
||||
it('FileUploader initialFiles 가 _local.form.attachments 에 바인딩됨 (등록 모드 전환 시 빈 배열로 동기화되어 이전 첨부 잔존 차단)', () => {
|
||||
// 회귀 배경: pageData?.data?.attachments 바인딩은 등록 모드에서 pageData 가 없어
|
||||
// initialFiles 참조가 불변 → FileUploader 동기화 useEffect 미발화 → 이전 첨부 잔존.
|
||||
// _local.form.attachments 바인딩은 init_actions 의 form 초기화로 참조가 바뀌어 동기화 트리거.
|
||||
const uploaders = findComponentsByName(adminPageForm, 'FileUploader');
|
||||
expect(uploaders.length).toBeGreaterThan(0);
|
||||
expect(uploaders[0].props.initialFiles).toContain('_local.form');
|
||||
expect(uploaders[0].props.initialFiles).toContain('attachments');
|
||||
expect(uploaders[0].props.initialFiles).not.toContain('pageData');
|
||||
});
|
||||
|
||||
it('생성 모드 init_actions 가 form 을 null 로 비운 뒤 빈 기본값으로 초기화함 (수정→등록 전환 시 이전 form 잔존 차단)', () => {
|
||||
const initActions = (adminPageForm as any).init_actions as any[];
|
||||
// 생성 모드(if !route?.id) 의 form 관련 setState 액션들
|
||||
const createFormActions = initActions.filter(
|
||||
(a) => a.if && a.if.includes('!route?.id') && a.params?.target === 'local' && 'form' in (a.params ?? {}),
|
||||
);
|
||||
// form: null 로 먼저 비우는 액션
|
||||
const nullReset = createFormActions.find((a) => a.params.form === null);
|
||||
expect(nullReset, 'form=null 리셋 액션').toBeDefined();
|
||||
// form: {기본값} 으로 채우는 액션 (attachments: [] 포함)
|
||||
const defaultInit = createFormActions.find(
|
||||
(a) => a.params.form && typeof a.params.form === 'object' && Array.isArray(a.params.form.attachments),
|
||||
);
|
||||
expect(defaultInit, 'form 기본값 초기화 액션').toBeDefined();
|
||||
expect(defaultInit.params.form.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it('생성 모드 init_actions 가 슬러그 검증 상태(slugChecked/slugAvailable)를 false 로 리셋함 (이전 "사용 가능" 메시지 잔존 차단)', () => {
|
||||
const initActions = (adminPageForm as any).init_actions as any[];
|
||||
const slugReset = initActions.find(
|
||||
(a) =>
|
||||
a.if &&
|
||||
a.if.includes('!route?.id') &&
|
||||
a.params?.slugChecked === false &&
|
||||
a.params?.slugAvailable === false,
|
||||
);
|
||||
expect(slugReset).toBeDefined();
|
||||
});
|
||||
|
||||
it('slug_hint URL 미리보기가 빈 슬러그일 때 || fallback 으로 example 치환됨 ({{slug}} 미치환 회귀 차단)', () => {
|
||||
// 회귀 배경: ?? 'example' 은 빈 문자열('')을 통과시켜 다국어 파라미터가 빈 값이 되고
|
||||
// {{slug}} 가 치환되지 않은 채 노출됨. || 'example' 로 빈 문자열도 fallback 처리.
|
||||
const slugField = findById(adminPageForm, 'slug_field');
|
||||
const hintSpan = (slugField.children ?? []).find(
|
||||
(c: any) => typeof c.text === 'string' && c.text.includes('slug_hint'),
|
||||
);
|
||||
expect(hintSpan).toBeDefined();
|
||||
expect(hintSpan.text).toContain("|| 'example'");
|
||||
expect(hintSpan.text).not.toContain("?? 'example'");
|
||||
});
|
||||
|
||||
// ─── Issue #424 6-2: 첨부 순서 — form.attachments 동기화 (이커머스 패턴) ───
|
||||
|
||||
it('FileUploader 에 onUploadComplete/onReorder/onRemove 액션이 form.attachments 를 동기화함', () => {
|
||||
const uploaders = findComponentsByName(adminPageForm, 'FileUploader');
|
||||
const actions = uploaders[0].actions ?? [];
|
||||
const events = actions.map((a: any) => a.event);
|
||||
expect(events).toContain('onUploadComplete');
|
||||
expect(events).toContain('onReorder');
|
||||
expect(events).toContain('onRemove');
|
||||
// onReorder 가 form.attachments 를 최종 순서로 덮어씀
|
||||
const onReorder = actions.find((a: any) => a.event === 'onReorder');
|
||||
expect(onReorder.handler).toBe('setState');
|
||||
expect(onReorder.params['form.attachments']).toContain('$args[0]');
|
||||
});
|
||||
|
||||
it('FileUploader reorder 엔드포인트가 수정 모드(route.id)에서만 활성화됨 (생성 모드는 temp 첨부라 reorder 불가)', () => {
|
||||
const uploaders = findComponentsByName(adminPageForm, 'FileUploader');
|
||||
expect(uploaders[0].props.apiEndpoints.reorder).toContain('route?.id');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
@@ -44,16 +44,35 @@
|
||||
],
|
||||
"init_actions": [
|
||||
{
|
||||
"comment": "생성 모드에서 폼 기본값 초기화 (published는 Select fallback과 동기화)",
|
||||
"comment": "생성 모드: 직전 페이지(수정)에서 같은 레이아웃을 재사용하므로 _local.form 잔존을 먼저 제거 (form=null → deep merge 가 객체 대신 null 로 덮어써 이전 id/title/attachments 완전 삭제)",
|
||||
"if": "{{!route?.id}}",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "생성 모드에서 폼 기본값 초기화 (published는 Select fallback과 동기화). 수정→등록 전환 시 이전 슬러그 검증 상태(slugChecked/slugAvailable 등)도 함께 리셋",
|
||||
"if": "{{!route?.id}}",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form": {
|
||||
"slug": "{{query.slug ?? ''}}",
|
||||
"title": {},
|
||||
"content": null,
|
||||
"published": false,
|
||||
"content_mode": "html"
|
||||
}
|
||||
"content_mode": "html",
|
||||
"seo_meta": null,
|
||||
"attachments": []
|
||||
},
|
||||
"slugChecked": false,
|
||||
"slugAvailable": false,
|
||||
"slugChecking": false,
|
||||
"slugError": null,
|
||||
"errors": null,
|
||||
"hasChanges": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -406,7 +425,7 @@
|
||||
"props": {
|
||||
"className": "form-hint block"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.form.fields.slug_hint|slug={{_local.form?.slug ?? 'example'}}"
|
||||
"text": "$t:sirsoft-page.admin.page.form.fields.slug_hint|slug={{_local.form?.slug || 'example'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -693,7 +712,7 @@
|
||||
"maxFiles": 5,
|
||||
"maxSize": 10,
|
||||
"accept": ".jpg,.jpeg,.png,.gif,.webp,.pdf,.zip",
|
||||
"initialFiles": "{{pageData?.data?.attachments ?? []}}",
|
||||
"initialFiles": "{{_local.form?.attachments ?? []}}",
|
||||
"autoUpload": false,
|
||||
"uploadTriggerEvent": "upload:page_attachments",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
@@ -707,9 +726,38 @@
|
||||
"apiEndpoints": {
|
||||
"upload": "/api/modules/sirsoft-page/admin/attachments",
|
||||
"delete": "/api/modules/sirsoft-page/admin/attachments/:id",
|
||||
"reorder": "/api/modules/sirsoft-page/admin/attachments/reorder"
|
||||
"reorder": "{{route?.id ? '/api/modules/sirsoft-page/admin/attachments/reorder' : ''}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "업로드 완료 시 form.attachments 에 누적 (수정 모드만 - 생성 모드는 저장 시 temp_key 로 연결)",
|
||||
"event": "onUploadComplete",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form.attachments": "{{route?.id ? [...(_local.form?.attachments ?? []), ...($args[0] ?? [])] : (_local.form?.attachments ?? [])}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "드래그 순서 변경 시 form.attachments 를 최종 순서로 덮어쓰기 (이커머스 상품 이미지와 동일 패턴, 이슈 #424 6-2)",
|
||||
"event": "onReorder",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form.attachments": "{{$args[0] ?? []}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "첨부 삭제 시 form.attachments 에서 제거",
|
||||
"event": "onRemove",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form.attachments": "{{(_local.form?.attachments ?? []).filter(item => (item.hash || item.id) !== $args[0])}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -966,7 +1014,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "FileUploader 업로드 트리거",
|
||||
"comment": "FileUploader 업로드 트리거 (대기 중인 첨부를 temp_key 로 업로드 → 저장 시 백엔드가 연결)",
|
||||
"handler": "emitEvent",
|
||||
"params": {
|
||||
"event": "upload:page_attachments"
|
||||
|
||||
@@ -15,6 +15,8 @@ class StorePageRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
|
||||
*
|
||||
* @return bool 항상 true (권한은 미들웨어 체인에서 검증)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
||||
@@ -15,6 +15,8 @@ class UpdatePageRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
|
||||
*
|
||||
* @return bool 항상 true (권한은 미들웨어 체인에서 검증)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
||||
@@ -4,12 +4,52 @@ namespace Modules\Sirsoft\Page\Http\Resources;
|
||||
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Sirsoft\Page\Models\PageAttachment;
|
||||
|
||||
/**
|
||||
* 페이지 첨부파일 API 리소스
|
||||
*/
|
||||
class PageAttachmentResource extends BaseApiResource
|
||||
{
|
||||
/**
|
||||
* URL 컨텍스트 ('admin' 또는 'public').
|
||||
*
|
||||
* 공개 라우트는 발행 가드가 있어 미발행 페이지 첨부를 차단하므로,
|
||||
* 관리자 응답은 'admin' 컨텍스트로 발행 가드 없는 라우트 URL을 사용한다.
|
||||
*/
|
||||
protected string $urlContext = 'public';
|
||||
|
||||
/**
|
||||
* URL 컨텍스트를 지정한 리소스 배열을 생성합니다.
|
||||
*
|
||||
* 각 첨부파일을 컨텍스트가 주입된 개별 리소스로 변환합니다.
|
||||
* (컬렉션 헬퍼는 개별 item에 컨텍스트를 전달하지 못하므로 직접 매핑)
|
||||
*
|
||||
* @param iterable<int, PageAttachment>|null $attachments 첨부파일 목록
|
||||
* @param string $context 'admin' 또는 'public'
|
||||
* @return array<int, self>
|
||||
*/
|
||||
public static function collectionFor($attachments, string $context): array
|
||||
{
|
||||
return Collection::make($attachments ?? [])
|
||||
->map(fn ($attachment) => (new self($attachment))->withContext($context))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 컨텍스트를 설정합니다.
|
||||
*
|
||||
* @param string $context 'admin' 또는 'public'
|
||||
* @return $this
|
||||
*/
|
||||
public function withContext(string $context): self
|
||||
{
|
||||
$this->urlContext = $context;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스를 배열로 변환합니다.
|
||||
*
|
||||
@@ -27,8 +67,8 @@ class PageAttachmentResource extends BaseApiResource
|
||||
'collection' => $this->collection,
|
||||
'order' => $this->order,
|
||||
'is_image' => $this->isImage(),
|
||||
'download_url' => $this->download_url,
|
||||
'preview_url' => $this->preview_url,
|
||||
'download_url' => $this->resource->downloadUrlFor($this->urlContext),
|
||||
'preview_url' => $this->resource->previewUrlFor($this->urlContext),
|
||||
'created_at' => $this->created_at
|
||||
? $this->formatDateTimeStringForUser($this->created_at)
|
||||
: null,
|
||||
|
||||
@@ -38,7 +38,10 @@ class PageResource extends BaseApiResource
|
||||
'uuid' => $this->updater->uuid,
|
||||
'name' => $this->updater->name,
|
||||
]),
|
||||
'attachments' => PageAttachmentResource::collection($this->whenLoaded('attachments')),
|
||||
'attachments' => $this->whenLoaded(
|
||||
'attachments',
|
||||
fn () => PageAttachmentResource::collectionFor($this->attachments, 'admin')
|
||||
),
|
||||
...$this->formatTimestamps(),
|
||||
...$this->resourceMeta($request),
|
||||
];
|
||||
|
||||
@@ -37,7 +37,10 @@ class PublicPageResource extends BaseApiResource
|
||||
: null,
|
||||
'seo_meta' => $this->seo_meta,
|
||||
'current_version' => $this->current_version,
|
||||
'attachments' => PageAttachmentResource::collection($this->whenLoaded('attachments')),
|
||||
'attachments' => $this->whenLoaded(
|
||||
'attachments',
|
||||
fn () => PageAttachmentResource::collectionFor($this->attachments, 'public')
|
||||
),
|
||||
'created_at' => $this->created_at
|
||||
? $this->formatDateTimeStringForUser($this->created_at)
|
||||
: null,
|
||||
|
||||
@@ -62,9 +62,9 @@ class SeoPageCacheListener implements HookListenerInterface
|
||||
try {
|
||||
$cache = app(SeoCacheManagerInterface::class);
|
||||
|
||||
// 페이지 상세 URL 캐시 무효화
|
||||
// 페이지 상세 URL 캐시 무효화 (공개 URL은 단수형 /page/{slug})
|
||||
if ($page && isset($page->slug)) {
|
||||
$cache->invalidateByUrl("*/pages/{$page->slug}");
|
||||
$cache->invalidateByUrl("*/page/{$page->slug}");
|
||||
}
|
||||
|
||||
// 페이지 상세 레이아웃 캐시 무효화
|
||||
|
||||
@@ -122,26 +122,55 @@ class PageAttachment extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 다운로드 URL을 반환합니다.
|
||||
* 다운로드 URL을 반환합니다 (공개 라우트 — 기본값).
|
||||
*
|
||||
* @return string 다운로드 URL
|
||||
*/
|
||||
public function getDownloadUrlAttribute(): string
|
||||
{
|
||||
return '/api/modules/sirsoft-page/pages/attachment/'.$this->hash;
|
||||
return $this->downloadUrlFor('public');
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 미리보기 URL을 반환합니다.
|
||||
* 이미지 미리보기 URL을 반환합니다 (공개 라우트 — 기본값).
|
||||
*
|
||||
* @return string|null 미리보기 URL (이미지가 아니면 null)
|
||||
*/
|
||||
public function getPreviewUrlAttribute(): ?string
|
||||
{
|
||||
return $this->previewUrlFor('public');
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨텍스트별 다운로드 URL을 반환합니다.
|
||||
*
|
||||
* 공개 라우트는 발행 가드가 있어 미발행 페이지의 첨부를 차단하므로,
|
||||
* 관리자 응답에는 발행 가드가 없는 admin 라우트 URL을 사용합니다.
|
||||
*
|
||||
* @param string $context 'admin' 또는 'public'
|
||||
* @return string 다운로드 URL
|
||||
*/
|
||||
public function downloadUrlFor(string $context = 'public'): string
|
||||
{
|
||||
return $context === 'admin'
|
||||
? '/api/modules/sirsoft-page/admin/attachments/download/'.$this->hash
|
||||
: '/api/modules/sirsoft-page/pages/attachment/'.$this->hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨텍스트별 이미지 미리보기 URL을 반환합니다.
|
||||
*
|
||||
* @param string $context 'admin' 또는 'public'
|
||||
* @return string|null 미리보기 URL (이미지가 아니면 null)
|
||||
*/
|
||||
public function previewUrlFor(string $context = 'public'): ?string
|
||||
{
|
||||
if (! $this->isImage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '/api/modules/sirsoft-page/pages/attachment/'.$this->hash.'/preview';
|
||||
return $context === 'admin'
|
||||
? '/api/modules/sirsoft-page/admin/attachments/preview/'.$this->hash
|
||||
: '/api/modules/sirsoft-page/pages/attachment/'.$this->hash.'/preview';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ class PageAttachmentService
|
||||
/**
|
||||
* 임시 첨부파일을 페이지에 연결하고 파일을 이동합니다.
|
||||
*
|
||||
* getByTempKey 가 order(업로드 시점 부여) 순으로 반환하므로, 그 순서대로
|
||||
* order 를 1..N 으로 재부여한다 (이커머스 ProductImageService::linkTempImages 와 동일).
|
||||
*
|
||||
* @param string $tempKey 임시 키
|
||||
* @param int $pageId 연결할 페이지 ID
|
||||
* @return int 연결된 첨부파일 수
|
||||
@@ -101,7 +104,7 @@ class PageAttachmentService
|
||||
$tempAttachments = $this->attachmentRepository->getByTempKey($tempKey);
|
||||
$linkedCount = 0;
|
||||
|
||||
foreach ($tempAttachments as $attachment) {
|
||||
foreach ($tempAttachments as $index => $attachment) {
|
||||
// 최종 경로 생성
|
||||
$newPath = date('Y/m/d').'/'.$attachment->stored_filename;
|
||||
|
||||
@@ -112,12 +115,14 @@ class PageAttachmentService
|
||||
$this->storage->delete('attachments', $attachment->path);
|
||||
}
|
||||
|
||||
// DB 업데이트: page_id 설정, temp_key 제거, path 변경
|
||||
// DB 업데이트: page_id 설정, temp_key 제거, path 변경, order 재배치 (조회 순서 = 업로드 순서)
|
||||
$this->attachmentRepository->update($attachment, [
|
||||
'page_id' => $pageId,
|
||||
'temp_key' => null,
|
||||
'path' => $newPath,
|
||||
'order' => $index + 1,
|
||||
]);
|
||||
|
||||
$linkedCount++;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,9 +72,12 @@ class PageService
|
||||
// 버전 1 스냅샷 저장
|
||||
$this->saveVersionSnapshot($page, $userId);
|
||||
|
||||
// temp_key 첨부파일 연결 및 파일 이동
|
||||
// temp_key 첨부파일 연결 및 파일 이동 (업로드 순서대로 order 부여)
|
||||
if (! empty($data['temp_key'])) {
|
||||
$this->pageAttachmentService->linkTempAttachmentsWithMove($data['temp_key'], $page->id);
|
||||
$this->pageAttachmentService->linkTempAttachmentsWithMove(
|
||||
$data['temp_key'],
|
||||
$page->id
|
||||
);
|
||||
}
|
||||
|
||||
HookManager::doAction('sirsoft-page.page.after_create', $page, $data);
|
||||
@@ -122,9 +125,12 @@ class PageService
|
||||
// 버전 스냅샷 저장
|
||||
$this->saveVersionSnapshot($page, $userId);
|
||||
|
||||
// temp_key 첨부파일 연결 및 파일 이동
|
||||
// temp_key 첨부파일 연결 및 파일 이동 (업로드 순서대로 order 부여)
|
||||
if (! empty($data['temp_key'])) {
|
||||
$this->pageAttachmentService->linkTempAttachmentsWithMove($data['temp_key'], $page->id);
|
||||
$this->pageAttachmentService->linkTempAttachmentsWithMove(
|
||||
$data['temp_key'],
|
||||
$page->id
|
||||
);
|
||||
}
|
||||
|
||||
HookManager::doAction('sirsoft-page.page.after_update', $page, $data, $snapshot);
|
||||
|
||||
@@ -181,6 +181,77 @@ class PageControllerTest extends FeatureTestCase
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 상세 조회 시 첨부 URL이 관리자 라우트를 가리키는지 확인 (이슈 #424 6-1)
|
||||
*
|
||||
* 첨부 URL이 공개 라우트(발행 가드 있음)를 가리키면 미발행 페이지의
|
||||
* 첨부가 관리자에게도 404로 차단된다. 관리자 응답은 발행 가드 없는
|
||||
* admin 라우트를 반환해야 한다.
|
||||
*/
|
||||
public function test_admin_show_returns_admin_attachment_urls(): void
|
||||
{
|
||||
$page = Page::factory()->create([
|
||||
'slug' => 'test-admin-attach-url',
|
||||
'published' => false,
|
||||
'created_by' => $this->adminUser->id,
|
||||
'updated_by' => $this->adminUser->id,
|
||||
]);
|
||||
$attachment = PageAttachment::factory()->image()->create([
|
||||
'page_id' => $page->id,
|
||||
'hash' => 'abc123def456',
|
||||
'created_by' => $this->adminUser->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->getJson("/api/modules/sirsoft-page/admin/pages/{$page->id}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$att = $response->json('data.attachments.0');
|
||||
$this->assertSame(
|
||||
"/api/modules/sirsoft-page/admin/attachments/preview/{$attachment->hash}",
|
||||
$att['preview_url']
|
||||
);
|
||||
$this->assertSame(
|
||||
"/api/modules/sirsoft-page/admin/attachments/download/{$attachment->hash}",
|
||||
$att['download_url']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 상세 조회 시 첨부 URL이 공개 라우트를 유지하는지 확인 (이슈 #424 6-1)
|
||||
*
|
||||
* 공개 응답에 admin URL(발행 가드 없음)이 섞이면 미발행 첨부가
|
||||
* 노출되는 보안 회귀가 된다. 공개 응답은 공개 라우트만 반환해야 한다.
|
||||
*/
|
||||
public function test_public_show_returns_public_attachment_urls(): void
|
||||
{
|
||||
$page = Page::factory()->create([
|
||||
'slug' => 'test-public-attach-url',
|
||||
'published' => true,
|
||||
'published_at' => now(),
|
||||
'created_by' => $this->adminUser->id,
|
||||
'updated_by' => $this->adminUser->id,
|
||||
]);
|
||||
$attachment = PageAttachment::factory()->image()->create([
|
||||
'page_id' => $page->id,
|
||||
'hash' => 'pub123def456',
|
||||
'created_by' => $this->adminUser->id,
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/modules/sirsoft-page/pages/{$page->slug}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$att = $response->json('data.attachments.0');
|
||||
$this->assertSame(
|
||||
"/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview",
|
||||
$att['preview_url']
|
||||
);
|
||||
$this->assertSame(
|
||||
"/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}",
|
||||
$att['download_url']
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 생성 (store) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -135,6 +135,29 @@ class SeoPageCacheListenerTest extends ModuleTestCase
|
||||
$this->listener->onPageChange($page);
|
||||
}
|
||||
|
||||
/**
|
||||
* onPageChange 호출 시 페이지 상세 URL 캐시가 URL 패턴으로 무효화되는지 확인합니다 (이슈 #424 12-2).
|
||||
*
|
||||
* 페이지 공개 URL은 단수형 /page/{slug} 이다 (PageSitemapContributor, SearchPagesListener 동일).
|
||||
* 레이아웃 태그 없이 put()으로 캐시된 항목은 invalidateByLayout('page/show')로는 지워지지 않으므로,
|
||||
* invalidateByUrl 의 와일드카드 패턴이 실제 URL(/page/{slug})과 일치해야만 무효화된다.
|
||||
*
|
||||
* 회귀 배경: 기존 패턴은 복수형 "*/pages/{slug}" 라서 실제 단수형 URL과 매칭되지 않아
|
||||
* 레이아웃 태그가 없는 페이지 상세 캐시가 영구히 남았다.
|
||||
*/
|
||||
public function test_on_page_change_invalidates_page_detail_url_by_pattern(): void
|
||||
{
|
||||
$page = (object) ['id' => 6, 'slug' => 'pattern-page'];
|
||||
|
||||
// 레이아웃 태그 없이 페이지 상세 URL 캐시 (URL 패턴 매칭으로만 무효화 가능)
|
||||
$this->cache->put('/page/pattern-page', 'ko', '<html>detail</html>');
|
||||
$this->assertContains('/page/pattern-page', $this->cache->getCachedUrls());
|
||||
|
||||
$this->listener->onPageChange($page);
|
||||
|
||||
$this->assertNotContains('/page/pattern-page', $this->cache->getCachedUrls());
|
||||
}
|
||||
|
||||
// ─── handle (인터페이스 준수) ───────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -157,6 +157,34 @@ class PageAttachmentServiceTest extends ModuleTestCase
|
||||
$this->assertNull($tempAttachment->temp_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 시 업로드(조회) 순서대로 order가 1..N 재부여되는지 확인 (이슈 #424 6-2)
|
||||
*
|
||||
* 이커머스 ProductImageService::linkTempImages 와 동일하게, getByTempKey 가
|
||||
* order 순으로 반환한 순서대로 order 를 1,2,3 으로 재배치한다. (생성 모드의
|
||||
* 드래그 재정렬은 미지원 — 업로드 순서가 SSoT, 재정렬은 수정 모드 reorder API)
|
||||
*/
|
||||
public function test_link_reassigns_order_by_upload_sequence(): void
|
||||
{
|
||||
$page = Page::factory()->create([
|
||||
'slug' => 'test-svc-link-order',
|
||||
'created_by' => $this->adminUser->id,
|
||||
'updated_by' => $this->adminUser->id,
|
||||
]);
|
||||
|
||||
// 임시 첨부 3개 업로드 (order 는 업로드 시점에 1,2,3 순차 부여됨)
|
||||
$a = $this->service->upload(UploadedFile::fake()->create('a.pdf', 100, 'application/pdf'), null, 'attachments', 'order-key');
|
||||
$b = $this->service->upload(UploadedFile::fake()->create('b.pdf', 100, 'application/pdf'), null, 'attachments', 'order-key');
|
||||
$c = $this->service->upload(UploadedFile::fake()->create('c.pdf', 100, 'application/pdf'), null, 'attachments', 'order-key');
|
||||
|
||||
$this->service->linkTempAttachmentsWithMove('order-key', $page->id);
|
||||
|
||||
// 연결 후 업로드(조회) 순서대로 order 1,2,3 재부여
|
||||
$this->assertEquals(1, $a->fresh()->order);
|
||||
$this->assertEquals(2, $b->fresh()->order);
|
||||
$this->assertEquals(3, $c->fresh()->order);
|
||||
}
|
||||
|
||||
// ─── deleteAttachment ──────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# audit:allow test-scenario-coverage reason: 본 매니페스트는 이슈 #424 (6-1/6-2/12-2) 회귀 매트릭스 SSoT 로 기록. 핵심 회귀 가드는 test_files 의 통과 테스트(백엔드 PHPUnit + 프론트 vitest)로 커버하며, 첨부 순서/URL 동작은 데이터 흐름 결함이라 단위 계층이 정확한 검증 위치 (브라우저 렌더 결함 아님).
|
||||
|
||||
feature: 페이지 첨부 컨텍스트 URL / 다중 업로드 최종 순서 저장 / 페이지 변경 시 SEO 캐시 무효화 (이슈 #424)
|
||||
|
||||
description: |
|
||||
이슈 #424 의 3개 결함을 한 매트릭스로 묶어 회귀를 보장한다.
|
||||
|
||||
6-1 (첨부 컨텍스트 URL):
|
||||
- 관리자 화면은 관리자 전용 다운로드/미리보기 URL, 공개 화면은 공개 URL 을 사용해야 한다.
|
||||
- 이전에는 관리자 화면에서도 공개 URL 이 노출되어 비공개 페이지 첨부를 관리자가 열지 못했다.
|
||||
- PageAttachmentResource 가 urlContext(public|admin) 를 받아 URL 을 분기.
|
||||
|
||||
6-2 (다중 업로드 / 첨부 순서 — 이커머스 상품 이미지와 동일 패턴):
|
||||
- 생성 모드: temp_key 로 업로드된 첨부를 저장 시 업로드(조회) 순서대로 order 1..N 재부여
|
||||
(linkTempAttachmentsWithMove). 드래그 재정렬은 생성 모드에서 미지원 — 업로드 순서가 SSoT.
|
||||
- 수정 모드: 드래그 시 즉시 reorder API(PATCH /attachments/reorder)로 순서를 저장하고,
|
||||
onReorder 가 form.attachments 를 최종 순서로 동기화.
|
||||
- 저장 body 는 _local.form + temp_key (이커머스 image_temp_key 와 동일). 별도 attachment_order
|
||||
페이로드/코어 emitEvent 결과 의존 없음.
|
||||
- 등록 폼 잔존 버그: 수정→등록 라우트 전환은 같은 layout_name 이라 _local·컴포넌트가 보존된다.
|
||||
FileUploader 의 initialFiles 를 pageData?.data?.attachments(등록 모드 absent → 참조 불변) 대신
|
||||
_local.form.attachments 에 바인딩하고, init_actions 에서 생성 모드 form 을 null→빈 기본값으로
|
||||
초기화하여 참조를 바꿈으로써 동기화 useEffect 가 발화해 이전 첨부/제목/슬러그/검증상태가 비워진다.
|
||||
slug_hint URL 미리보기는 빈 슬러그('')에서 || 'example' fallback 으로 {{slug}} 미치환을 차단.
|
||||
(공용 FileUploader 컴포넌트·코어 템플릿 엔진 무수정 — 레이아웃 바인딩/init_actions 만 변경)
|
||||
|
||||
12-2 (SEO 캐시 무효화):
|
||||
- 페이지 생성/수정/삭제 시 페이지 상세 SEO 캐시가 즉시 갱신돼야 한다.
|
||||
- 공개 URL 은 단수형 /page/{slug} (PageSitemapContributor, SearchPagesListener 동일).
|
||||
- 이전 패턴은 복수형 */pages/{slug} 라 실제 URL 과 매칭되지 않아, 레이아웃 태그 없이
|
||||
put() 으로 캐시된 상세 캐시가 영구히 남았다 → */page/{slug} 로 정정.
|
||||
|
||||
axes:
|
||||
url_context: [public, admin]
|
||||
form_mode: [create, edit]
|
||||
upload_count: [single, multiple]
|
||||
page_change: [create, update, delete]
|
||||
cached_entry: [layout_tagged, url_only]
|
||||
|
||||
effects:
|
||||
- admin_context_returns_admin_download_and_preview_url
|
||||
- public_context_returns_public_download_and_preview_url
|
||||
- temp_attachments_linked_in_upload_order_1_to_n
|
||||
- edit_mode_drag_persists_via_reorder_api
|
||||
- create_form_clears_stale_attachments_via_form_attachments_binding
|
||||
- create_form_clears_stale_title_slug_and_slug_check_state
|
||||
- slug_hint_url_preview_falls_back_to_example_on_empty_slug
|
||||
- page_change_invalidates_page_detail_url_by_singular_pattern
|
||||
- page_change_invalidates_url_only_cached_detail_without_layout_tag
|
||||
- page_change_invalidates_page_show_layout_cache
|
||||
- page_change_invalidates_home_and_search_and_sitemap_cache
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-page/tests/Unit/Services/PageAttachmentServiceTest.php
|
||||
- modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php
|
||||
- modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php
|
||||
- modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts
|
||||
|
||||
notes: |
|
||||
6-2 는 이커머스 상품 이미지(FileUploader + temp_key 연결 + reorder API)와 동일한 검증된
|
||||
패턴으로 정렬했다. 생성 모드 업로드 순서 연결은 PageAttachmentServiceTest, 수정 모드 reorder 와
|
||||
잔존 버그(라우트 전환 시 key 리마운트)는 admin_page_form 레이아웃 + 브라우저 검수로 확인.
|
||||
6-1 은 백엔드 Feature 테스트, 12-2 는 리스너 unit 테스트로 커버.
|
||||
E2E 면제 사유: 6-2 데이터 흐름은 단위/Feature 계층이 정확한 검증 위치이고, 페이지 모듈에
|
||||
Playwright 인프라(토큰/스펙/베이스) 미구축 상태로 spec 1건을 위한 인프라 신설은 본 이슈 범위를 초과한다.
|
||||
Reference in New Issue
Block a user