feat(content): 본문 이미지 목록 썸네일·og:image 폴백 전 티어 구현
공개 이슈 https://github.com/gnuboard/g7/issues/22 — 본문에 삽입한 이미지가 카드/갤러리 목록 썸네일과 공유 미리보기(og:image)에 반영되지 않던 공백을 코어+3모듈+2템플릿에서 해소. - 코어 7.0.9: HtmlImageExtractor(내부 이미지 한정, origin 정규화 SSoT 재사용) + seo.og_image_default 사이트 기본 공유 이미지 설정 + SeoMetaResolver 폴백 체인 - board 1.1.0 / ecommerce 1.2.0 / page 1.1.0: content_thumbnail_url 캐시 컬럼, saving 추출(html 모드 한정·첨부 우선·비밀글 게이트 유지), filter_content_thumbnail 훅, 기존 데이터 백필 업그레이드 스텝 - ecommerce 카테고리 og:image 생산자 신설, page og:description 死키 정정 - sirsoft-basic 1.1.2 og 바인딩 정정, admin_basic 1.0.7 SEO 탭 업로더(+ja 팩 2종) - 검수 중 발견한 text 모드 오캐시 결함은 실패 테스트 선행 후 수정
This commit is contained in:
@@ -4,6 +4,13 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.0] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
|
||||
|
||||
## [1.0.5] - 2026-08-22
|
||||
|
||||
### Security
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-board",
|
||||
"description": "Board module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* board_posts 에 본문 첫 내부 이미지 URL 캐시 컬럼 추가 (공개 이슈 #22)
|
||||
*
|
||||
* 목록 쿼리는 content 를 SELECT 하지 않으므로(컬럼 프루닝) 조회 시점 본문 파싱이
|
||||
* 불가하다 — 저장 시점(모델 saving 이벤트)에 추출해 이 컬럼에 캐시한다.
|
||||
* 기존 행 백필은 Upgrade_1_1_0 업그레이드 스텝이 수행한다 (마이그레이션은 스키마만).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('board_posts', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('board_posts', 'content_thumbnail_url')) {
|
||||
$table->string('content_thumbnail_url', 1000)
|
||||
->nullable()
|
||||
->after('content_mode')
|
||||
->comment('본문 첫 내부 이미지 URL 캐시 — 이미지 첨부가 없을 때 목록 썸네일 폴백');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이그레이션 롤백
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('board_posts')) {
|
||||
$columns = Schema::getColumnListing('board_posts');
|
||||
|
||||
Schema::table('board_posts', function (Blueprint $table) use ($columns) {
|
||||
if (in_array('content_thumbnail_url', $columns)) {
|
||||
$table->dropColumn('content_thumbnail_url');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -361,7 +361,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시·본문 이미지 URL 노출 차단 — 필드 자체는 유지) |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -529,7 +529,7 @@ _단건 응답: `data` 객체의 필드 (`PostResource` — 게시글 수정(PUT
|
||||
| reply_count | integer | `0` | 답변글 수 (집계) |
|
||||
| attachment_count | integer | `0` | 첨부파일 수 (집계) |
|
||||
| has_attachment | boolean | `false` | 첨부파일 보유 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 (없으면 null) |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | 원글 ID (답변글일 때만 값 존재) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위) |
|
||||
| is_reply | boolean | `false` | 답변글 여부 |
|
||||
@@ -880,7 +880,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -1040,7 +1040,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시·본문 이미지 URL 노출 차단 — 필드 자체는 유지) |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -1300,7 +1300,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -1459,7 +1459,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -1625,7 +1625,7 @@ _단건 응답: `data` 객체의 필드 (`PostResource` — 복원된 게시글.
|
||||
| reply_count | integer | `0` | 답변글 수 (집계) |
|
||||
| attachment_count | integer | `0` | 첨부파일 수 (집계) |
|
||||
| has_attachment | boolean | `false` | 첨부파일 보유 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | 원글 ID (답변글일 때만 값 존재) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 |
|
||||
| is_reply | boolean | `false` | 답변글 여부 |
|
||||
|
||||
@@ -2848,7 +2848,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -3020,7 +3020,7 @@ _단건 응답: `data` 객체의 필드 (생성된 게시글 = `PostResource`,
|
||||
| reply_count | integer | `0` | 답변글 수 (집계) |
|
||||
| attachment_count | integer | `0` | 첨부 수 (집계) |
|
||||
| has_attachment | boolean | `false` | 첨부 보유 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL (첫 이미지 첨부 — 없으면 null) |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | 답변글의 원글 식별자 (원글이면 null) |
|
||||
| depth | integer | `0` | 계층 트리 깊이 (0 = 원글) |
|
||||
| is_reply | boolean | `false` | 답변글 여부 |
|
||||
@@ -3438,7 +3438,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -3696,7 +3696,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"ko": "게시판",
|
||||
"en": "Board"
|
||||
},
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
"en": "Module for board management"
|
||||
},
|
||||
"g7_version": ">=7.0.7",
|
||||
"g7_version": ">=7.0.9",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -303,7 +303,8 @@ class PostResource extends BaseApiResource
|
||||
* 썸네일 URL을 관계 로딩 상태에 따라 반환합니다.
|
||||
*
|
||||
* thumbnailAttachment(경량 hasOne) 우선, attachments(전체) fallback.
|
||||
* 둘 다 미로딩 시 null 반환 (lazy loading 방지).
|
||||
* 첨부에서 URL 을 얻지 못한 모든 경우 본문 첫 내부 이미지 캐시
|
||||
* (content_thumbnail_url)로 폴백하고, 그것도 없으면 null (공개 이슈 #22).
|
||||
*
|
||||
* @return string|null 썸네일 URL
|
||||
*/
|
||||
@@ -312,28 +313,29 @@ class PostResource extends BaseApiResource
|
||||
// 비밀글은 썸네일 URL 자체를 방출하지 않는다 — 서빙은 이미 차단되어 이미지가 보이지는
|
||||
// 않지만, URL 에 실린 첨부 해시가 목록·상세 응답으로 나가 있었다(KVE-2026-1894).
|
||||
// 판정은 첨부 목록과 같은 SecretContentGate(SSoT)를 쓴다. 필드는 남기고 값만 가린다.
|
||||
// 본문 캐시 폴백도 반드시 이 게이트 뒤 — 에디터 이미지는 공개 hash 서빙이라
|
||||
// 첨부와 달리 서빙측 차단이 없어, 이 게이트가 유일한 차단선이다.
|
||||
if ($this->is_secret && ! $this->canViewSecretContent(request())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = null;
|
||||
|
||||
// 목록용 경량 관계 우선 — slug를 직접 전달하여 Board::find() N+1 방지
|
||||
if ($this->relationLoaded('thumbnailAttachment') && $this->thumbnailAttachment) {
|
||||
$attachment = $this->thumbnailAttachment;
|
||||
$slug = request()->route('slug') ?? ($this->relationLoaded('board') ? $this->board?->slug : null);
|
||||
|
||||
if ($slug && $attachment->hash) {
|
||||
return $attachment->previewUrlForSlug($slug);
|
||||
$url = $attachment->previewUrlForSlug($slug);
|
||||
}
|
||||
|
||||
return null;
|
||||
} elseif ($this->relationLoaded('attachments')) {
|
||||
// 상세 페이지 등에서 attachments가 로딩된 경우 fallback
|
||||
$url = $this->getThumbnailUrl();
|
||||
}
|
||||
|
||||
// 상세 페이지 등에서 attachments가 로딩된 경우 fallback
|
||||
if ($this->relationLoaded('attachments')) {
|
||||
return $this->getThumbnailUrl();
|
||||
}
|
||||
|
||||
return null;
|
||||
// 이미지 첨부가 없을 때만 본문 첫 내부 이미지 캐시 폴백 (첨부 우선 정책)
|
||||
return $url ?? ($this->content_thumbnail_url ?: null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,10 +5,12 @@ namespace Modules\Sirsoft\Board\Models;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use App\Search\Contracts\FulltextSearchable;
|
||||
use App\Support\HtmlImageExtractor;
|
||||
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\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||
@@ -30,7 +32,7 @@ class Post extends Model implements FulltextSearchable
|
||||
'content_mode' => ['label_key' => 'sirsoft-board::activity_log.fields.content_mode', 'type' => 'text'],
|
||||
'is_notice' => ['label_key' => 'sirsoft-board::activity_log.fields.is_notice', 'type' => 'boolean'],
|
||||
'is_secret' => ['label_key' => 'sirsoft-board::activity_log.fields.is_secret', 'type' => 'boolean'],
|
||||
'status' => ['label_key' => 'sirsoft-board::activity_log.fields.status', 'type' => 'enum', 'enum' => \Modules\Sirsoft\Board\Enums\PostStatus::class],
|
||||
'status' => ['label_key' => 'sirsoft-board::activity_log.fields.status', 'type' => 'enum', 'enum' => PostStatus::class],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -64,6 +66,7 @@ class Post extends Model implements FulltextSearchable
|
||||
'title',
|
||||
'content',
|
||||
'content_mode',
|
||||
'content_thumbnail_url',
|
||||
'user_id',
|
||||
'author_name',
|
||||
'password',
|
||||
@@ -105,6 +108,47 @@ class Post extends Model implements FulltextSearchable
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 이벤트 등록
|
||||
*
|
||||
* 본문 첫 내부 이미지 URL 캐시(content_thumbnail_url)는 저장 시점에만 계산한다 —
|
||||
* 목록 쿼리는 content 를 SELECT 하지 않으므로(컬럼 프루닝) 조회 시점 파싱이 불가하다.
|
||||
* Service 가 아닌 모델 saving 이벤트에 두는 이유: 시더·테스트의 Post::create() 직접
|
||||
* 호출과 이커머스 문의 훅 리스너 경유 경로까지 누락 없이 커버되는 유일 지점이기 때문.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (Post $post) {
|
||||
if ($post->exists && ! $post->isDirty('content') && ! $post->isDirty('content_mode')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// text 모드 본문은 이스케이프되어 렌더된다(상세에 이미지 미표시) — 리터럴
|
||||
// img 마크업을 캐시하면 목록 썸네일만 떠서 상세와 어긋나므로 추출하지 않는다.
|
||||
if ($post->content_mode !== 'html') {
|
||||
$post->content_thumbnail_url = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$content = (string) $post->content;
|
||||
|
||||
// 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null)할 수 있는 필터 훅.
|
||||
// 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다.
|
||||
$value = HookManager::applyFilters(
|
||||
'sirsoft-board.post.filter_content_thumbnail',
|
||||
HtmlImageExtractor::firstInternal($content),
|
||||
$post,
|
||||
HtmlImageExtractor::candidates($content)
|
||||
);
|
||||
|
||||
// 필터 반환값 방어 — 비문자열/빈 값/컬럼 상한 초과는 null(후보 없음)로 강등
|
||||
$post->content_thumbnail_url = is_string($value) && $value !== '' && mb_strlen($value) <= 1000
|
||||
? $value
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판과의 관계를 정의합니다.
|
||||
*
|
||||
@@ -181,9 +225,9 @@ class Post extends Model implements FulltextSearchable
|
||||
*
|
||||
* 전체 attachments를 eager loading하는 대신 이미지 1건만 가져와 성능을 확보합니다.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
* @return HasOne
|
||||
*/
|
||||
public function thumbnailAttachment(): \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
public function thumbnailAttachment(): HasOne
|
||||
{
|
||||
return $this->hasOne(Attachment::class, 'post_id')
|
||||
->where('mime_type', 'like', 'image/%')
|
||||
|
||||
@@ -62,7 +62,7 @@ class PostRepository implements PostRepositoryInterface
|
||||
// 목록 전용 컬럼: content(본문 HTML) 제외 → content_preview로 대체
|
||||
$listColumns = [
|
||||
'id', 'board_id', 'user_id', 'parent_id', 'category',
|
||||
'title', 'author_name', 'content_mode',
|
||||
'title', 'author_name', 'content_mode', 'content_thumbnail_url',
|
||||
'is_notice', 'is_secret', 'status', 'depth',
|
||||
'view_count', 'comments_count', 'replies_count', 'attachments_count',
|
||||
'trigger_type', 'ip_address', 'created_at', 'updated_at', 'deleted_at',
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\Upgrade;
|
||||
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
use Modules\Sirsoft\Board\Upgrades\Upgrade_1_1_0;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 1.1.0 본문 썸네일 백필 업그레이드 스텝 테스트 (공개 이슈 #22)
|
||||
*
|
||||
* 검증 목적:
|
||||
* - 1.1.0 이전에 작성된 게시글(캐시 null)이 본문 첫 내부 이미지로 백필된다
|
||||
* - 이미 값이 있는 행은 건드리지 않는다
|
||||
* - 외부 이미지만 있는 행은 null 유지
|
||||
* - 재실행해도 결과가 동일하다 (멱등)
|
||||
*
|
||||
* @group board
|
||||
* @group upgrade
|
||||
*/
|
||||
class ContentThumbnailBackfillTest extends BoardTestCase
|
||||
{
|
||||
protected function getTestBoardSlug(): string
|
||||
{
|
||||
return 'content-thumbnail-backfill';
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.1.0 백필 스텝을 실행합니다.
|
||||
*/
|
||||
private function runBackfill(): void
|
||||
{
|
||||
$context = new UpgradeContext('1.0.5', '1.1.0', '1.1.0', 'extension-upgrade');
|
||||
|
||||
(new Upgrade_1_1_0)->run($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario backfill=legacy_row
|
||||
*
|
||||
* @effects backfill_fills_legacy_rows
|
||||
*/
|
||||
#[Test]
|
||||
public function legacy_rows_are_backfilled_with_first_internal_image(): void
|
||||
{
|
||||
// createTestPost 는 DB 직접 insert — 모델 saving 이벤트를 타지 않아
|
||||
// 업그레이드 전 작성 글(캐시 null)과 동일한 상태다
|
||||
$legacy = $this->createTestPost([
|
||||
'content_mode' => 'html',
|
||||
'content' => '<p>글</p><img src="https://evil.example.org/skip.jpg"><img src="/storage/uploads/legacy.jpg">',
|
||||
]);
|
||||
|
||||
$this->runBackfill();
|
||||
|
||||
$this->assertSame(
|
||||
'/storage/uploads/legacy.jpg',
|
||||
DB::table('board_posts')->where('id', $legacy)->value('content_thumbnail_url'),
|
||||
'첫 외부 후보는 건너뛰고 첫 내부 이미지가 백필되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario backfill=already_filled
|
||||
*
|
||||
* @effects backfill_fills_legacy_rows
|
||||
*/
|
||||
#[Test]
|
||||
public function already_filled_rows_are_untouched(): void
|
||||
{
|
||||
$filled = $this->createTestPost([
|
||||
'content' => '<img src="/storage/uploads/other.jpg">',
|
||||
'content_thumbnail_url' => '/storage/uploads/kept.jpg',
|
||||
]);
|
||||
|
||||
$this->runBackfill();
|
||||
|
||||
$this->assertSame(
|
||||
'/storage/uploads/kept.jpg',
|
||||
DB::table('board_posts')->where('id', $filled)->value('content_thumbnail_url'),
|
||||
'이미 값이 있는 행은 백필 대상이 아니어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario backfill=external_only
|
||||
*
|
||||
* @effects backfill_keeps_external_only_null
|
||||
*/
|
||||
#[Test]
|
||||
public function external_only_rows_stay_null(): void
|
||||
{
|
||||
$external = $this->createTestPost([
|
||||
'content_mode' => 'html',
|
||||
'content' => '<img src="https://evil.example.org/x.jpg"><img src="//cdn.example.org/y.jpg">',
|
||||
]);
|
||||
$noImage = $this->createTestPost(['content_mode' => 'html', 'content' => '<p>이미지 없음</p>']);
|
||||
|
||||
$this->runBackfill();
|
||||
|
||||
$this->assertNull(DB::table('board_posts')->where('id', $external)->value('content_thumbnail_url'));
|
||||
$this->assertNull(DB::table('board_posts')->where('id', $noImage)->value('content_thumbnail_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* text 모드 레거시 행은 리터럴 img 마크업이 있어도 백필되지 않아야 합니다.
|
||||
*
|
||||
* text 모드 본문은 이스케이프 렌더라 상세에 이미지가 표시되지 않는다 —
|
||||
* 목록 썸네일만 뜨면 상세와 어긋난다 (content_mode 기본값이 text 라 모집단이 크다).
|
||||
*
|
||||
* @scenario backfill=text_mode
|
||||
*
|
||||
* @effects text_mode_content_never_caches
|
||||
*/
|
||||
#[Test]
|
||||
public function text_mode_rows_are_not_backfilled(): void
|
||||
{
|
||||
$textMode = $this->createTestPost([
|
||||
'content_mode' => 'text',
|
||||
'content' => '텍스트 본문의 <img src="/storage/uploads/literal.jpg"> 마크업',
|
||||
]);
|
||||
|
||||
$this->runBackfill();
|
||||
|
||||
$this->assertNull(
|
||||
DB::table('board_posts')->where('id', $textMode)->value('content_thumbnail_url'),
|
||||
'text 모드 글은 백필 대상이 아니어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario backfill=idempotent
|
||||
*
|
||||
* @effects backfill_idempotent_second_run
|
||||
*/
|
||||
#[Test]
|
||||
public function second_run_is_idempotent(): void
|
||||
{
|
||||
$this->createTestPost(['content_mode' => 'html', 'content' => '<img src="/storage/uploads/a.jpg">']);
|
||||
$this->createTestPost(['content_mode' => 'html', 'content' => '<img src="https://evil.example.org/b.jpg">']);
|
||||
$this->createTestPost(['content_mode' => 'html', 'content' => '<p>이미지 없음</p>']);
|
||||
|
||||
$this->runBackfill();
|
||||
$first = $this->snapshot();
|
||||
|
||||
$this->runBackfill();
|
||||
$second = $this->snapshot();
|
||||
|
||||
$this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 게시판 게시글의 (id, content_thumbnail_url) 스냅샷을 반환합니다.
|
||||
*
|
||||
* @return array<int, array{id: int, content_thumbnail_url: string|null}> 스냅샷
|
||||
*/
|
||||
private function snapshot(): array
|
||||
{
|
||||
return DB::table('board_posts')
|
||||
->where('board_id', $this->board->id)
|
||||
->orderBy('id')
|
||||
->get(['id', 'content_thumbnail_url'])
|
||||
->map(fn ($row) => ['id' => (int) $row->id, 'content_thumbnail_url' => $row->content_thumbnail_url])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\User;
|
||||
|
||||
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Modules\Sirsoft\Board\Models\Attachment;
|
||||
use Modules\Sirsoft\Board\Models\Post;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 목록 API 본문 썸네일 폴백 테스트 (공개 이슈 #22)
|
||||
*
|
||||
* 목록 SELECT(컬럼 프루닝)에 content_thumbnail_url 이 포함되어 첨부 없는
|
||||
* 본문이미지 글의 thumbnail 이 채워지는지, 첨부 우선·비밀글 게이트·생성/수정
|
||||
* 응답 즉시 반영까지 API 경계에서 고정합니다.
|
||||
*/
|
||||
class PostListContentThumbnailTest extends BoardTestCase
|
||||
{
|
||||
private User $memberUser;
|
||||
|
||||
protected function getTestBoardSlug(): string
|
||||
{
|
||||
return 'list-content-thumbnail';
|
||||
}
|
||||
|
||||
protected function getDefaultBoardAttributes(string $slug): array
|
||||
{
|
||||
return [
|
||||
'slug' => $slug,
|
||||
'name' => ['ko' => '본문 썸네일 게시판', 'en' => 'Content Thumbnail Board'],
|
||||
'is_active' => true,
|
||||
'secret_mode' => 'enabled',
|
||||
];
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->grantUserRolePermissions();
|
||||
|
||||
$this->memberUser = User::factory()->create(['email' => 'thumb-member@test.com']);
|
||||
$userRole = Role::where('identifier', 'user')->first();
|
||||
$this->memberUser->roles()->attach($userRole->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 이벤트를 경유해 게시글을 생성합니다 (saving 추출 경로 포함).
|
||||
*
|
||||
* @param array $attributes 덮어쓸 속성
|
||||
* @return Post 생성된 게시글
|
||||
*/
|
||||
private function createPostViaModel(array $attributes = []): Post
|
||||
{
|
||||
return Post::create(array_merge([
|
||||
'board_id' => $this->board->id,
|
||||
'title' => '본문 썸네일 글',
|
||||
'content' => '<p>본문</p>',
|
||||
'content_mode' => 'html',
|
||||
'author_name' => '테스트',
|
||||
'ip_address' => '127.0.0.1',
|
||||
'is_notice' => false,
|
||||
'is_secret' => false,
|
||||
'status' => 'published',
|
||||
'trigger_type' => 'admin',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 응답에서 특정 게시글 행을 찾습니다.
|
||||
*
|
||||
* @param int $postId 게시글 ID
|
||||
* @return array<string, mixed>|null 목록 행
|
||||
*/
|
||||
private function findListItem(int $postId): ?array
|
||||
{
|
||||
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
|
||||
$response->assertStatus(200);
|
||||
|
||||
foreach ($response->json('data.data') ?? [] as $item) {
|
||||
if (($item['id'] ?? null) === $postId) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_internal_only, secrecy=normal
|
||||
*
|
||||
* @effects content_internal_image_fills_list_thumbnail
|
||||
*/
|
||||
#[Test]
|
||||
public function list_thumbnail_filled_from_content_image_without_attachment(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<p>글</p><img src="/storage/uploads/list-content.jpg">',
|
||||
]);
|
||||
|
||||
$item = $this->findListItem($post->id);
|
||||
|
||||
$this->assertNotNull($item, '목록에 게시글이 있어야 합니다.');
|
||||
$this->assertSame('/storage/uploads/list-content.jpg', $item['thumbnail']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=both, secrecy=normal
|
||||
*
|
||||
* @effects attachment_takes_precedence_over_content_image
|
||||
*/
|
||||
#[Test]
|
||||
public function list_thumbnail_prefers_attachment_over_content_image(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<img src="/storage/uploads/content-image.jpg">',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'board_id' => $this->board->id,
|
||||
'post_id' => $post->id,
|
||||
'original_filename' => 'attach.jpg',
|
||||
'stored_filename' => 'stored-attach.jpg',
|
||||
'disk' => 'modules',
|
||||
'path' => 'attachments/attach.jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size' => 100,
|
||||
'collection' => 'attachments',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$item = $this->findListItem($post->id);
|
||||
|
||||
$this->assertNotNull($item);
|
||||
$this->assertSame(
|
||||
'/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview',
|
||||
$item['thumbnail'],
|
||||
'이미지 첨부가 있으면 본문 캐시보다 첨부가 우선해야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_internal_only, secrecy=secret_unauthorized
|
||||
*
|
||||
* @effects secret_post_thumbnail_stays_null
|
||||
*/
|
||||
#[Test]
|
||||
public function secret_post_list_thumbnail_is_null_for_guest(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'is_secret' => true,
|
||||
'content' => '<img src="/storage/uploads/secret-content.jpg">',
|
||||
]);
|
||||
|
||||
$item = $this->findListItem($post->id);
|
||||
|
||||
$this->assertNotNull($item, '비밀글도 목록 행 자체는 노출됩니다 (제목/배지).');
|
||||
$this->assertNull($item['thumbnail'], '권한 없는 조회에서 본문 캐시 URL 이 노출되면 안 됩니다.');
|
||||
|
||||
// 응답 어디에도 이미지 URL 이 실리지 않아야 한다 (에디터 이미지는 공개 서빙)
|
||||
$raw = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts")->getContent();
|
||||
$this->assertStringNotContainsString('secret-content.jpg', $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_external_only, secrecy=normal
|
||||
*
|
||||
* @effects external_only_content_yields_null
|
||||
*/
|
||||
#[Test]
|
||||
public function external_only_content_yields_null_thumbnail_in_list(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<img src="https://evil.example.org/x.jpg">',
|
||||
]);
|
||||
|
||||
$item = $this->findListItem($post->id);
|
||||
|
||||
$this->assertNotNull($item);
|
||||
$this->assertNull($item['thumbnail']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=attachment_only, secrecy=normal
|
||||
*
|
||||
* @effects attachment_takes_precedence_over_content_image
|
||||
*/
|
||||
#[Test]
|
||||
public function attachment_only_post_keeps_existing_behavior(): void
|
||||
{
|
||||
$post = $this->createPostViaModel(['content' => '<p>첨부만 있는 글</p>']);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'board_id' => $this->board->id,
|
||||
'post_id' => $post->id,
|
||||
'original_filename' => 'only.jpg',
|
||||
'stored_filename' => 'stored-only.jpg',
|
||||
'disk' => 'modules',
|
||||
'path' => 'attachments/only.jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size' => 100,
|
||||
'collection' => 'attachments',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$item = $this->findListItem($post->id);
|
||||
|
||||
$this->assertNotNull($item);
|
||||
$this->assertSame(
|
||||
'/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview',
|
||||
$item['thumbnail'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 응답의 thumbnail 이 본문 이미지로 즉시 채워져야 합니다.
|
||||
*
|
||||
* @effects detail_and_og_share_same_fallback
|
||||
*/
|
||||
#[Test]
|
||||
public function create_response_thumbnail_is_immediately_filled(): void
|
||||
{
|
||||
$response = $this->actingAs($this->memberUser)
|
||||
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
|
||||
'title' => '에디터 이미지 글',
|
||||
'content' => '<p>본문</p><img src="/storage/uploads/created.jpg">',
|
||||
'content_mode' => 'html',
|
||||
'author_name' => '회원작성자',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$this->assertSame('/storage/uploads/created.jpg', $response->json('data.thumbnail'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 응답의 thumbnail 이 교체된 본문 이미지로 즉시 갱신되어야 합니다.
|
||||
*
|
||||
* @effects detail_and_og_share_same_fallback
|
||||
*/
|
||||
#[Test]
|
||||
public function update_response_thumbnail_reflects_new_content(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'user_id' => $this->memberUser->id,
|
||||
'trigger_type' => 'user',
|
||||
'content' => '<img src="/storage/uploads/before.jpg">',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->memberUser)
|
||||
->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$post->id}", [
|
||||
'title' => '수정된 글',
|
||||
'content' => '<img src="/storage/uploads/after.jpg">',
|
||||
'content_mode' => 'html',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertSame('/storage/uploads/after.jpg', $response->json('data.thumbnail'));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ type BoardAuthFixtures = {
|
||||
boardManageToken: string;
|
||||
/** 게시판 조회 + 첨부 다운로드 권한 토큰 (#413-58b 행위자 기록 검증용) */
|
||||
attachmentDownloadToken: string;
|
||||
/** 본문 썸네일 폴백 E2E 용 — 게시판 CRUD + 전용 게시판(e2e-content-thumbnail) 글/첨부 관리 토큰 (공개 #22) */
|
||||
contentThumbnailToken: string;
|
||||
};
|
||||
|
||||
export const test = base.extend<BoardAuthFixtures>({
|
||||
@@ -89,6 +91,22 @@ export const test = base.extend<BoardAuthFixtures>({
|
||||
),
|
||||
);
|
||||
},
|
||||
contentThumbnailToken: async ({}, use) => {
|
||||
// 전용 카드형 게시판(e2e-content-thumbnail)을 spec 안에서 생성/삭제하고,
|
||||
// 관리자 글 작성 + 첨부 업로드로 첨부 우선 정책까지 실측한다 (공개 #22).
|
||||
await use(
|
||||
issueToken(
|
||||
'sirsoft-board.boards.read',
|
||||
'sirsoft-board.boards.create',
|
||||
'sirsoft-board.boards.update',
|
||||
'sirsoft-board.boards.delete',
|
||||
'sirsoft-board.e2e-content-thumbnail.admin.posts.read',
|
||||
'sirsoft-board.e2e-content-thumbnail.admin.posts.write',
|
||||
'sirsoft-board.e2e-content-thumbnail.admin.manage',
|
||||
'sirsoft-board.e2e-content-thumbnail.admin.attachments.upload',
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export { authenticatePage };
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 카드형 목록 — 본문 첫 내부 이미지 썸네일 폴백 (공개 이슈 #22, 내부 #610).
|
||||
*
|
||||
* 에디터로 본문에만 이미지를 넣은 글(첨부 없음)이 카드형 목록에서 "이미지 없음"
|
||||
* placeholder 대신 실제 썸네일을 렌더하는지, 이미지 첨부가 있으면 종전대로 첨부가
|
||||
* 우선하는지를 브라우저 수준에서 실측한다. 기대 이미지 URL 은 상수로 박제하지 않고
|
||||
* 그 글 상세 API 의 thumbnail 값을 읽어 단언한다 (운영자 편집 값 박제 금지 규율).
|
||||
*
|
||||
* 단위/통합: PostContentThumbnailTest(saving 추출), PostResourceThumbnailUrlTest(폴백
|
||||
* 우선순위·비밀글 게이트), PostListContentThumbnailTest(목록 API 계약)가 담당하고,
|
||||
* 이 spec 은 카드 레이아웃 렌더(브라우저)를 담당한다.
|
||||
*
|
||||
* 전용 게시판(e2e-content-thumbnail)을 spec 안에서 생성/삭제하므로 시드 의존이 없다.
|
||||
*
|
||||
* @scenario board-content-thumbnail-fallback
|
||||
* @axes image_source=content_internal_only image_source=both image_source=none board_type=card
|
||||
* @effects content_internal_image_fills_list_thumbnail,
|
||||
* attachment_takes_precedence_over_content_image,
|
||||
* detail_and_og_share_same_fallback
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/board-auth';
|
||||
|
||||
const SLUG = 'e2e-content-thumbnail';
|
||||
const API = '/api/modules/sirsoft-board';
|
||||
|
||||
/** 브라우저 컨텍스트에서 Bearer 토큰으로 API 를 호출한다 (실 브라우저 실측 규약). */
|
||||
async function api(
|
||||
page: import('@playwright/test').Page,
|
||||
bearer: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; body: any }> {
|
||||
return page.evaluate(
|
||||
async ({ bearer, method, path, body }) => {
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch {
|
||||
/* 비 JSON 응답 허용 */
|
||||
}
|
||||
return { status: response.status, body: parsed as any };
|
||||
},
|
||||
{ bearer, method, path, body },
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteBoardIfExists(page: import('@playwright/test').Page, bearer: string): Promise<void> {
|
||||
await api(page, bearer, 'DELETE', `${API}/admin/boards/${SLUG}`);
|
||||
}
|
||||
|
||||
/** 전용 카드형 게시판을 생성한다 (StoreBoardRequest 필수값 포함). */
|
||||
async function createCardBoard(page: import('@playwright/test').Page, bearer: string): Promise<{ status: number; body: any }> {
|
||||
// board_manager_ids 필수값: 시드 게시판(free)의 관리자 uuid 를 재사용한다
|
||||
const freeBoard = await api(page, bearer, 'GET', `${API}/admin/boards/free`);
|
||||
const managerUuid = (freeBoard.body?.data?.board_manager_ids ?? [])[0];
|
||||
|
||||
if (!managerUuid) {
|
||||
throw new Error(`manager uuid unavailable: status=${freeBoard.status} body=${JSON.stringify(freeBoard.body).slice(0, 300)}`);
|
||||
}
|
||||
|
||||
return api(page, bearer, 'POST', `${API}/admin/boards`, {
|
||||
slug: SLUG,
|
||||
name: { ko: '[검수용] 본문썸네일', en: 'Content Thumbnail E2E' },
|
||||
type: 'card',
|
||||
show_view_count: true,
|
||||
use_report: false,
|
||||
board_manager_ids: [managerUuid],
|
||||
});
|
||||
}
|
||||
|
||||
/** 관리자 글 작성 API 로 게시글을 생성하고 id 를 돌려준다 (모델 saving 추출 경로 경유). */
|
||||
async function createPost(
|
||||
page: import('@playwright/test').Page,
|
||||
bearer: string,
|
||||
title: string,
|
||||
content: string,
|
||||
): Promise<number> {
|
||||
const created = await api(page, bearer, 'POST', `${API}/admin/board/${SLUG}/posts`, {
|
||||
title,
|
||||
content,
|
||||
// text 모드는 이스케이프 렌더라 추출 게이트에 걸린다 — 에디터 시나리오는 html 모드
|
||||
content_mode: 'html',
|
||||
author_name: 'E2E',
|
||||
});
|
||||
if (![200, 201].includes(created.status)) {
|
||||
throw new Error(`post create failed: ${created.status} ${JSON.stringify(created.body).slice(0, 300)}`);
|
||||
}
|
||||
return created.body?.data?.id;
|
||||
}
|
||||
|
||||
/** 1×1 픽셀 JPEG 를 브라우저에서 만들어 첨부로 업로드한다 (multipart). */
|
||||
async function uploadImageAttachment(
|
||||
page: import('@playwright/test').Page,
|
||||
bearer: string,
|
||||
postId: number,
|
||||
): Promise<{ status: number; body: any }> {
|
||||
return page.evaluate(
|
||||
async ({ bearer, postId, apiBase, slug }) => {
|
||||
// canvas 로 진짜 JPEG 바이트를 생성 (mimes 검증 통과)
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 8;
|
||||
canvas.height = 8;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = '#3b82f6';
|
||||
ctx.fillRect(0, 0, 8, 8);
|
||||
const blob: Blob = await new Promise((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b!), 'image/jpeg', 0.9),
|
||||
);
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([blob], 'e2e-attach.jpg', { type: 'image/jpeg' }));
|
||||
form.append('post_id', String(postId));
|
||||
|
||||
const response = await fetch(`${apiBase}/admin/board/${slug}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' },
|
||||
body: form,
|
||||
});
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch {
|
||||
/* 비 JSON 응답 허용 */
|
||||
}
|
||||
return { status: response.status, body: parsed as any };
|
||||
},
|
||||
{ bearer, postId, apiBase: API, slug: SLUG },
|
||||
);
|
||||
}
|
||||
|
||||
/** 사용자 상세 API 에서 thumbnail 값을 읽는다 (기대값의 SSoT — 상수 박제 금지). */
|
||||
async function fetchDetailThumbnail(
|
||||
page: import('@playwright/test').Page,
|
||||
bearer: string,
|
||||
postId: number,
|
||||
): Promise<string | null> {
|
||||
const detail = await api(page, bearer, 'GET', `${API}/boards/${SLUG}/posts/${postId}`);
|
||||
expect(detail.status).toBe(200);
|
||||
return detail.body?.data?.thumbnail ?? null;
|
||||
}
|
||||
|
||||
// 전용 게시판 slug 를 생성/삭제하므로 병렬 실행 시 경합한다 — 직렬 고정
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('카드형 목록 본문 썸네일 폴백 (공개 #22)', () => {
|
||||
// @scenario image_source=content_internal_only board_type=card
|
||||
// @effects content_internal_image_fills_list_thumbnail, detail_and_og_share_same_fallback
|
||||
test('첨부 없는 본문이미지 글의 카드에 썸네일이 렌더되고 placeholder 가 없다', async ({ page, contentThumbnailToken }) => {
|
||||
await authenticatePage(page, contentThumbnailToken);
|
||||
await page.goto('/');
|
||||
await deleteBoardIfExists(page, contentThumbnailToken);
|
||||
|
||||
const created = await createCardBoard(page, contentThumbnailToken);
|
||||
expect([200, 201], `board create failed: ${JSON.stringify(created.body)}`).toContain(created.status);
|
||||
|
||||
// 본문에만 내부 이미지 — 첨부 없음 (favicon 은 항상 서빙되는 내부 자산)
|
||||
const postId = await createPost(
|
||||
page,
|
||||
contentThumbnailToken,
|
||||
'[검수용] 본문 이미지만 있는 글',
|
||||
'<p>본문입니다.</p><img src="/favicon.ico">',
|
||||
);
|
||||
|
||||
// 기대값은 상세 API 의 thumbnail (상수 박제 금지)
|
||||
const thumbnail = await fetchDetailThumbnail(page, contentThumbnailToken, postId);
|
||||
expect(thumbnail, '상세 API thumbnail 이 본문 이미지로 채워져야 합니다').toBe('/favicon.ico');
|
||||
|
||||
// 카드형 목록 렌더 실측
|
||||
await page.goto(`/board/${SLUG}`);
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
const card = page.locator('a, div').filter({ hasText: '[검수용] 본문 이미지만 있는 글' }).last();
|
||||
await expect(page.locator(`img[src="${thumbnail}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 그 카드에는 "이미지 없음" placeholder 가 없어야 한다
|
||||
await expect(card.getByText('이미지 없음')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// @scenario image_source=both board_type=card
|
||||
// @effects attachment_takes_precedence_over_content_image
|
||||
test('첨부와 본문이미지가 함께 있으면 첨부 preview 가 우선한다', async ({ page, contentThumbnailToken }) => {
|
||||
await authenticatePage(page, contentThumbnailToken);
|
||||
await page.goto('/');
|
||||
|
||||
// 본문 이미지 + 이미지 첨부 동시 보유 글
|
||||
const postId = await createPost(
|
||||
page,
|
||||
contentThumbnailToken,
|
||||
'[검수용] 첨부 우선 글',
|
||||
'<p>본문입니다.</p><img src="/favicon.ico">',
|
||||
);
|
||||
const uploaded = await uploadImageAttachment(page, contentThumbnailToken, postId);
|
||||
expect([200, 201], `attachment upload failed: ${JSON.stringify(uploaded.body).slice(0, 300)}`).toContain(uploaded.status);
|
||||
|
||||
const thumbnail = await fetchDetailThumbnail(page, contentThumbnailToken, postId);
|
||||
expect(thumbnail, '첨부가 있으면 첨부 preview URL 이어야 합니다').toContain('/attachment/');
|
||||
expect(thumbnail).toContain('/preview');
|
||||
|
||||
await page.goto(`/board/${SLUG}`);
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
// 카드에 첨부 preview 가 렌더된다 (본문 favicon 이 아니라)
|
||||
await expect(page.locator(`img[src="${thumbnail}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
// @scenario image_source=none board_type=card
|
||||
// @effects content_internal_image_fills_list_thumbnail
|
||||
test('이미지가 전혀 없는 글은 placeholder 가 유지된다 (회귀 없음) — 정리 포함', async ({ page, contentThumbnailToken }) => {
|
||||
await authenticatePage(page, contentThumbnailToken);
|
||||
await page.goto('/');
|
||||
|
||||
const postId = await createPost(
|
||||
page,
|
||||
contentThumbnailToken,
|
||||
'[검수용] 이미지 없는 글',
|
||||
'<p>이미지 없이 텍스트만 있는 본문입니다.</p>',
|
||||
);
|
||||
|
||||
const thumbnail = await fetchDetailThumbnail(page, contentThumbnailToken, postId);
|
||||
expect(thumbnail).toBeNull();
|
||||
|
||||
await page.goto(`/board/${SLUG}`);
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
// 이미지 없는 글의 카드에는 placeholder("이미지 없음")가 렌더된다
|
||||
await expect(page.getByText('이미지 없음').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 생성물 정리 — 전용 게시판 삭제 (글·첨부 포함)
|
||||
await deleteBoardIfExists(page, contentThumbnailToken);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Unit\Models;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Board\Models\Post;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* Post 모델 content_thumbnail_url 캐시 계산 테스트 (공개 이슈 #22)
|
||||
*
|
||||
* saving 이벤트가 본문 첫 내부 이미지 URL 을 저장 시점에 추출·캐시하는 계약을
|
||||
* 고정합니다. 재계산 조건(생성 시 / content dirty 시에만)과 필터 훅
|
||||
* (sirsoft-board.post.filter_content_thumbnail)의 대체·차단 동작을 포함합니다.
|
||||
*/
|
||||
class PostContentThumbnailTest extends BoardTestCase
|
||||
{
|
||||
private const FILTER_HOOK = 'sirsoft-board.post.filter_content_thumbnail';
|
||||
|
||||
protected function getTestBoardSlug(): string
|
||||
{
|
||||
return 'post-content-thumbnail';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
HookManager::clearFilter(self::FILTER_HOOK);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 이벤트를 경유해 게시글을 생성합니다 (DB 직접 insert 아님).
|
||||
*
|
||||
* @param array $attributes 덮어쓸 속성
|
||||
* @return Post 생성된 게시글
|
||||
*/
|
||||
private function createPostViaModel(array $attributes = []): Post
|
||||
{
|
||||
return Post::create(array_merge([
|
||||
'board_id' => $this->board->id,
|
||||
'title' => '본문 썸네일 테스트',
|
||||
'content' => '<p>본문</p>',
|
||||
'content_mode' => 'html',
|
||||
'author_name' => '테스트',
|
||||
'ip_address' => '127.0.0.1',
|
||||
'is_notice' => false,
|
||||
'is_secret' => false,
|
||||
'status' => 'published',
|
||||
'trigger_type' => 'admin',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_internal_only, secrecy=normal
|
||||
*
|
||||
* @effects content_internal_image_fills_list_thumbnail
|
||||
*/
|
||||
#[Test]
|
||||
public function saving_extracts_first_internal_image_on_create(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<p>글</p><img src="/storage/uploads/first.jpg"><img src="/storage/uploads/second.jpg">',
|
||||
]);
|
||||
|
||||
$this->assertSame('/storage/uploads/first.jpg', $post->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_external_only, secrecy=normal
|
||||
*
|
||||
* @effects external_only_content_yields_null
|
||||
*/
|
||||
#[Test]
|
||||
public function external_only_content_yields_null(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<img src="https://evil.example.org/x.jpg"><img src="//cdn.example.org/y.jpg">',
|
||||
]);
|
||||
|
||||
$this->assertNull($post->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=none, secrecy=normal
|
||||
*
|
||||
* @effects external_only_content_yields_null
|
||||
*/
|
||||
#[Test]
|
||||
public function content_without_image_yields_null(): void
|
||||
{
|
||||
$post = $this->createPostViaModel(['content' => '<p>이미지가 없는 본문</p>']);
|
||||
|
||||
$this->assertNull($post->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects recompute_on_content_change
|
||||
*/
|
||||
#[Test]
|
||||
public function content_change_recomputes_cache(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<img src="/storage/uploads/old.jpg">',
|
||||
]);
|
||||
|
||||
$post->update(['content' => '<img src="/storage/uploads/new.jpg">']);
|
||||
|
||||
$this->assertSame('/storage/uploads/new.jpg', $post->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects recompute_on_content_change
|
||||
*/
|
||||
#[Test]
|
||||
public function removing_all_images_resets_cache_to_null(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<img src="/storage/uploads/only.jpg">',
|
||||
]);
|
||||
|
||||
$post->update(['content' => '<p>이미지 전부 제거됨</p>']);
|
||||
|
||||
$this->assertNull($post->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects no_recompute_on_unrelated_update
|
||||
*/
|
||||
#[Test]
|
||||
public function unrelated_update_does_not_recompute(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<img src="/storage/uploads/a.jpg">',
|
||||
]);
|
||||
|
||||
// 이벤트 우회로 캐시를 다른 값으로 바꿔 두면, content 무변경 저장이
|
||||
// 재계산을 하지 않는다는 사실이 값 보존으로 관측된다
|
||||
DB::table('board_posts')->where('id', $post->id)
|
||||
->update(['content_thumbnail_url' => '/storage/uploads/manual.jpg']);
|
||||
|
||||
$post->fresh()->update(['title' => '제목만 변경']);
|
||||
|
||||
$this->assertSame(
|
||||
'/storage/uploads/manual.jpg',
|
||||
$post->fresh()->content_thumbnail_url,
|
||||
'content 가 dirty 가 아니면 캐시를 재계산하지 않아야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects filter_hook_can_override_or_block
|
||||
*/
|
||||
#[Test]
|
||||
public function filter_hook_can_override_value(): void
|
||||
{
|
||||
HookManager::addFilter(self::FILTER_HOOK, function ($value, $post, $candidates) {
|
||||
$this->assertIsArray($candidates, '세 번째 인자로 전체 후보 목록이 전달되어야 합니다.');
|
||||
|
||||
return 'https://cdn.example.net/promoted.jpg';
|
||||
});
|
||||
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<img src="https://cdn.example.net/promoted.jpg">',
|
||||
]);
|
||||
|
||||
$this->assertSame('https://cdn.example.net/promoted.jpg', $post->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects filter_hook_can_override_or_block
|
||||
*/
|
||||
#[Test]
|
||||
public function filter_hook_can_block_fallback(): void
|
||||
{
|
||||
HookManager::addFilter(self::FILTER_HOOK, fn ($value) => null);
|
||||
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<img src="/storage/uploads/internal.jpg">',
|
||||
]);
|
||||
|
||||
$this->assertNull($post->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* text 모드 글은 리터럴 img 마크업이 있어도 캐시하지 않아야 합니다.
|
||||
*
|
||||
* text 모드 본문은 이스케이프되어 렌더되므로(상세에 이미지가 표시되지 않음)
|
||||
* 목록 썸네일만 뜨면 상세와 목록이 어긋난다 (Chrome MCP T10② 실측 회귀).
|
||||
*
|
||||
* @effects text_mode_content_never_caches
|
||||
*/
|
||||
#[Test]
|
||||
public function text_mode_content_is_never_cached(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content_mode' => 'text',
|
||||
'content' => '텍스트 본문에 쓴 <img src="/storage/uploads/literal.jpg"> 마크업',
|
||||
]);
|
||||
|
||||
$this->assertNull($post->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* content_mode 를 html → text 로 바꾸면 캐시가 비워져야 합니다 (content 무변경이어도).
|
||||
*
|
||||
* @effects mode_switch_recomputes_cache
|
||||
*/
|
||||
#[Test]
|
||||
public function switching_to_text_mode_clears_cache(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<img src="/storage/uploads/a.jpg">',
|
||||
]);
|
||||
|
||||
$this->assertSame('/storage/uploads/a.jpg', $post->fresh()->content_thumbnail_url);
|
||||
|
||||
$post->fresh()->update(['content_mode' => 'text']);
|
||||
|
||||
$this->assertNull($post->fresh()->content_thumbnail_url, 'text 모드 전환 시 캐시가 비워져야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* content_mode 를 text → html 로 바꾸면 캐시가 다시 계산되어야 합니다.
|
||||
*
|
||||
* @effects mode_switch_recomputes_cache
|
||||
*/
|
||||
#[Test]
|
||||
public function switching_to_html_mode_recomputes_cache(): void
|
||||
{
|
||||
$post = $this->createPostViaModel([
|
||||
'content_mode' => 'text',
|
||||
'content' => '<img src="/storage/uploads/promoted.jpg">',
|
||||
]);
|
||||
|
||||
$this->assertNull($post->fresh()->content_thumbnail_url);
|
||||
|
||||
$post->fresh()->update(['content_mode' => 'html']);
|
||||
|
||||
$this->assertSame('/storage/uploads/promoted.jpg', $post->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터가 비정상 값(비문자열/상한 초과)을 돌려줘도 저장이 실패하지 않아야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function filter_hook_invalid_return_degrades_to_null(): void
|
||||
{
|
||||
HookManager::addFilter(self::FILTER_HOOK, fn ($value) => str_repeat('a', 1500));
|
||||
|
||||
$post = $this->createPostViaModel([
|
||||
'content' => '<img src="/storage/uploads/internal.jpg">',
|
||||
]);
|
||||
|
||||
$this->assertNull($post->fresh()->content_thumbnail_url);
|
||||
}
|
||||
}
|
||||
@@ -112,4 +112,96 @@ class PostResourceThumbnailUrlTest extends BoardTestCase
|
||||
$response['thumbnail'],
|
||||
);
|
||||
}
|
||||
|
||||
// ── 본문 첫 내부 이미지 캐시 폴백 (공개 이슈 #22) ──────────────
|
||||
|
||||
/**
|
||||
* 이미지 첨부가 없으면 본문 캐시(content_thumbnail_url)로 폴백해야 합니다.
|
||||
*
|
||||
* @scenario image_source=content_internal_only, secrecy=normal
|
||||
*
|
||||
* @effects content_internal_image_fills_list_thumbnail
|
||||
*/
|
||||
#[Test]
|
||||
public function thumbnail_falls_back_to_content_cache_without_attachment(): void
|
||||
{
|
||||
$postId = $this->createTestPost([
|
||||
'content' => '<img src="/storage/uploads/content.jpg">',
|
||||
'content_thumbnail_url' => '/storage/uploads/content.jpg',
|
||||
]);
|
||||
|
||||
$post = Post::with(['thumbnailAttachment', 'board'])->findOrFail($postId);
|
||||
|
||||
$response = (new PostResource($post))->toArray(Request::create('/'));
|
||||
|
||||
$this->assertSame('/storage/uploads/content.jpg', $response['thumbnail']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 첨부와 본문 캐시가 모두 있으면 첨부가 우선해야 합니다 (첨부 우선 정책).
|
||||
*
|
||||
* @scenario image_source=both, secrecy=normal
|
||||
*
|
||||
* @effects attachment_takes_precedence_over_content_image
|
||||
*/
|
||||
#[Test]
|
||||
public function attachment_takes_precedence_over_content_cache(): void
|
||||
{
|
||||
['post' => $post, 'attachment' => $attachment] = $this->createPostWithAttachment('image/jpeg', [
|
||||
'content_thumbnail_url' => '/storage/uploads/content.jpg',
|
||||
]);
|
||||
|
||||
$response = (new PostResource($post))->toArray(Request::create('/'));
|
||||
|
||||
$this->assertSame(
|
||||
'/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview',
|
||||
$response['thumbnail'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀글은 본문 캐시가 있어도 썸네일이 null 이어야 합니다.
|
||||
*
|
||||
* 에디터 이미지는 공개 hash 서빙이라 첨부와 달리 서빙측 차단이 없다 —
|
||||
* 이 게이트가 유일한 차단선이므로 폴백은 반드시 게이트 뒤에 있어야 한다.
|
||||
*
|
||||
* @scenario image_source=content_internal_only, secrecy=secret_unauthorized
|
||||
*
|
||||
* @effects secret_post_thumbnail_stays_null
|
||||
*/
|
||||
#[Test]
|
||||
public function secret_post_thumbnail_null_even_with_content_cache(): void
|
||||
{
|
||||
$postId = $this->createTestPost([
|
||||
'is_secret' => true,
|
||||
'content' => '<img src="/storage/uploads/secret.jpg">',
|
||||
'content_thumbnail_url' => '/storage/uploads/secret.jpg',
|
||||
]);
|
||||
|
||||
$post = Post::with(['thumbnailAttachment', 'board'])->findOrFail($postId);
|
||||
|
||||
$response = (new PostResource($post))->toArray(Request::create('/'));
|
||||
|
||||
$this->assertArrayHasKey('thumbnail', $response);
|
||||
$this->assertNull($response['thumbnail'], '비밀글은 본문 캐시 URL 도 방출되지 않아야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부도 본문 캐시도 없으면 null 이어야 합니다.
|
||||
*
|
||||
* @scenario image_source=none, secrecy=normal
|
||||
*
|
||||
* @effects external_only_content_yields_null
|
||||
*/
|
||||
#[Test]
|
||||
public function thumbnail_is_null_without_attachment_and_cache(): void
|
||||
{
|
||||
$postId = $this->createTestPost(['content' => '<p>이미지 없음</p>']);
|
||||
|
||||
$post = Post::with(['thumbnailAttachment', 'board'])->findOrFail($postId);
|
||||
|
||||
$response = (new PostResource($post))->toArray(Request::create('/'));
|
||||
|
||||
$this->assertNull($response['thumbnail']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
feature: 본문 첫 이미지 목록 썸네일 폴백
|
||||
|
||||
description: |
|
||||
공개 이슈 #22 — 에디터로 본문에 삽입한 이미지가 카드형·갤러리형 목록 썸네일에
|
||||
반영되지 않던 문제. 저장 시 본문 첫 내부 이미지 URL 을 board_posts.content_thumbnail_url
|
||||
에 캐시하고, 이미지 첨부가 없을 때 PostResource thumbnail 이 이를 폴백으로 사용한다.
|
||||
내부 URL 판정은 TrustedScriptHosts::normalizeForOriginCheck 공유 정규화를 따른다.
|
||||
|
||||
axes:
|
||||
image_source: [attachment_only, content_internal_only, both, content_external_only, none]
|
||||
secrecy: [normal, secret_unauthorized]
|
||||
|
||||
exclusions:
|
||||
- { image_source: attachment_only, secrecy: secret_unauthorized, reason: "비밀글 게이트는 image_source 와 무관하게 최상단에서 null — 대표 1조합(content_internal_only)으로 게이트를 검증하고 첨부 축은 기존 PostResourceThumbnailUrlTest 비밀글 케이스가 커버" }
|
||||
- { image_source: both, secrecy: secret_unauthorized, reason: "동일 — 게이트 선행으로 소스 축 무의미, 대표 조합으로 검증" }
|
||||
- { image_source: content_external_only, secrecy: secret_unauthorized, reason: "동일" }
|
||||
- { image_source: none, secrecy: secret_unauthorized, reason: "동일" }
|
||||
|
||||
sub_flows:
|
||||
- id: backfill
|
||||
description: 1.1.0 업그레이드 스텝이 기존 게시물을 chunkById 백필
|
||||
effects:
|
||||
- backfill_fills_legacy_rows
|
||||
- backfill_idempotent_second_run
|
||||
- backfill_keeps_external_only_null
|
||||
- id: recompute
|
||||
description: content/content_mode 변경 시에만 saving 이벤트가 재계산
|
||||
effects:
|
||||
- recompute_on_content_change
|
||||
- no_recompute_on_unrelated_update
|
||||
- filter_hook_can_override_or_block
|
||||
- text_mode_content_never_caches
|
||||
- mode_switch_recomputes_cache
|
||||
|
||||
effects:
|
||||
- attachment_takes_precedence_over_content_image
|
||||
- content_internal_image_fills_list_thumbnail
|
||||
- external_only_content_yields_null
|
||||
- secret_post_thumbnail_stays_null
|
||||
- detail_and_og_share_same_fallback
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Support/HtmlImageExtractorTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* Board 모듈 1.1.0 업그레이드 스텝
|
||||
*
|
||||
* 기존 게시글의 본문 첫 내부 이미지 URL 을 content_thumbnail_url 캐시 컬럼에
|
||||
* 백필한다 (공개 이슈 #22 — 본문 이미지 목록 썸네일 폴백).
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.1.0/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path A
|
||||
*/
|
||||
class Upgrade_1_1_0 extends AbstractUpgradeStep {}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_1_0\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 기존 게시글의 본문 첫 내부 이미지 URL 을 content_thumbnail_url 에 백필합니다.
|
||||
*
|
||||
* 1.1.0 이전에 작성된 게시글은 저장 시점 추출(모델 saving 이벤트)을 거치지 않아
|
||||
* 캐시가 비어 있다 — 이미지 첨부 없이 본문에만 이미지를 넣은 글이 목록 썸네일
|
||||
* 폴백의 수혜를 받으려면 이 백필이 필요하다 (공개 이슈 #22 제보 시나리오).
|
||||
*
|
||||
* 멱등: whereNull 필터가 이미 채워진 행을 제외한다. "후보 없음" 행은 null 유지라
|
||||
* 재실행 시 재스캔되지만 결과는 불변이다.
|
||||
*
|
||||
* 훅 미적용: 과거 데이터 일괄 처리에 확장 개입은 불요하다 — 이후 글 수정 시
|
||||
* 모델 saving 이벤트가 필터 훅 포함으로 재계산한다.
|
||||
*
|
||||
* V-1 안전: 추출·정규화 로직은 코어 HtmlImageExtractor / TrustedScriptHosts 의
|
||||
* 1.1.0 시점 동결 사본이다(버전 스냅샷 규약 — 코어/모듈 클래스 미참조).
|
||||
*/
|
||||
class BackfillContentThumbnails implements DataMigration
|
||||
{
|
||||
private const POSTS_TABLE = 'board_posts';
|
||||
|
||||
/**
|
||||
* 캐시 컬럼 상한 (board_posts.content_thumbnail_url string(1000) 정합 — 1.1.0 동결).
|
||||
*/
|
||||
private const MAX_URL_LENGTH = 1000;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillContentThumbnails';
|
||||
}
|
||||
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
if (! Schema::hasTable(self::POSTS_TABLE)
|
||||
|| ! Schema::hasColumn(self::POSTS_TABLE, 'content_thumbnail_url')) {
|
||||
$context->logger->warning('[board:1.1.0] board_posts.content_thumbnail_url 미존재 — 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$filled = 0;
|
||||
$noCandidate = 0;
|
||||
|
||||
// text 모드 본문은 이스케이프 렌더(이미지 미표시)이므로 html 모드만 대상
|
||||
DB::table(self::POSTS_TABLE)
|
||||
->whereNull('content_thumbnail_url')
|
||||
->where('content_mode', 'html')
|
||||
->whereNotNull('content')
|
||||
->where('content', '!=', '')
|
||||
->orderBy('id')
|
||||
->select('id', 'content')
|
||||
->chunkById(200, function ($posts) use (&$filled, &$noCandidate) {
|
||||
foreach ($posts as $post) {
|
||||
$url = $this->firstInternalImageUrl((string) $post->content);
|
||||
|
||||
if ($url === null) {
|
||||
$noCandidate++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table(self::POSTS_TABLE)
|
||||
->where('id', $post->id)
|
||||
->update(['content_thumbnail_url' => $url]);
|
||||
|
||||
$filled++;
|
||||
}
|
||||
});
|
||||
|
||||
$context->logger->info("[board:1.1.0] 본문 썸네일 백필: 채움 {$filled} / 후보 없음 {$noCandidate}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 HTML 에서 첫 번째 내부 이미지 URL 을 추출합니다 (1.1.0 동결 사본).
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @return string|null 첫 내부 이미지 URL (없으면 null)
|
||||
*/
|
||||
private function firstInternalImageUrl(string $html): ?string
|
||||
{
|
||||
foreach ($this->imageSources($html) as $src) {
|
||||
$resolved = $this->resolveInternal($src);
|
||||
|
||||
if ($resolved === null || mb_strlen($resolved) > self::MAX_URL_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 에서 모든 img src 를 문서 순서로 수집합니다 (동결 사본).
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @return array<int, string> src 목록
|
||||
*/
|
||||
private function imageSources(string $html): array
|
||||
{
|
||||
if (trim($html) === '' || ! class_exists(\DOMDocument::class)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$document = new \DOMDocument('1.0', 'UTF-8');
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
|
||||
try {
|
||||
$loaded = $document->loadHTML(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div>'
|
||||
.$html.'</div></body></html>',
|
||||
LIBXML_NOERROR | LIBXML_NOWARNING
|
||||
);
|
||||
|
||||
if (! $loaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sources = [];
|
||||
|
||||
foreach ($document->getElementsByTagName('img') as $img) {
|
||||
if (! $img instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$src = trim($img->getAttribute('src'));
|
||||
|
||||
if ($src !== '') {
|
||||
$sources[] = $src;
|
||||
}
|
||||
}
|
||||
|
||||
return $sources;
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* src 후보를 내부 URL 로 해석합니다 (동결 사본 — 정규화 규칙 포함).
|
||||
*
|
||||
* @param string $src img src 속성값
|
||||
* @return string|null 내부 URL (외부/비허용 스킴이면 null)
|
||||
*/
|
||||
private function resolveInternal(string $src): ?string
|
||||
{
|
||||
$trimmed = trim($src);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeForOriginCheck($trimmed);
|
||||
|
||||
if ($normalized === '' || str_starts_with($normalized, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($normalized, '/')) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$scheme = parse_url($normalized, PHP_URL_SCHEME);
|
||||
|
||||
if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) {
|
||||
$host = parse_url($normalized, PHP_URL_HOST);
|
||||
$appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
|
||||
if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) {
|
||||
$path = parse_url($normalized, PHP_URL_PATH);
|
||||
$query = parse_url($normalized, PHP_URL_QUERY);
|
||||
|
||||
return (is_string($path) && $path !== '' ? $path : '/')
|
||||
.(is_string($query) && $query !== '' ? '?'.$query : '');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* origin 판정 전 브라우저 URL 파서 동형 정규화 (동결 사본).
|
||||
*
|
||||
* @param string $url 원본 URL
|
||||
* @return string 정규화된 URL
|
||||
*/
|
||||
private function normalizeForOriginCheck(string $url): string
|
||||
{
|
||||
$stripped = str_replace(["\t", "\n", "\r"], '', $url);
|
||||
$slashed = str_replace('\\', '/', $stripped);
|
||||
|
||||
return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,16 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.2.0] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 카테고리 페이지의 공유 미리보기 이미지(og:image)가 어떤 경우에도 채워지지 않던 문제를 수정했습니다. 이제 카테고리에 등록된 첫 이미지가 사용됩니다.
|
||||
|
||||
## [1.1.2] - 2026-08-22
|
||||
|
||||
### Security
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-ecommerce",
|
||||
"description": "Ecommerce module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* ecommerce_products 에 설명 첫 내부 이미지 URL 캐시 컬럼 추가 (공개 이슈 #22 동종)
|
||||
*
|
||||
* 상품 이미지 없이 상세설명(에디터)에만 이미지를 넣은 상품은 목록·og:image 6개
|
||||
* 표면이 동시에 비어 있었다. 저장 시점(모델 saving 이벤트)에 추출해 캐시하고,
|
||||
* Product::getThumbnailUrl() 이 상품 이미지 부재 시 이 값으로 폴백한다.
|
||||
* 기존 행 백필은 Upgrade_1_2_0 업그레이드 스텝이 수행한다.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ecommerce_products', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('ecommerce_products', 'content_thumbnail_url')) {
|
||||
$table->string('content_thumbnail_url', 1000)
|
||||
->nullable()
|
||||
->after('description_mode')
|
||||
->comment('설명 첫 내부 이미지 URL 캐시 — 상품 이미지가 없을 때 목록/공유 썸네일 폴백');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이그레이션 롤백
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('ecommerce_products')) {
|
||||
$columns = Schema::getColumnListing('ecommerce_products');
|
||||
|
||||
Schema::table('ecommerce_products', function (Blueprint $table) use ($columns) {
|
||||
if (in_array('content_thumbnail_url', $columns)) {
|
||||
$table->dropColumn('content_thumbnail_url');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1369,6 +1369,6 @@ HTTP/1.1 200
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** slug로 단일 공개 카테고리와 직계 자식을 조회합니다. 인증이 필요 없는 공개 엔드포인트이며, `Public\CategoryController@show`가 `CategoryService::getPublicCategoryBySlug()`를 호출해 활성 자식(`activeChildren`)과 이미지를 함께 로드합니다. 조회된 카테고리가 비활성(`is_active=false`)이면 없는 것으로 간주해 404를 반환하며, 응답에는 상위 경로를 나타내는 `breadcrumb` 배열과 `products_count` 집계가 포함됩니다. 스토어프론트 카테고리 상세/목록 페이지 진입 시 사용합니다.
|
||||
**설명** slug로 단일 공개 카테고리와 직계 자식을 조회합니다. 인증이 필요 없는 공개 엔드포인트이며, `Public\CategoryController@show`가 `CategoryService::getPublicCategoryBySlug()`를 호출해 활성 자식(`activeChildren`)과 이미지를 함께 로드합니다. 조회된 카테고리가 비활성(`is_active=false`)이면 없는 것으로 간주해 404를 반환하며, 응답에는 상위 경로를 나타내는 `breadcrumb` 배열과 `products_count` 집계가 포함됩니다. 스토어프론트 카테고리 상세/목록 페이지 진입 시 사용합니다. 1.2.0 부터 `thumbnail_url` 필드(로드된 카테고리 이미지 첫 건의 URL, 없으면 `null`)가 추가되어 카테고리 페이지의 공유 미리보기 이미지(og:image)에 사용됩니다.
|
||||
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
| name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
|
||||
| product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) |
|
||||
| sku | string | `JK-0100` | 재고관리코드(SKU) |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) |
|
||||
| list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
| selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) |
|
||||
@@ -413,7 +413,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource`). 성공 시 HTTP 201
|
||||
| hs_code | string | `null` | HS 코드 (수출입 관세 분류 코드) |
|
||||
| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) |
|
||||
| thumbnail_hash | string | `null` | 대표 이미지 해시 |
|
||||
| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL |
|
||||
| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) |
|
||||
| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) |
|
||||
| meta_keywords | array | `null` | SEO 메타 키워드 배열 |
|
||||
@@ -1084,7 +1084,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품
|
||||
| hs_code | string | `null` | HS 코드 |
|
||||
| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) |
|
||||
| thumbnail_hash | string | `null` | 대표 이미지 해시 |
|
||||
| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL |
|
||||
| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) |
|
||||
| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) |
|
||||
| meta_keywords | array | `null` | SEO 메타 키워드 배열 |
|
||||
@@ -2011,7 +2011,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품
|
||||
| hs_code | string | `null` | HS 코드 |
|
||||
| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) |
|
||||
| thumbnail_hash | string | `null` | 대표 이미지 해시 |
|
||||
| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL |
|
||||
| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) |
|
||||
| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) |
|
||||
| meta_keywords | array | `null` | SEO 메타 키워드 배열 |
|
||||
@@ -2249,7 +2249,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| seo_sync_description | boolean | `true` | SEO 설명 동기화 여부 (1: 상품 설명으로 자동 채움, 0: 직접 입력 보존) |
|
||||
| barcode | null | `null` | 바코드 |
|
||||
| hs_code | null | `null` | HS 코드 (관세 분류) |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| categories | array | `[{"id":29,"name":{"ko":"의류","en":"Clothing"},"name_locali…` | 소속 카테고리 목록 (breadcrumb 포함 — 복사 폼의 카테고리 표시용) |
|
||||
|
||||
**응답 예시**
|
||||
@@ -2636,7 +2636,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
| name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
|
||||
| product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) |
|
||||
| sku | string | `JK-0100` | 재고관리코드(SKU) |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) |
|
||||
| list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
| selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) |
|
||||
@@ -2766,7 +2766,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
|
||||
| name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
|
||||
| product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) |
|
||||
| sku | string | `JK-0100` | 재고관리코드(SKU) |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) |
|
||||
| list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
| selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) |
|
||||
@@ -2913,7 +2913,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
|
||||
| name_localized | string | `프리미엄 브이넥 티셔츠 #5` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
|
||||
| product_code | string | `4R5OG8VYO0JKG1WQ` | 상품코드 (상품 고유 관리 식별자) |
|
||||
| sku | string | `TS-0005` | 재고관리코드(SKU) |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| list_price | integer | `34000` | 정가 (기본통화 자릿수로 정규화된 값) |
|
||||
| list_price_formatted | string | `34,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
| selling_price | integer | `27000` | 판매가 (기본통화 자릿수로 정규화된 값) |
|
||||
@@ -3062,7 +3062,7 @@ _목록 응답: `data[]` 배열 항목의 필드 (`ProductListResource` — `GET
|
||||
| name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 |
|
||||
| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) |
|
||||
| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 대표 이미지 URL |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 대표 이미지 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화) |
|
||||
| list_price_formatted | string | `112,594원` | `list_price` 의 표시용 통화 포맷 문자열 |
|
||||
| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화) |
|
||||
@@ -3186,7 +3186,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| description_localized | string | `<p>부드러운 면 100% 손수건 3매 세트입니다.</p>` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
|
||||
| description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) |
|
||||
| images | array | `[{"id":7,"hash":"7df7761cdf16","original_filename":"produ…` | 상품 이미지 목록 (각 항목: hash·url·alt_text·is_thumbnail·sort_order 등, images 관계 로드 시) |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
|
||||
| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| meta_title | null | `null` | SEO 제목 (다국어 JSON) |
|
||||
| meta_description | object | `{"ko":"면 손수건 3매입 #1 의 직접 입력 SEO 설명입니다.","en":"Custom SEO …` | SEO 설명 (다국어 JSON) |
|
||||
| meta_keywords | null | `null` | SEO 키워드 (배열) |
|
||||
|
||||
@@ -59,7 +59,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
| name_localized | string | `상품명` | 현재 로케일로 해석된 상품명 |
|
||||
| product_code | string | `P0000001` | 상품 코드 |
|
||||
| sku | string \| null | `SKU-001` | 재고 관리 코드 |
|
||||
| thumbnail_url | string \| null | `/storage/products/thumb.jpg` | 대표 썸네일 이미지 URL |
|
||||
| thumbnail_url | string \| null | `/storage/products/thumb.jpg` | 대표 썸네일 이미지 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
|
||||
| list_price | number | `30000` | 정가 (기본 통화 기준 반올림) |
|
||||
| list_price_formatted | string | `30,000원` | 정가 표시용 포맷 문자열 |
|
||||
| selling_price | number | `24000` | 판매가 (기본 통화 기준 반올림) |
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"ko": "이커머스",
|
||||
"en": "Ecommerce"
|
||||
},
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
|
||||
"en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management"
|
||||
},
|
||||
"g7_version": ">=7.0.7",
|
||||
"g7_version": ">=7.0.9",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
@@ -2368,13 +2368,11 @@ class Module extends AbstractModule
|
||||
? (str_starts_with($imageRaw, 'http') ? $imageRaw : url($imageRaw))
|
||||
: '';
|
||||
|
||||
// thumbnail_width/height 는 어떤 Resource 도 방출하지 않는 死키였다 — 값 공급이
|
||||
// 불가능한 축이므로 제거하고 크기 메타는 코어 기본값 폴백에 맡긴다 (공개 #22 부수 정리)
|
||||
return array_filter([
|
||||
'type' => 'product',
|
||||
'image' => $image,
|
||||
'image_width' => isset($product['thumbnail_width']) && (int) $product['thumbnail_width'] > 0
|
||||
? (int) $product['thumbnail_width'] : null,
|
||||
'image_height' => isset($product['thumbnail_height']) && (int) $product['thumbnail_height'] > 0
|
||||
? (int) $product['thumbnail_height'] : null,
|
||||
'image_alt' => $name,
|
||||
'extra' => $extra,
|
||||
], fn ($v) => $v !== null && $v !== '' && $v !== []);
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+5
-1
@@ -15,7 +15,7 @@ class PublicCategoryDetailResource extends BaseApiResource
|
||||
/**
|
||||
* 리소스를 배열로 변환합니다.
|
||||
*
|
||||
* @param Request $request 요청
|
||||
* @param Request $request 요청
|
||||
* @return array
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
@@ -31,6 +31,10 @@ class PublicCategoryDetailResource extends BaseApiResource
|
||||
'parent_id' => $this->parent_id,
|
||||
'products_count' => $this->products_count ?? 0,
|
||||
'breadcrumb' => $this->getBreadcrumb(),
|
||||
// 카테고리 og:image 생산자 — 로드된 images 첫 건의 URL (재쿼리 금지,
|
||||
// 로드 여부는 Repository/Service 가 결정 — whenLoaded 단일 가드).
|
||||
// module.php seoOpenGraph 의 category.data.thumbnail_url 소비처가 읽는다.
|
||||
'thumbnail_url' => $this->whenLoaded('images', fn () => $this->images->first()?->download_url, null),
|
||||
'images' => $this->whenLoaded('images', function () {
|
||||
return $this->images->map(function ($image) {
|
||||
return [
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Models;
|
||||
use App\Casts\AsUnicodeJson;
|
||||
use App\Extension\HookManager;
|
||||
use App\Search\Contracts\FulltextSearchable;
|
||||
use App\Support\HtmlImageExtractor;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -93,6 +94,7 @@ class Product extends Model implements FulltextSearchable
|
||||
'common_info_id',
|
||||
'description',
|
||||
'description_mode',
|
||||
'content_thumbnail_url',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'meta_keywords',
|
||||
@@ -133,6 +135,67 @@ class Product extends Model implements FulltextSearchable
|
||||
'tax_status' => ProductTaxStatus::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* 모델 이벤트 등록
|
||||
*
|
||||
* 설명 첫 내부 이미지 URL 캐시(content_thumbnail_url)는 저장 시점에만 계산한다 —
|
||||
* 목록 쿼리는 description 을 SELECT 하지 않으므로 조회 시점 파싱이 불가하다.
|
||||
* 시더·테스트의 Product::create() 직접 호출까지 커버되는 유일 지점이 모델
|
||||
* saving 이벤트다 (공개 이슈 #22 동종 — board 와 동형).
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (Product $product) {
|
||||
if ($product->exists
|
||||
&& ! $product->isDirty('description')
|
||||
&& ! $product->isDirty('description_mode')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// text 모드 설명은 이스케이프 렌더(이미지 미표시) — 캐시하지 않는다
|
||||
if ($product->description_mode !== 'html') {
|
||||
$product->content_thumbnail_url = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// description 은 다국어 JSON — 기본 로케일 우선, 없으면 배열 순서대로
|
||||
// 첫 내부 이미지가 나올 때까지 시도 (레거시 평문 문자열도 수용)
|
||||
$extracted = null;
|
||||
$candidates = [];
|
||||
$description = $product->description;
|
||||
$htmls = is_array($description)
|
||||
? array_values(array_filter(
|
||||
[$description[config('app.locale')] ?? null, ...array_values($description)],
|
||||
fn ($html) => is_string($html) && $html !== ''
|
||||
))
|
||||
: (is_string($description) && $description !== '' ? [$description] : []);
|
||||
|
||||
foreach ($htmls as $html) {
|
||||
$candidates = array_merge($candidates, HtmlImageExtractor::candidates($html));
|
||||
$extracted ??= HtmlImageExtractor::firstInternal($html);
|
||||
|
||||
if ($extracted !== null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null)할 수 있는 필터 훅.
|
||||
// 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다.
|
||||
$value = HookManager::applyFilters(
|
||||
'sirsoft-ecommerce.product.filter_content_thumbnail',
|
||||
$extracted,
|
||||
$product,
|
||||
$candidates
|
||||
);
|
||||
|
||||
// 필터 반환값 방어 — 비문자열/빈 값/컬럼 상한 초과는 null(후보 없음)로 강등
|
||||
$product->content_thumbnail_url = is_string($value) && $value !== '' && mb_strlen($value) <= 1000
|
||||
? $value
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 상품의 옵션 관계 (color/size 등 가변 옵션 정의).
|
||||
*
|
||||
@@ -190,13 +253,15 @@ class Product extends Model implements FulltextSearchable
|
||||
$thumbnailImage = $this->images->firstWhere('is_thumbnail', true)
|
||||
?? $this->images->first();
|
||||
|
||||
return $thumbnailImage?->download_url;
|
||||
// 상품 이미지가 없으면 설명 첫 내부 이미지 캐시로 폴백 (공개 이슈 #22 동종).
|
||||
// 추가 쿼리 없음 — 이미 로드된 속성 읽기만 (쿼리 수 잠금 테스트 준수)
|
||||
return $thumbnailImage?->download_url ?? ($this->content_thumbnail_url ?: null);
|
||||
}
|
||||
|
||||
$thumbnailImage = $this->images()->where('is_thumbnail', true)->first()
|
||||
?? $this->images()->first();
|
||||
|
||||
return $thumbnailImage?->download_url;
|
||||
return $thumbnailImage?->download_url ?? ($this->content_thumbnail_url ?: null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature;
|
||||
|
||||
use App\Enums\PermissionType;
|
||||
use App\Http\Middleware\PermissionMiddleware;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 상품 목록/상세/검색 표면의 설명 썸네일 폴백 테스트 (공개 이슈 #22 동종)
|
||||
*
|
||||
* 상품 이미지 없는 상품의 thumbnail_url 이 설명 캐시로 채워지는지,
|
||||
* 상품 이미지가 있으면 우선하는지, 비공개(display_status) 상품의 기존 노출
|
||||
* 범위를 폴백이 넓히지 않는지 API 경계에서 고정합니다.
|
||||
*/
|
||||
class ProductListContentThumbnailTest extends ModuleTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['app.locale' => 'ko']);
|
||||
|
||||
// 비인증 공개 접근 (optional.sanctum + guest 권한)
|
||||
$productReadPerm = Permission::firstOrCreate(
|
||||
['identifier' => 'sirsoft-ecommerce.user-products.read'],
|
||||
['name' => ['ko' => '상품 조회', 'en' => 'View Products'], 'type' => PermissionType::User]
|
||||
);
|
||||
$guestRole = Role::firstOrCreate(
|
||||
['identifier' => 'guest'],
|
||||
['name' => ['ko' => '비회원', 'en' => 'Guest'], 'is_active' => true]
|
||||
);
|
||||
$guestRole->permissions()->syncWithoutDetaching([$productReadPerm->id]);
|
||||
PermissionMiddleware::clearGuestRoleCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $attributes 덮어쓸 속성
|
||||
* @return Product 설명 이미지만 가진 상품
|
||||
*/
|
||||
private function createContentImageProduct(array $attributes = []): Product
|
||||
{
|
||||
return Product::factory()->onSale()->create(array_merge([
|
||||
'description_mode' => 'html',
|
||||
'description' => ['ko' => '<p>설명</p><img src="/storage/products/desc-image.jpg">'],
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 응답에서 특정 상품 행을 찾습니다.
|
||||
*
|
||||
* @param int $productId 상품 ID
|
||||
* @return array<string, mixed>|null 목록 행
|
||||
*/
|
||||
private function findListItem(int $productId): ?array
|
||||
{
|
||||
$response = $this->getJson('/api/modules/sirsoft-ecommerce/products?per_page=50');
|
||||
$response->assertStatus(200);
|
||||
|
||||
foreach ($response->json('data.data') ?? [] as $item) {
|
||||
if (($item['id'] ?? null) === $productId) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_internal_only, locale_content=default_locale
|
||||
*
|
||||
* @effects content_image_fills_product_thumbnail
|
||||
*/
|
||||
#[Test]
|
||||
public function list_thumbnail_filled_from_description_without_product_image(): void
|
||||
{
|
||||
$product = $this->createContentImageProduct();
|
||||
|
||||
$item = $this->findListItem($product->id);
|
||||
|
||||
$this->assertNotNull($item, '목록에 상품이 있어야 합니다.');
|
||||
$this->assertSame('/storage/products/desc-image.jpg', $item['thumbnail_url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=product_image_only, locale_content=default_locale
|
||||
*
|
||||
* @effects product_image_takes_precedence
|
||||
*/
|
||||
#[Test]
|
||||
public function product_image_takes_precedence_over_description_cache(): void
|
||||
{
|
||||
$product = $this->createContentImageProduct();
|
||||
|
||||
$image = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'original_filename' => 'main.jpg',
|
||||
'stored_filename' => 'main-'.uniqid().'.jpg',
|
||||
'disk' => 'modules',
|
||||
'path' => 'ecommerce/products/main.jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'file_size' => 1024,
|
||||
'is_thumbnail' => true,
|
||||
'sort_order' => 0,
|
||||
'collection' => 'main',
|
||||
]);
|
||||
|
||||
$item = $this->findListItem($product->id);
|
||||
|
||||
$this->assertNotNull($item);
|
||||
$this->assertSame(
|
||||
$image->download_url,
|
||||
$item['thumbnail_url'],
|
||||
'상품 이미지가 있으면 설명 캐시보다 우선해야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세(공개) 응답에도 같은 폴백이 반영되어야 합니다.
|
||||
*
|
||||
* @scenario image_source=both, locale_content=default_locale
|
||||
*
|
||||
* @effects detail_og_and_search_share_same_fallback
|
||||
*/
|
||||
#[Test]
|
||||
public function public_detail_thumbnail_uses_same_fallback(): void
|
||||
{
|
||||
$product = $this->createContentImageProduct();
|
||||
|
||||
$response = $this->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->product_code);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertSame('/storage/products/desc-image.jpg', $response->json('data.thumbnail_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 표면(searchByKeyword)의 상품도 폴백된 썸네일에 도달해야 합니다.
|
||||
*
|
||||
* @effects detail_og_and_search_share_same_fallback
|
||||
*/
|
||||
#[Test]
|
||||
public function search_surface_reaches_fallback_thumbnail(): void
|
||||
{
|
||||
$product = $this->createContentImageProduct();
|
||||
|
||||
// FULLTEXT 는 트랜잭션 내 신규 행을 보지 못한다 — 보조필드(product_code) 합집합
|
||||
// 매칭으로 같은 검색 쿼리 경로(관계 로드·게이트·컬럼 도달)를 검증한다
|
||||
$page = app(ProductService::class)->searchByKeyword($product->product_code);
|
||||
$found = collect($page->items())->firstWhere('id', $product->id);
|
||||
|
||||
$this->assertNotNull($found, '검색 결과에 상품이 있어야 합니다.');
|
||||
$this->assertSame('/storage/products/desc-image.jpg', $found->getThumbnailUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비공개 상품은 설명 캐시가 있어도 목록/검색에 노출되지 않아야 합니다 (게이트 불변).
|
||||
*
|
||||
* @scenario image_source=content_internal_only, locale_content=default_locale
|
||||
*
|
||||
* @effects hidden_product_stays_hidden
|
||||
*/
|
||||
#[Test]
|
||||
public function hidden_product_is_not_exposed_by_fallback(): void
|
||||
{
|
||||
$hidden = $this->createContentImageProduct([
|
||||
'display_status' => ProductDisplayStatus::HIDDEN,
|
||||
'name' => ['ko' => '비공개폴백상품', 'en' => 'HiddenFallbackProduct'],
|
||||
]);
|
||||
|
||||
$this->assertNull($this->findListItem($hidden->id), '비공개 상품은 목록에 없어야 합니다.');
|
||||
|
||||
$page = app(ProductService::class)->searchByKeyword($hidden->product_code);
|
||||
$this->assertNull(
|
||||
collect($page->items())->firstWhere('id', $hidden->id),
|
||||
'비공개 상품은 검색에도 없어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_external_only, locale_content=default_locale
|
||||
*
|
||||
* @effects external_only_description_yields_null
|
||||
*/
|
||||
#[Test]
|
||||
public function external_only_description_yields_null_thumbnail(): void
|
||||
{
|
||||
$product = Product::factory()->onSale()->create([
|
||||
'description_mode' => 'html',
|
||||
'description' => ['ko' => '<img src="https://evil.example.org/x.jpg">'],
|
||||
]);
|
||||
|
||||
$item = $this->findListItem($product->id);
|
||||
|
||||
$this->assertNotNull($item);
|
||||
$this->assertNull($item['thumbnail_url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_internal_only, locale_content=other_locale_only
|
||||
*
|
||||
* @effects other_locale_image_used_when_default_has_none
|
||||
*/
|
||||
#[Test]
|
||||
public function other_locale_only_image_fills_thumbnail(): void
|
||||
{
|
||||
$product = Product::factory()->onSale()->create([
|
||||
'description_mode' => 'html',
|
||||
'description' => [
|
||||
'ko' => '<p>이미지 없는 한국어</p>',
|
||||
'en' => '<img src="/storage/products/en-image.jpg">',
|
||||
],
|
||||
]);
|
||||
|
||||
$item = $this->findListItem($product->id);
|
||||
|
||||
$this->assertNotNull($item);
|
||||
$this->assertSame('/storage/products/en-image.jpg', $item['thumbnail_url']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Models\Category;
|
||||
use Modules\Sirsoft\Ecommerce\Models\CategoryImage;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 공개 카테고리 상세 thumbnail_url 방출 테스트 (공개 이슈 #22 부수 — P3-B)
|
||||
*
|
||||
* module.php seoOpenGraph 의 `category.data.thumbnail_url` 소비처는 있었지만 어떤
|
||||
* Resource 도 이 키를 방출하지 않는 死표현식이었다. PublicCategoryDetailResource 가
|
||||
* 로드된 images 첫 건의 URL 을 방출해 카테고리 og:image 의 생산자가 되는 계약을
|
||||
* 고정한다 (재쿼리 금지 — 로드된 관계에서만 선택).
|
||||
*
|
||||
* @effects category_detail_emits_thumbnail_url
|
||||
*/
|
||||
class PublicCategoryThumbnailTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* @param array $attributes 덮어쓸 속성
|
||||
* @return Category 생성된 활성 카테고리
|
||||
*/
|
||||
private function createCategory(array $attributes = []): Category
|
||||
{
|
||||
return Category::create(array_merge([
|
||||
'name' => ['ko' => '썸네일 카테고리', 'en' => 'Thumbnail Category'],
|
||||
'slug' => 'thumb-cat-'.uniqid(),
|
||||
'is_active' => true,
|
||||
'depth' => 0,
|
||||
'path' => '',
|
||||
'sort_order' => 0,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects category_detail_emits_thumbnail_url
|
||||
*/
|
||||
#[Test]
|
||||
public function detail_emits_first_image_url_as_thumbnail(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
|
||||
$image = CategoryImage::create([
|
||||
'category_id' => $category->id,
|
||||
'original_filename' => 'cat.jpg',
|
||||
'stored_filename' => 'cat-'.uniqid().'.jpg',
|
||||
'disk' => 'modules',
|
||||
'path' => 'category/thumb-test/cat.jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'file_size' => 11,
|
||||
'collection' => 'main',
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/modules/sirsoft-ecommerce/categories/'.$category->slug);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertSame(
|
||||
$image->download_url,
|
||||
$response->json('data.thumbnail_url'),
|
||||
'로드된 images 첫 건의 URL 이 thumbnail_url 로 방출되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects category_detail_emits_thumbnail_url
|
||||
*/
|
||||
#[Test]
|
||||
public function detail_emits_null_thumbnail_without_images(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
|
||||
$response = $this->getJson('/api/modules/sirsoft-ecommerce/categories/'.$category->slug);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertArrayHasKey('thumbnail_url', $response->json('data'));
|
||||
$this->assertNull($response->json('data.thumbnail_url'));
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Upgrade;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Upgrades\Upgrade_1_2_0;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 1.2.0 상품 설명 썸네일 백필 업그레이드 스텝 테스트 (공개 이슈 #22 동종)
|
||||
*
|
||||
* 검증 목적:
|
||||
* - 1.2.0 이전 등록 상품(캐시 null)이 다국어 설명 JSON 의 첫 내부 이미지로 백필된다
|
||||
* - 이미 값이 있는 행·text 모드·외부 이미지만 있는 행은 건드리지 않는다
|
||||
* - 재실행해도 결과가 동일하다 (멱등)
|
||||
*
|
||||
* @group ecommerce
|
||||
* @group upgrade
|
||||
*/
|
||||
class ProductContentThumbnailBackfillTest extends ModuleTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['app.locale' => 'ko']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 이벤트를 우회해 레거시 상태 상품 행을 만듭니다 (업그레이드 전 등록분과 동일).
|
||||
*
|
||||
* @param array $attributes 덮어쓸 속성
|
||||
* @return int 상품 ID
|
||||
*/
|
||||
private function createLegacyRow(array $attributes = []): int
|
||||
{
|
||||
return DB::table('ecommerce_products')->insertGetId(array_merge([
|
||||
'name' => json_encode(['ko' => '레거시 상품'], JSON_UNESCAPED_UNICODE),
|
||||
'product_code' => strtoupper(bin2hex(random_bytes(8))),
|
||||
'list_price' => 10000,
|
||||
'selling_price' => 10000,
|
||||
'stock_quantity' => 10,
|
||||
'safe_stock_quantity' => 1,
|
||||
'sales_status' => 'on_sale',
|
||||
'display_status' => 'visible',
|
||||
'tax_status' => 'taxable',
|
||||
'tax_rate' => 10,
|
||||
'description' => json_encode(['ko' => '<p>설명</p>'], JSON_UNESCAPED_UNICODE),
|
||||
'description_mode' => 'html',
|
||||
'content_thumbnail_url' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
private function runBackfill(): void
|
||||
{
|
||||
$context = new UpgradeContext('1.1.2', '1.2.0', '1.2.0', 'extension-upgrade');
|
||||
|
||||
(new Upgrade_1_2_0)->run($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario backfill=legacy_row
|
||||
*
|
||||
* @effects backfill_fills_legacy_products
|
||||
*/
|
||||
#[Test]
|
||||
public function legacy_rows_are_backfilled_from_multilingual_description(): void
|
||||
{
|
||||
$legacy = $this->createLegacyRow([
|
||||
'description' => json_encode([
|
||||
'ko' => '<p>글</p><img src="https://evil.example.org/skip.jpg"><img src="/storage/products/legacy.jpg">',
|
||||
'en' => '<img src="/storage/products/en.jpg">',
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
$this->runBackfill();
|
||||
|
||||
$this->assertSame(
|
||||
'/storage/products/legacy.jpg',
|
||||
DB::table('ecommerce_products')->where('id', $legacy)->value('content_thumbnail_url'),
|
||||
'기본 로케일의 첫 내부 이미지가 백필되어야 합니다 (외부 후보 건너뜀).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario backfill=text_mode
|
||||
*
|
||||
* @effects text_mode_description_never_caches
|
||||
*/
|
||||
#[Test]
|
||||
public function text_mode_and_filled_and_external_rows_are_untouched(): void
|
||||
{
|
||||
$textMode = $this->createLegacyRow([
|
||||
'description_mode' => 'text',
|
||||
'description' => json_encode(['ko' => '텍스트 <img src="/storage/products/literal.jpg">'], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$filled = $this->createLegacyRow([
|
||||
'description' => json_encode(['ko' => '<img src="/storage/products/other.jpg">'], JSON_UNESCAPED_UNICODE),
|
||||
'content_thumbnail_url' => '/storage/products/kept.jpg',
|
||||
]);
|
||||
$external = $this->createLegacyRow([
|
||||
'description' => json_encode(['ko' => '<img src="https://evil.example.org/x.jpg">'], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
$this->runBackfill();
|
||||
|
||||
$this->assertNull(DB::table('ecommerce_products')->where('id', $textMode)->value('content_thumbnail_url'));
|
||||
$this->assertSame('/storage/products/kept.jpg', DB::table('ecommerce_products')->where('id', $filled)->value('content_thumbnail_url'));
|
||||
$this->assertNull(DB::table('ecommerce_products')->where('id', $external)->value('content_thumbnail_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario backfill=idempotent
|
||||
*
|
||||
* @effects backfill_idempotent_second_run
|
||||
*/
|
||||
#[Test]
|
||||
public function second_run_is_idempotent(): void
|
||||
{
|
||||
$this->createLegacyRow([
|
||||
'description' => json_encode(['ko' => '<img src="/storage/products/a.jpg">'], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$this->createLegacyRow([
|
||||
'description' => json_encode(['ko' => '<p>이미지 없음</p>'], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
$this->runBackfill();
|
||||
$first = $this->snapshot();
|
||||
|
||||
$this->runBackfill();
|
||||
$second = $this->snapshot();
|
||||
|
||||
$this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, content_thumbnail_url: string|null}> 스냅샷
|
||||
*/
|
||||
private function snapshot(): array
|
||||
{
|
||||
return DB::table('ecommerce_products')
|
||||
->orderBy('id')
|
||||
->get(['id', 'content_thumbnail_url'])
|
||||
->map(fn ($row) => ['id' => (int) $row->id, 'content_thumbnail_url' => $row->content_thumbnail_url])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,57 @@ class EcommerceModuleSeoTest extends ModuleTestCase
|
||||
$this->assertStringContainsString('/api/modules/sirsoft-ecommerce/product-image/abc123', $og['image']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 og:image 출력 레벨 — thumbnail_url 이 있으면 image 가 절대 URL 로 방출된다.
|
||||
*
|
||||
* @scenario product-content-thumbnail-fallback
|
||||
*
|
||||
* @effects category_og_image_emitted_from_thumbnail_url
|
||||
*/
|
||||
public function test_category_seo_og_defaults_emits_image_from_thumbnail_url(): void
|
||||
{
|
||||
$context = [
|
||||
'category' => [
|
||||
'data' => [
|
||||
'name' => ['ko' => '아우터', 'en' => 'Outer'],
|
||||
'thumbnail_url' => '/api/modules/sirsoft-ecommerce/category-image/cat123',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$og = $this->module->seoOgDefaults('category', $context);
|
||||
|
||||
$this->assertArrayHasKey('image', $og);
|
||||
$this->assertStringStartsWith('http', $og['image'], 'og:image 는 절대 URL 이어야 공유 카드가 인식합니다');
|
||||
$this->assertStringContainsString('/api/modules/sirsoft-ecommerce/category-image/cat123', $og['image']);
|
||||
$this->assertSame('아우터', $og['image_alt']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 og:image 출력 레벨 — 이미지 없는 카테고리는 image 키 자체가 미출력된다
|
||||
* (빈 값은 array_filter 로 제거 → 코어 사이트 기본 OG 이미지 폴백에 위임).
|
||||
*
|
||||
* @scenario product-content-thumbnail-fallback
|
||||
*
|
||||
* @effects category_og_image_omitted_without_thumbnail
|
||||
*/
|
||||
public function test_category_seo_og_defaults_omits_image_without_thumbnail(): void
|
||||
{
|
||||
$context = [
|
||||
'category' => [
|
||||
'data' => [
|
||||
'name' => '아우터',
|
||||
'thumbnail_url' => null,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$og = $this->module->seoOgDefaults('category', $context);
|
||||
|
||||
$this->assertArrayNotHasKey('image', $og);
|
||||
$this->assertSame('website', $og['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회귀: 상품 structured_data 도 다국어 array 안전 처리.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Models;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* Product 모델 content_thumbnail_url 캐시 계산 테스트 (공개 이슈 #22 동종)
|
||||
*
|
||||
* saving 이벤트가 다국어 설명(JSON)에서 첫 내부 이미지 URL 을 저장 시점에
|
||||
* 추출·캐시하는 계약을 고정합니다. 로케일 우선순위(기본 로케일 → 배열 순서),
|
||||
* text 모드 게이트, 필터 훅(sirsoft-ecommerce.product.filter_content_thumbnail)
|
||||
* 을 포함합니다.
|
||||
*/
|
||||
class ProductContentThumbnailTest extends ModuleTestCase
|
||||
{
|
||||
private const FILTER_HOOK = 'sirsoft-ecommerce.product.filter_content_thumbnail';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['app.locale' => 'ko']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
HookManager::clearFilter(self::FILTER_HOOK);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $attributes 덮어쓸 속성
|
||||
* @return Product 생성된 상품
|
||||
*/
|
||||
private function createProduct(array $attributes = []): Product
|
||||
{
|
||||
return Product::factory()->create(array_merge([
|
||||
'description_mode' => 'html',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_internal_only, locale_content=default_locale
|
||||
*
|
||||
* @effects content_image_fills_product_thumbnail
|
||||
*/
|
||||
#[Test]
|
||||
public function saving_extracts_first_internal_image_from_default_locale(): void
|
||||
{
|
||||
$product = $this->createProduct([
|
||||
'description' => [
|
||||
'ko' => '<p>설명</p><img src="/storage/products/ko-first.jpg"><img src="/storage/products/ko-second.jpg">',
|
||||
'en' => '<img src="/storage/products/en-first.jpg">',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame('/storage/products/ko-first.jpg', $product->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 로케일에 내부 이미지가 없으면 다른 로케일에서 찾아야 합니다.
|
||||
*
|
||||
* @scenario image_source=content_internal_only, locale_content=other_locale_only
|
||||
*
|
||||
* @effects other_locale_image_used_when_default_has_none
|
||||
*/
|
||||
#[Test]
|
||||
public function falls_back_to_other_locale_when_default_has_no_internal_image(): void
|
||||
{
|
||||
$product = $this->createProduct([
|
||||
'description' => [
|
||||
'ko' => '<p>이미지 없는 한국어 설명</p><img src="https://evil.example.org/x.jpg">',
|
||||
'en' => '<img src="/storage/products/en-only.jpg">',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame('/storage/products/en-only.jpg', $product->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_external_only, locale_content=default_locale
|
||||
*
|
||||
* @effects external_only_description_yields_null
|
||||
*/
|
||||
#[Test]
|
||||
public function external_only_description_yields_null(): void
|
||||
{
|
||||
$product = $this->createProduct([
|
||||
'description' => [
|
||||
'ko' => '<img src="https://evil.example.org/a.jpg"><img src="//cdn.example.org/b.jpg">',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertNull($product->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=none, locale_content=default_locale
|
||||
*
|
||||
* @effects external_only_description_yields_null
|
||||
*/
|
||||
#[Test]
|
||||
public function description_without_image_yields_null(): void
|
||||
{
|
||||
$product = $this->createProduct([
|
||||
'description' => ['ko' => '<p>이미지 없는 설명</p>'],
|
||||
]);
|
||||
|
||||
$this->assertNull($product->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* text 모드 설명은 리터럴 img 마크업이 있어도 캐시하지 않아야 합니다.
|
||||
*
|
||||
* @effects text_mode_description_never_caches
|
||||
*/
|
||||
#[Test]
|
||||
public function text_mode_description_is_never_cached(): void
|
||||
{
|
||||
$product = $this->createProduct([
|
||||
'description_mode' => 'text',
|
||||
'description' => ['ko' => '텍스트 설명의 <img src="/storage/products/literal.jpg"> 마크업'],
|
||||
]);
|
||||
|
||||
$this->assertNull($product->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects recompute_on_description_change
|
||||
*/
|
||||
#[Test]
|
||||
public function description_change_recomputes_cache(): void
|
||||
{
|
||||
$product = $this->createProduct([
|
||||
'description' => ['ko' => '<img src="/storage/products/old.jpg">'],
|
||||
]);
|
||||
|
||||
$product->update(['description' => ['ko' => '<img src="/storage/products/new.jpg">']]);
|
||||
|
||||
$this->assertSame('/storage/products/new.jpg', $product->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects no_recompute_on_unrelated_update
|
||||
*/
|
||||
#[Test]
|
||||
public function unrelated_update_does_not_recompute(): void
|
||||
{
|
||||
$product = $this->createProduct([
|
||||
'description' => ['ko' => '<img src="/storage/products/a.jpg">'],
|
||||
]);
|
||||
|
||||
DB::table('ecommerce_products')->where('id', $product->id)
|
||||
->update(['content_thumbnail_url' => '/storage/products/manual.jpg']);
|
||||
|
||||
$product->fresh()->update(['stock_quantity' => 7]);
|
||||
|
||||
$this->assertSame('/storage/products/manual.jpg', $product->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects filter_hook_can_override_or_block
|
||||
*/
|
||||
#[Test]
|
||||
public function filter_hook_can_override_or_block(): void
|
||||
{
|
||||
HookManager::addFilter(self::FILTER_HOOK, fn ($value) => null);
|
||||
|
||||
$blocked = $this->createProduct([
|
||||
'description' => ['ko' => '<img src="/storage/products/internal.jpg">'],
|
||||
]);
|
||||
|
||||
$this->assertNull($blocked->fresh()->content_thumbnail_url);
|
||||
|
||||
HookManager::clearFilter(self::FILTER_HOOK);
|
||||
HookManager::addFilter(self::FILTER_HOOK, fn ($value) => 'https://cdn.example.net/promoted.jpg');
|
||||
|
||||
$promoted = $this->createProduct([
|
||||
'description' => ['ko' => '<p>설명</p>'],
|
||||
]);
|
||||
|
||||
$this->assertSame('https://cdn.example.net/promoted.jpg', $promoted->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 이미지가 없으면 getThumbnailUrl 이 캐시로 폴백해야 합니다 (추가 쿼리 없이).
|
||||
*
|
||||
* @effects product_image_takes_precedence
|
||||
*/
|
||||
#[Test]
|
||||
public function get_thumbnail_url_falls_back_to_cache_without_images(): void
|
||||
{
|
||||
$product = $this->createProduct([
|
||||
'description' => ['ko' => '<img src="/storage/products/cache.jpg">'],
|
||||
]);
|
||||
|
||||
$loaded = Product::with('images')->findOrFail($product->id);
|
||||
|
||||
$this->assertSame('/storage/products/cache.jpg', $loaded->getThumbnailUrl());
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
feature: 상품 설명 첫 이미지 목록/공유 썸네일 폴백
|
||||
|
||||
description: |
|
||||
공개 이슈 #22 동종 — 상품 이미지 없이 상세설명(에디터)에만 이미지를 넣은 상품이
|
||||
목록·검색·og:image 6개 표면에서 이미지 없이 나가던 문제. 저장 시 설명(다국어 JSON)
|
||||
의 첫 내부 이미지 URL 을 ecommerce_products.content_thumbnail_url 에 캐시하고,
|
||||
Product::getThumbnailUrl() 이 상품 이미지 부재 시 이를 폴백으로 사용한다.
|
||||
내부 URL 판정은 TrustedScriptHosts::normalizeForOriginCheck 공유 정규화를 따른다.
|
||||
|
||||
axes:
|
||||
image_source: [product_image_only, content_internal_only, both, content_external_only, none]
|
||||
locale_content: [default_locale, other_locale_only]
|
||||
|
||||
exclusions:
|
||||
- { image_source: product_image_only, locale_content: other_locale_only, reason: "로케일 축은 설명 내용에만 작용 — 상품 이미지 우선순위와 직교라 default_locale 대표 조합으로 검증" }
|
||||
- { image_source: both, locale_content: other_locale_only, reason: "동일 — 로케일 축은 추출 입력에만 작용, 우선순위 판정과 직교" }
|
||||
- { image_source: content_external_only, locale_content: other_locale_only, reason: "동일 — 외부 제외 판정은 로케일 무관 (전 로케일 순회 후 null)" }
|
||||
- { image_source: none, locale_content: other_locale_only, reason: "이미지 없음은 로케일 구분이 성립하지 않는다 (전 로케일 빈 결과)" }
|
||||
|
||||
sub_flows:
|
||||
- id: backfill
|
||||
description: 1.2.0 업그레이드 스텝이 기존 상품을 chunkById 백필 (다국어 JSON 파싱 동결 사본)
|
||||
effects:
|
||||
- backfill_fills_legacy_products
|
||||
- backfill_idempotent_second_run
|
||||
- text_mode_description_never_caches
|
||||
- id: recompute
|
||||
description: description/description_mode 변경 시에만 saving 이벤트가 재계산
|
||||
effects:
|
||||
- recompute_on_description_change
|
||||
- no_recompute_on_unrelated_update
|
||||
- filter_hook_can_override_or_block
|
||||
- id: category_og_producer
|
||||
description: 死표현식이던 category.data.thumbnail_url 의 생산자 신설 (P3-B — 로드된 images 첫 건)
|
||||
effects:
|
||||
- category_detail_emits_thumbnail_url
|
||||
- category_og_image_emitted_from_thumbnail_url
|
||||
- category_og_image_omitted_without_thumbnail
|
||||
|
||||
effects:
|
||||
- product_image_takes_precedence
|
||||
- content_image_fills_product_thumbnail
|
||||
- other_locale_image_used_when_default_has_none
|
||||
- external_only_description_yields_null
|
||||
- hidden_product_stays_hidden
|
||||
- detail_og_and_search_share_same_fallback
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* Ecommerce 모듈 1.2.0 업그레이드 스텝
|
||||
*
|
||||
* 기존 상품의 설명(에디터) 첫 내부 이미지 URL 을 content_thumbnail_url 캐시
|
||||
* 컬럼에 백필한다 (공개 이슈 #22 동종 — 상품 이미지 없는 상품의 목록/공유 썸네일).
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.2.0/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path A
|
||||
*/
|
||||
class Upgrade_1_2_0 extends AbstractUpgradeStep {}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_2_0\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 기존 상품의 설명 첫 내부 이미지 URL 을 content_thumbnail_url 에 백필합니다.
|
||||
*
|
||||
* 1.2.0 이전에 등록된 상품은 저장 시점 추출(모델 saving 이벤트)을 거치지 않아
|
||||
* 캐시가 비어 있다 — 상품 이미지 없이 설명에만 이미지를 넣은 상품이 목록/공유
|
||||
* 썸네일 폴백의 수혜를 받으려면 이 백필이 필요하다.
|
||||
*
|
||||
* 멱등: whereNull 필터가 이미 채워진 행을 제외한다. text 모드 설명은 이스케이프
|
||||
* 렌더(이미지 미표시)이므로 html 모드만 대상이다.
|
||||
*
|
||||
* V-1 안전: 추출·정규화·다국어 JSON 순회 로직은 코어 HtmlImageExtractor /
|
||||
* TrustedScriptHosts / 모델 saving 콜백의 1.2.0 시점 동결 사본이다
|
||||
* (버전 스냅샷 규약 — 코어/모듈 클래스 미참조).
|
||||
*/
|
||||
class BackfillProductContentThumbnails implements DataMigration
|
||||
{
|
||||
private const PRODUCTS_TABLE = 'ecommerce_products';
|
||||
|
||||
/**
|
||||
* 캐시 컬럼 상한 (string(1000) 정합 — 1.2.0 동결).
|
||||
*/
|
||||
private const MAX_URL_LENGTH = 1000;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillProductContentThumbnails';
|
||||
}
|
||||
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
if (! Schema::hasTable(self::PRODUCTS_TABLE)
|
||||
|| ! Schema::hasColumn(self::PRODUCTS_TABLE, 'content_thumbnail_url')) {
|
||||
$context->logger->warning('[ecommerce:1.2.0] ecommerce_products.content_thumbnail_url 미존재 — 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$filled = 0;
|
||||
$noCandidate = 0;
|
||||
|
||||
// text 모드 설명은 이스케이프 렌더(이미지 미표시)이므로 html 모드만 대상
|
||||
DB::table(self::PRODUCTS_TABLE)
|
||||
->whereNull('content_thumbnail_url')
|
||||
->where('description_mode', 'html')
|
||||
->whereNotNull('description')
|
||||
->where('description', '!=', '')
|
||||
->orderBy('id')
|
||||
->select('id', 'description')
|
||||
->chunkById(200, function ($products) use (&$filled, &$noCandidate) {
|
||||
foreach ($products as $product) {
|
||||
$url = $this->firstInternalFromDescription((string) $product->description);
|
||||
|
||||
if ($url === null) {
|
||||
$noCandidate++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table(self::PRODUCTS_TABLE)
|
||||
->where('id', $product->id)
|
||||
->update(['content_thumbnail_url' => $url]);
|
||||
|
||||
$filled++;
|
||||
}
|
||||
});
|
||||
|
||||
$context->logger->info("[ecommerce:1.2.0] 상품 설명 썸네일 백필: 채움 {$filled} / 후보 없음 {$noCandidate}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 다국어 JSON(또는 레거시 평문) 설명에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본).
|
||||
*
|
||||
* 기본 로케일 값 우선, 없으면 배열 순서대로 첫 내부 이미지가 나올 때까지 시도한다.
|
||||
*
|
||||
* @param string $rawDescription DB 원시 description 값
|
||||
* @return string|null 첫 내부 이미지 URL (없으면 null)
|
||||
*/
|
||||
private function firstInternalFromDescription(string $rawDescription): ?string
|
||||
{
|
||||
if (trim($rawDescription) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($rawDescription, true);
|
||||
|
||||
$htmls = is_array($decoded)
|
||||
? array_values(array_filter(
|
||||
[$decoded[config('app.locale')] ?? null, ...array_values($decoded)],
|
||||
static fn ($html) => is_string($html) && $html !== ''
|
||||
))
|
||||
: [$rawDescription];
|
||||
|
||||
foreach ($htmls as $html) {
|
||||
$url = $this->firstInternalImageUrl($html);
|
||||
|
||||
if ($url !== null) {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 HTML 에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본).
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @return string|null 첫 내부 이미지 URL
|
||||
*/
|
||||
private function firstInternalImageUrl(string $html): ?string
|
||||
{
|
||||
foreach ($this->imageSources($html) as $src) {
|
||||
$resolved = $this->resolveInternal($src);
|
||||
|
||||
if ($resolved === null || mb_strlen($resolved) > self::MAX_URL_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 에서 모든 img src 를 문서 순서로 수집합니다 (동결 사본).
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @return array<int, string> src 목록
|
||||
*/
|
||||
private function imageSources(string $html): array
|
||||
{
|
||||
if (trim($html) === '' || ! class_exists(\DOMDocument::class)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$document = new \DOMDocument('1.0', 'UTF-8');
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
|
||||
try {
|
||||
$loaded = $document->loadHTML(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div>'
|
||||
.$html.'</div></body></html>',
|
||||
LIBXML_NOERROR | LIBXML_NOWARNING
|
||||
);
|
||||
|
||||
if (! $loaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sources = [];
|
||||
|
||||
foreach ($document->getElementsByTagName('img') as $img) {
|
||||
if (! $img instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$src = trim($img->getAttribute('src'));
|
||||
|
||||
if ($src !== '') {
|
||||
$sources[] = $src;
|
||||
}
|
||||
}
|
||||
|
||||
return $sources;
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* src 후보를 내부 URL 로 해석합니다 (동결 사본 — 정규화 규칙 포함).
|
||||
*
|
||||
* @param string $src img src 속성값
|
||||
* @return string|null 내부 URL (외부/비허용 스킴이면 null)
|
||||
*/
|
||||
private function resolveInternal(string $src): ?string
|
||||
{
|
||||
$trimmed = trim($src);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeForOriginCheck($trimmed);
|
||||
|
||||
if ($normalized === '' || str_starts_with($normalized, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($normalized, '/')) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$scheme = parse_url($normalized, PHP_URL_SCHEME);
|
||||
|
||||
if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) {
|
||||
$host = parse_url($normalized, PHP_URL_HOST);
|
||||
$appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
|
||||
if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) {
|
||||
$path = parse_url($normalized, PHP_URL_PATH);
|
||||
$query = parse_url($normalized, PHP_URL_QUERY);
|
||||
|
||||
return (is_string($path) && $path !== '' ? $path : '/')
|
||||
.(is_string($query) && $query !== '' ? '?'.$query : '');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* origin 판정 전 브라우저 URL 파서 동형 정규화 (동결 사본).
|
||||
*
|
||||
* @param string $url 원본 URL
|
||||
* @return string 정규화된 URL
|
||||
*/
|
||||
private function normalizeForOriginCheck(string $url): string
|
||||
{
|
||||
$stripped = str_replace(["\t", "\n", "\r"], '', $url);
|
||||
$slashed = str_replace('\\', '/', $stripped);
|
||||
|
||||
return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"generated_at": "2026-08-21T13:47:13+00:00",
|
||||
"generated_at": "2026-08-22T12:22:17+00:00",
|
||||
"generator": "g7 vendor-bundle:build",
|
||||
"target": "module:sirsoft-ecommerce",
|
||||
"composer_json_sha256": "6dc16ed7acb614593e17d97cf3230af0935e3e4910000026b4a7062d4f9de031",
|
||||
"composer_json_sha256": "0556835aa543c462efe9132123b57234fddb66e482898144e7930f6cbedb1a9d",
|
||||
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
|
||||
"zip_sha256": "b1f6cfcb3fea1c68be7ffd55074acab8c2d51c2750e9c036a678abc40ebee405",
|
||||
"zip_size": 435549,
|
||||
"zip_sha256": "3127bf97f4342c91cffc0d729eaa70f2a1f74a4a4015288081a082e665ccb9c0",
|
||||
"zip_size": 435548,
|
||||
"package_count": 1,
|
||||
"php_requirement": "^8.2",
|
||||
"g7_version": "7.0.8",
|
||||
"g7_version": "7.0.9",
|
||||
"packages": [
|
||||
{
|
||||
"name": "ezyang/htmlpurifier",
|
||||
|
||||
Binary file not shown.
@@ -4,6 +4,16 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.0] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- 페이지를 공유할 때의 미리보기 이미지(og:image)가 본문의 첫 이미지로 채워집니다. 이전에는 페이지에 공유 이미지를 공급할 방법이 전혀 없어 미리보기 카드가 항상 이미지 없이 표시되었습니다. 외부 주소의 이미지는 사용하지 않으며, 기존 페이지는 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 페이지 공유 미리보기의 설명(og:description)이 항상 비어 있던 문제를 수정했습니다. 이제 페이지 SEO 설정의 설명이 사용됩니다.
|
||||
|
||||
## [1.0.3] - 2026-08-19
|
||||
|
||||
### Security
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-page",
|
||||
"description": "Page module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* pages 에 본문 첫 내부 이미지 URL 캐시 컬럼 추가 (공개 이슈 #22 동종)
|
||||
*
|
||||
* 페이지는 og:image 를 공급할 경로가 전무했다(본문·첨부·수동 어느 경로로도 불가).
|
||||
* 저장 시점(모델 saving 이벤트)에 본문(다국어 JSON) 첫 내부 이미지를 추출해 캐시하고,
|
||||
* 공개 Resource 가 방출해 페이지 표시 레이아웃의 og:image 가 소비한다.
|
||||
* 기존 행 백필은 Upgrade_1_1_0 업그레이드 스텝이 수행한다.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('pages', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('pages', 'content_thumbnail_url')) {
|
||||
$table->string('content_thumbnail_url', 1000)
|
||||
->nullable()
|
||||
->after('content_mode')
|
||||
->comment('본문 첫 내부 이미지 URL 캐시 — 페이지 og:image 폴백');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이그레이션 롤백
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('pages')) {
|
||||
$columns = Schema::getColumnListing('pages');
|
||||
|
||||
Schema::table('pages', function (Blueprint $table) use ($columns) {
|
||||
if (in_array('content_thumbnail_url', $columns)) {
|
||||
$table->dropColumn('content_thumbnail_url');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1271,6 +1271,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| title | string | `이용약관` | 제목 |
|
||||
| content | string | `<h2 style="font-size: 1.25rem; font-w…` | 본문 내용 |
|
||||
| content_mode | string | `html` | 본문 형식 (html, text) |
|
||||
| content_thumbnail_url | string \| null | `null` | 본문 첫 내부 이미지 URL 캐시 — 페이지 표시 레이아웃의 공유 미리보기(og:image)가 소비한다. html 모드에서 본문에 내부 이미지가 있으면 채워지고, 외부 주소 이미지·text 모드·이미지 없음이면 `null` (1.1.0+) |
|
||||
| is_preview | boolean | `false` | preview 여부 |
|
||||
| published_at | string | `2026-07-08 10:44:43` | published 일시 |
|
||||
| seo_meta | null | `null` | SEO 메타 정보 (title, description, keywords) |
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"ko": "페이지",
|
||||
"en": "Page"
|
||||
},
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
|
||||
"en": "Static page (info/policy/guide) management module"
|
||||
},
|
||||
"g7_version": ">=7.0.7",
|
||||
"g7_version": ">=7.0.9",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -53,6 +53,8 @@ class PublicPageResource extends BaseApiResource
|
||||
? ($this->content[$locale] ?? $this->content[$fallback] ?? (! empty($this->content) ? array_values($this->content)[0] : ''))
|
||||
: (string) ($this->content ?? ''),
|
||||
'content_mode' => $this->content_mode ?? 'html',
|
||||
// 본문 첫 내부 이미지 URL 캐시 — 페이지 표시 레이아웃의 og:image 가 소비 (공개 #22)
|
||||
'content_thumbnail_url' => $this->content_thumbnail_url,
|
||||
'is_preview' => $this->preview,
|
||||
'published_at' => $this->published_at
|
||||
? $this->formatDateTimeStringForUser($this->published_at)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace Modules\Sirsoft\Page\Models;
|
||||
|
||||
use App\Casts\AsUnicodeJson;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use App\Casts\AsUnicodeJson;
|
||||
use App\Search\Contracts\FulltextSearchable;
|
||||
use App\Support\HtmlImageExtractor;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -52,6 +54,7 @@ class Page extends Model implements FulltextSearchable
|
||||
'title',
|
||||
'content',
|
||||
'content_mode',
|
||||
'content_thumbnail_url',
|
||||
'published',
|
||||
'published_at',
|
||||
'seo_meta',
|
||||
@@ -77,6 +80,65 @@ class Page extends Model implements FulltextSearchable
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 이벤트 등록
|
||||
*
|
||||
* 본문 첫 내부 이미지 URL 캐시(content_thumbnail_url)는 저장 시점에만 계산한다.
|
||||
* 모델 saving 이벤트에 두는 이유: PageService::restoreVersion() 이 서비스
|
||||
* create/update 를 우회해 Repository 를 직접 호출하므로, 버전 롤백 시에도 캐시가
|
||||
* 자동 재계산되는 유일 지점이 모델 이벤트다 (공개 이슈 #22 동종 — board 와 동형).
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (Page $page) {
|
||||
if ($page->exists && ! $page->isDirty('content') && ! $page->isDirty('content_mode')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// text 모드 본문은 이스케이프 렌더(이미지 미표시) — 캐시하지 않는다
|
||||
if ($page->content_mode !== 'html') {
|
||||
$page->content_thumbnail_url = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// content 는 다국어 JSON — 기본 로케일 우선, 없으면 배열 순서대로
|
||||
// 첫 내부 이미지가 나올 때까지 시도 (레거시 평문 문자열도 수용)
|
||||
$extracted = null;
|
||||
$candidates = [];
|
||||
$content = $page->content;
|
||||
$htmls = is_array($content)
|
||||
? array_values(array_filter(
|
||||
[$content[config('app.locale')] ?? null, ...array_values($content)],
|
||||
fn ($html) => is_string($html) && $html !== ''
|
||||
))
|
||||
: (is_string($content) && $content !== '' ? [$content] : []);
|
||||
|
||||
foreach ($htmls as $html) {
|
||||
$candidates = array_merge($candidates, HtmlImageExtractor::candidates($html));
|
||||
$extracted ??= HtmlImageExtractor::firstInternal($html);
|
||||
|
||||
if ($extracted !== null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null)할 수 있는 필터 훅.
|
||||
// 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다.
|
||||
$value = HookManager::applyFilters(
|
||||
'sirsoft-page.page.filter_content_thumbnail',
|
||||
$extracted,
|
||||
$page,
|
||||
$candidates
|
||||
);
|
||||
|
||||
// 필터 반환값 방어 — 비문자열/빈 값/컬럼 상한 초과는 null(후보 없음)로 강등
|
||||
$page->content_thumbnail_url = is_string($value) && $value !== '' && mb_strlen($value) <= 1000
|
||||
? $value
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자와의 관계를 정의합니다.
|
||||
*
|
||||
@@ -120,8 +182,8 @@ class Page extends Model implements FulltextSearchable
|
||||
/**
|
||||
* 발행된 페이지만 조회하는 스코프
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
* @param Builder $query
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopePublished($query)
|
||||
{
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Tests\Feature\Upgrade;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
|
||||
use Modules\Sirsoft\Page\Upgrades\Upgrade_1_1_0;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 1.1.0 페이지 본문 썸네일 백필 업그레이드 스텝 테스트 (공개 이슈 #22 동종)
|
||||
*
|
||||
* @group page
|
||||
* @group upgrade
|
||||
*/
|
||||
class PageContentThumbnailBackfillTest extends ModuleTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['app.locale' => 'ko']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
DB::table('pages')->where('slug', 'like', 'backfill-%')->delete();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 이벤트를 우회해 레거시 상태 페이지 행을 만듭니다.
|
||||
*
|
||||
* @param array $attributes 덮어쓸 속성
|
||||
* @return int 페이지 ID
|
||||
*/
|
||||
private function createLegacyRow(array $attributes = []): int
|
||||
{
|
||||
return DB::table('pages')->insertGetId(array_merge([
|
||||
'slug' => 'backfill-'.uniqid(),
|
||||
'title' => json_encode(['ko' => '레거시 페이지'], JSON_UNESCAPED_UNICODE),
|
||||
'content' => json_encode(['ko' => '<p>본문</p>'], JSON_UNESCAPED_UNICODE),
|
||||
'content_mode' => 'html',
|
||||
'content_thumbnail_url' => null,
|
||||
'published' => 1,
|
||||
'published_at' => now(),
|
||||
'current_version' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
private function runBackfill(): void
|
||||
{
|
||||
$context = new UpgradeContext('1.0.3', '1.1.0', '1.1.0', 'extension-upgrade');
|
||||
|
||||
(new Upgrade_1_1_0)->run($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario backfill=legacy_row
|
||||
*
|
||||
* @effects backfill_fills_legacy_pages
|
||||
*/
|
||||
#[Test]
|
||||
public function legacy_rows_are_backfilled_and_gated(): void
|
||||
{
|
||||
$legacy = $this->createLegacyRow([
|
||||
'content' => json_encode([
|
||||
'ko' => '<img src="https://evil.example.org/skip.jpg"><img src="/storage/pages/legacy.jpg">',
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$textMode = $this->createLegacyRow([
|
||||
'content_mode' => 'text',
|
||||
'content' => json_encode(['ko' => '텍스트 <img src="/storage/pages/literal.jpg">'], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$filled = $this->createLegacyRow([
|
||||
'content' => json_encode(['ko' => '<img src="/storage/pages/other.jpg">'], JSON_UNESCAPED_UNICODE),
|
||||
'content_thumbnail_url' => '/storage/pages/kept.jpg',
|
||||
]);
|
||||
|
||||
$this->runBackfill();
|
||||
|
||||
$this->assertSame('/storage/pages/legacy.jpg', DB::table('pages')->where('id', $legacy)->value('content_thumbnail_url'));
|
||||
$this->assertNull(DB::table('pages')->where('id', $textMode)->value('content_thumbnail_url'));
|
||||
$this->assertSame('/storage/pages/kept.jpg', DB::table('pages')->where('id', $filled)->value('content_thumbnail_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario backfill=idempotent
|
||||
*
|
||||
* @effects backfill_idempotent_second_run
|
||||
*/
|
||||
#[Test]
|
||||
public function second_run_is_idempotent(): void
|
||||
{
|
||||
$this->createLegacyRow([
|
||||
'content' => json_encode(['ko' => '<img src="/storage/pages/a.jpg">'], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$this->createLegacyRow([
|
||||
'content' => json_encode(['ko' => '<p>이미지 없음</p>'], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
$this->runBackfill();
|
||||
$first = $this->snapshot();
|
||||
|
||||
$this->runBackfill();
|
||||
$second = $this->snapshot();
|
||||
|
||||
$this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, content_thumbnail_url: string|null}> 스냅샷
|
||||
*/
|
||||
private function snapshot(): array
|
||||
{
|
||||
return DB::table('pages')
|
||||
->where('slug', 'like', 'backfill-%')
|
||||
->orderBy('id')
|
||||
->get(['id', 'content_thumbnail_url'])
|
||||
->map(fn ($row) => ['id' => (int) $row->id, 'content_thumbnail_url' => $row->content_thumbnail_url])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Tests\Feature\User;
|
||||
|
||||
// FeatureTestCase를 수동으로 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../FeatureTestCase.php';
|
||||
|
||||
use Modules\Sirsoft\Page\Models\Page;
|
||||
use Modules\Sirsoft\Page\Tests\FeatureTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 공개 페이지 응답의 content_thumbnail_url 방출 테스트 (공개 이슈 #22 동종)
|
||||
*
|
||||
* PublicPageResource 가 본문 첫 내부 이미지 캐시를 방출해 페이지 표시 레이아웃의
|
||||
* og:image 바인딩(page.data.content_thumbnail_url)이 값을 받는지 API 경계에서
|
||||
* 고정합니다.
|
||||
*/
|
||||
class PageContentThumbnailResourceTest extends FeatureTestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Page::where('slug', 'like', 'thumb-emit-%')->forceDelete();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_internal_only, locale_content=default_locale
|
||||
*
|
||||
* @effects public_resource_emits_thumbnail_key
|
||||
*/
|
||||
#[Test]
|
||||
public function public_response_emits_content_thumbnail_url(): void
|
||||
{
|
||||
config(['app.locale' => 'ko']);
|
||||
|
||||
Page::factory()->create([
|
||||
'slug' => 'thumb-emit-filled',
|
||||
'published' => true,
|
||||
'published_at' => now(),
|
||||
'content' => ['ko' => '<p>본문</p><img src="/storage/pages/og-image.jpg">'],
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/modules/sirsoft-page/pages/thumb-emit-filled');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertSame('/storage/pages/og-image.jpg', $response->json('data.content_thumbnail_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=none, locale_content=default_locale
|
||||
*
|
||||
* @effects public_resource_emits_thumbnail_key
|
||||
*/
|
||||
#[Test]
|
||||
public function public_response_emits_null_when_no_image(): void
|
||||
{
|
||||
Page::factory()->create([
|
||||
'slug' => 'thumb-emit-empty',
|
||||
'published' => true,
|
||||
'published_at' => now(),
|
||||
'content' => ['ko' => '<p>이미지 없는 본문</p>'],
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/modules/sirsoft-page/pages/thumb-emit-empty');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertArrayHasKey('content_thumbnail_url', $response->json('data'));
|
||||
$this->assertNull($response->json('data.content_thumbnail_url'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 페이지 og:image 본문 첫 이미지 공급 — 봇 경로 실측 (공개 이슈 #22, 내부 #610).
|
||||
*
|
||||
* 페이지는 og:image 를 공급할 경로가 전무했다. 본문에 이미지를 넣어 저장하면
|
||||
* 봇 경로(`?_escaped_fragment_=`)의 서버 렌더 HTML 에 og:image 메타가 절대 URL 로
|
||||
* 실리는지, 이미지 없는 페이지는 og:image 가 출력되지 않는지(사이트 기본값 미설정 시)
|
||||
* 확인한다. og:description 死키 정렬(#11)도 함께 실측한다.
|
||||
*
|
||||
* 단위/통합: PageContentThumbnailTest(saving 추출), PageContentThumbnailResourceTest
|
||||
* (Resource 방출)가 담당하고, 이 spec 은 봇 경로 meta 렌더를 담당한다.
|
||||
*
|
||||
* 전용 페이지(slug: e2e-og-image-*)를 spec 안에서 생성/삭제하므로 시드 의존이 없다.
|
||||
*
|
||||
* @scenario page-og-image
|
||||
* @axes image_source=content_internal_only image_source=none
|
||||
* @effects bot_page_renders_og_image_meta,
|
||||
* content_image_fills_page_og
|
||||
*/
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { issueToken } from '../../../../../../../tests/Playwright/fixtures/auth';
|
||||
|
||||
const API = '/api/modules/sirsoft-page';
|
||||
|
||||
type PageAuthFixtures = { pageManageToken: string };
|
||||
|
||||
const test = base.extend<PageAuthFixtures>({
|
||||
pageManageToken: async ({}, use) => {
|
||||
await use(issueToken(
|
||||
'sirsoft-page.pages.create',
|
||||
'sirsoft-page.pages.read',
|
||||
'sirsoft-page.pages.update',
|
||||
'sirsoft-page.pages.delete',
|
||||
));
|
||||
},
|
||||
});
|
||||
|
||||
/** API 컨텍스트로 호출한다 (spec 전용 — 페이지 방문 불필요한 셋업/정리용). */
|
||||
async function api(
|
||||
request: import('@playwright/test').APIRequestContext,
|
||||
bearer: string,
|
||||
method: 'get' | 'post' | 'delete',
|
||||
path: string,
|
||||
data?: unknown,
|
||||
): Promise<{ status: number; body: any }> {
|
||||
const response = await request[method](path, {
|
||||
headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' },
|
||||
data: data as any,
|
||||
});
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
/* 비 JSON 응답 허용 */
|
||||
}
|
||||
return { status: response.status(), body };
|
||||
}
|
||||
|
||||
test.describe('페이지 og:image 본문 이미지 공급 (공개 #22)', () => {
|
||||
test('본문 이미지 페이지의 봇 경로 HTML 에 og:image 절대 URL 이 실린다', async ({ request, pageManageToken }) => {
|
||||
const slug = 'e2e-og-image-filled';
|
||||
await api(request, pageManageToken, 'delete', `${API}/admin/pages/by-slug/${slug}`).catch(() => null);
|
||||
|
||||
const created = await api(request, pageManageToken, 'post', `${API}/admin/pages`, {
|
||||
slug,
|
||||
title: { ko: '[검수] og 이미지 페이지' },
|
||||
content: { ko: '<p>본문</p><img src="/favicon.ico">' },
|
||||
content_mode: 'html',
|
||||
published: true,
|
||||
seo_meta: { description: 'og 설명 검증용' },
|
||||
});
|
||||
expect([200, 201], `page create failed: ${JSON.stringify(created.body).slice(0, 300)}`).toContain(created.status);
|
||||
const pageId = created.body?.data?.id;
|
||||
|
||||
// 공개 API 가 캐시를 방출하는지 (레이아웃 og.image 의 데이터 근원)
|
||||
const pub = await api(request, pageManageToken, 'get', `${API}/pages/${slug}`);
|
||||
expect(pub.body?.data?.content_thumbnail_url).toBe('/favicon.ico');
|
||||
|
||||
// 봇 경로 서버 렌더 — og:image 절대 URL + og:description 정렬(#11)
|
||||
const bot = await request.get(`/page/${slug}?_escaped_fragment_=`);
|
||||
const html = await bot.text();
|
||||
expect(html).toMatch(/<meta property="og:image" content="https?:\/\/[^"]+\/favicon\.ico">/);
|
||||
expect(html).toContain('<meta property="og:description" content="og 설명 검증용">');
|
||||
|
||||
// 정리
|
||||
const del = await api(request, pageManageToken, 'delete', `${API}/admin/pages/${pageId}`);
|
||||
expect([200, 204]).toContain(del.status);
|
||||
});
|
||||
|
||||
test('이미지 없는 페이지는 og:image 가 본문 폴백으로 채워지지 않는다', async ({ request, pageManageToken }) => {
|
||||
const slug = 'e2e-og-image-empty';
|
||||
|
||||
const created = await api(request, pageManageToken, 'post', `${API}/admin/pages`, {
|
||||
slug,
|
||||
title: { ko: '[검수] og 이미지 없는 페이지' },
|
||||
content: { ko: '<p>이미지가 없는 본문입니다.</p>' },
|
||||
content_mode: 'html',
|
||||
published: true,
|
||||
});
|
||||
expect([200, 201]).toContain(created.status);
|
||||
const pageId = created.body?.data?.id;
|
||||
|
||||
const pub = await api(request, pageManageToken, 'get', `${API}/pages/${slug}`);
|
||||
expect(pub.body?.data?.content_thumbnail_url).toBeNull();
|
||||
|
||||
// 정리
|
||||
const del = await api(request, pageManageToken, 'delete', `${API}/admin/pages/${pageId}`);
|
||||
expect([200, 204]).toContain(del.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Tests\Unit\Models;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Page\Models\Page;
|
||||
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* Page 모델 content_thumbnail_url 캐시 계산 테스트 (공개 이슈 #22 동종)
|
||||
*
|
||||
* saving 이벤트가 다국어 본문(JSON)에서 첫 내부 이미지 URL 을 저장 시점에
|
||||
* 추출·캐시하는 계약을 고정합니다. 버전 롤백(restoreVersion)이 Repository 를
|
||||
* 직접 호출해도 모델 이벤트가 커버하는 것이 배치 근거입니다.
|
||||
*/
|
||||
class PageContentThumbnailTest extends ModuleTestCase
|
||||
{
|
||||
private const FILTER_HOOK = 'sirsoft-page.page.filter_content_thumbnail';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['app.locale' => 'ko']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
HookManager::clearFilter(self::FILTER_HOOK);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $attributes 덮어쓸 속성
|
||||
* @return Page 생성된 페이지
|
||||
*/
|
||||
private function createPage(array $attributes = []): Page
|
||||
{
|
||||
return Page::factory()->create($attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_internal_only, locale_content=default_locale
|
||||
*
|
||||
* @effects content_image_fills_page_og
|
||||
*/
|
||||
#[Test]
|
||||
public function saving_extracts_first_internal_image(): void
|
||||
{
|
||||
$page = $this->createPage([
|
||||
'content' => [
|
||||
'ko' => '<p>본문</p><img src="/storage/pages/ko-first.jpg">',
|
||||
'en' => '<img src="/storage/pages/en.jpg">',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame('/storage/pages/ko-first.jpg', $page->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_internal_only, locale_content=other_locale_only
|
||||
*
|
||||
* @effects other_locale_image_used_when_default_has_none
|
||||
*/
|
||||
#[Test]
|
||||
public function falls_back_to_other_locale(): void
|
||||
{
|
||||
$page = $this->createPage([
|
||||
'content' => [
|
||||
'ko' => '<p>이미지 없음</p>',
|
||||
'en' => '<img src="/storage/pages/en-only.jpg">',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame('/storage/pages/en-only.jpg', $page->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=content_external_only, locale_content=default_locale
|
||||
*
|
||||
* @effects external_only_content_yields_null
|
||||
*/
|
||||
#[Test]
|
||||
public function external_only_content_yields_null(): void
|
||||
{
|
||||
$page = $this->createPage([
|
||||
'content' => ['ko' => '<img src="https://evil.example.org/x.jpg"><img src="//cdn.example.org/y.jpg">'],
|
||||
]);
|
||||
|
||||
$this->assertNull($page->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario image_source=none, locale_content=default_locale
|
||||
*
|
||||
* @effects external_only_content_yields_null
|
||||
*/
|
||||
#[Test]
|
||||
public function text_mode_and_no_image_yield_null(): void
|
||||
{
|
||||
$noImage = $this->createPage(['content' => ['ko' => '<p>이미지 없음</p>']]);
|
||||
$textMode = $this->createPage([
|
||||
'content_mode' => 'text',
|
||||
'content' => ['ko' => '텍스트 <img src="/storage/pages/literal.jpg"> 마크업'],
|
||||
]);
|
||||
|
||||
$this->assertNull($noImage->fresh()->content_thumbnail_url);
|
||||
$this->assertNull($textMode->fresh()->content_thumbnail_url, 'text 모드는 캐시하지 않아야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects recompute_on_content_change
|
||||
*/
|
||||
#[Test]
|
||||
public function content_change_recomputes_and_unrelated_update_does_not(): void
|
||||
{
|
||||
$page = $this->createPage([
|
||||
'content' => ['ko' => '<img src="/storage/pages/old.jpg">'],
|
||||
]);
|
||||
|
||||
$page->update(['content' => ['ko' => '<img src="/storage/pages/new.jpg">']]);
|
||||
$this->assertSame('/storage/pages/new.jpg', $page->fresh()->content_thumbnail_url);
|
||||
|
||||
DB::table('pages')->where('id', $page->id)
|
||||
->update(['content_thumbnail_url' => '/storage/pages/manual.jpg']);
|
||||
|
||||
$page->fresh()->update(['published' => true, 'published_at' => now()]);
|
||||
|
||||
$this->assertSame(
|
||||
'/storage/pages/manual.jpg',
|
||||
$page->fresh()->content_thumbnail_url,
|
||||
'content 가 dirty 가 아니면 재계산하지 않아야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects filter_hook_can_override_or_block
|
||||
*/
|
||||
#[Test]
|
||||
public function filter_hook_can_override_or_block(): void
|
||||
{
|
||||
HookManager::addFilter(self::FILTER_HOOK, fn ($value) => null);
|
||||
|
||||
$blocked = $this->createPage([
|
||||
'content' => ['ko' => '<img src="/storage/pages/internal.jpg">'],
|
||||
]);
|
||||
|
||||
$this->assertNull($blocked->fresh()->content_thumbnail_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 롤백 경로(Repository 직접 update)에서도 캐시가 재계산되어야 합니다.
|
||||
*
|
||||
* @effects version_restore_recomputes_cache
|
||||
*/
|
||||
#[Test]
|
||||
public function model_level_update_recomputes_cache(): void
|
||||
{
|
||||
$page = $this->createPage([
|
||||
'content' => ['ko' => '<img src="/storage/pages/v1.jpg">'],
|
||||
]);
|
||||
|
||||
// restoreVersion 은 Repository 를 직접 호출한다 — 모델 update 경로와 동일한
|
||||
// saving 이벤트를 타므로 모델 레벨 update 로 등가 검증한다
|
||||
Page::findOrFail($page->id)->update([
|
||||
'content' => ['ko' => '<img src="/storage/pages/v2-restored.jpg">'],
|
||||
]);
|
||||
|
||||
$this->assertSame('/storage/pages/v2-restored.jpg', $page->fresh()->content_thumbnail_url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
feature: 페이지 og:image 본문 첫 이미지 공급
|
||||
|
||||
description: |
|
||||
공개 이슈 #22 동종 — 페이지는 og:image 를 공급할 경로가 전무했다(본문·첨부·수동
|
||||
어느 경로로도 불가). 저장 시 본문(다국어 JSON) 첫 내부 이미지 URL 을
|
||||
pages.content_thumbnail_url 에 캐시하고, PublicPageResource 가 방출해 페이지 표시
|
||||
레이아웃(page/show.json)의 og.image / structured_data.image 가 소비한다.
|
||||
별건 #11(og:description 死키 excerpt)도 seo_meta.description 으로 정렬했다.
|
||||
|
||||
axes:
|
||||
image_source: [content_internal_only, content_external_only, none]
|
||||
locale_content: [default_locale, other_locale_only]
|
||||
|
||||
exclusions:
|
||||
- { image_source: content_external_only, locale_content: other_locale_only, reason: "외부 제외 판정은 로케일 무관 (전 로케일 순회 후 null) — default_locale 대표 조합으로 검증" }
|
||||
- { image_source: none, locale_content: other_locale_only, reason: "이미지 없음은 로케일 구분이 성립하지 않는다 (전 로케일 빈 결과)" }
|
||||
|
||||
sub_flows:
|
||||
- id: backfill
|
||||
description: 1.1.0 업그레이드 스텝이 기존 페이지를 chunkById 백필 (다국어 JSON 파싱 동결 사본)
|
||||
effects:
|
||||
- backfill_fills_legacy_pages
|
||||
- backfill_idempotent_second_run
|
||||
- id: recompute
|
||||
description: content/content_mode 변경 시에만 saving 이벤트가 재계산 (버전 롤백 경로 포함)
|
||||
effects:
|
||||
- recompute_on_content_change
|
||||
- filter_hook_can_override_or_block
|
||||
- version_restore_recomputes_cache
|
||||
|
||||
effects:
|
||||
- content_image_fills_page_og
|
||||
- other_locale_image_used_when_default_has_none
|
||||
- external_only_content_yields_null
|
||||
- public_resource_emits_thumbnail_key
|
||||
- bot_page_renders_og_image_meta
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php
|
||||
- modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php
|
||||
- modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php
|
||||
- modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* Page 모듈 1.1.0 업그레이드 스텝
|
||||
*
|
||||
* 기존 페이지의 본문 첫 내부 이미지 URL 을 content_thumbnail_url 캐시 컬럼에
|
||||
* 백필한다 (공개 이슈 #22 동종 — 페이지 og:image 공급 경로 신설).
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.1.0/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path A
|
||||
*/
|
||||
class Upgrade_1_1_0 extends AbstractUpgradeStep {}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftPage\V1_1_0\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 기존 페이지의 본문 첫 내부 이미지 URL 을 content_thumbnail_url 에 백필합니다.
|
||||
*
|
||||
* 1.1.0 이전에 작성된 페이지는 저장 시점 추출(모델 saving 이벤트)을 거치지 않아
|
||||
* 캐시가 비어 있다 — 백필해야 기존 페이지의 og:image 가 채워진다.
|
||||
*
|
||||
* 멱등: whereNull 필터가 이미 채워진 행을 제외한다. text 모드 본문은 이스케이프
|
||||
* 렌더(이미지 미표시)이므로 html 모드만 대상이다.
|
||||
*
|
||||
* V-1 안전: 추출·정규화·다국어 JSON 순회 로직은 코어 HtmlImageExtractor /
|
||||
* TrustedScriptHosts / 모델 saving 콜백의 1.1.0 시점 동결 사본이다
|
||||
* (버전 스냅샷 규약 — 코어/모듈 클래스 미참조).
|
||||
*/
|
||||
class BackfillPageContentThumbnails implements DataMigration
|
||||
{
|
||||
private const PAGES_TABLE = 'pages';
|
||||
|
||||
/**
|
||||
* 캐시 컬럼 상한 (string(1000) 정합 — 1.1.0 동결).
|
||||
*/
|
||||
private const MAX_URL_LENGTH = 1000;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillPageContentThumbnails';
|
||||
}
|
||||
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
if (! Schema::hasTable(self::PAGES_TABLE)
|
||||
|| ! Schema::hasColumn(self::PAGES_TABLE, 'content_thumbnail_url')) {
|
||||
$context->logger->warning('[page:1.1.0] pages.content_thumbnail_url 미존재 — 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$filled = 0;
|
||||
$noCandidate = 0;
|
||||
|
||||
// text 모드 본문은 이스케이프 렌더(이미지 미표시)이므로 html 모드만 대상
|
||||
DB::table(self::PAGES_TABLE)
|
||||
->whereNull('content_thumbnail_url')
|
||||
->where('content_mode', 'html')
|
||||
->whereNotNull('content')
|
||||
->where('content', '!=', '')
|
||||
->orderBy('id')
|
||||
->select('id', 'content')
|
||||
->chunkById(200, function ($pages) use (&$filled, &$noCandidate) {
|
||||
foreach ($pages as $page) {
|
||||
$url = $this->firstInternalFromContent((string) $page->content);
|
||||
|
||||
if ($url === null) {
|
||||
$noCandidate++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table(self::PAGES_TABLE)
|
||||
->where('id', $page->id)
|
||||
->update(['content_thumbnail_url' => $url]);
|
||||
|
||||
$filled++;
|
||||
}
|
||||
});
|
||||
|
||||
$context->logger->info("[page:1.1.0] 본문 썸네일 백필: 채움 {$filled} / 후보 없음 {$noCandidate}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 다국어 JSON(또는 레거시 평문) 본문에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본).
|
||||
*
|
||||
* @param string $rawContent DB 원시 content 값
|
||||
* @return string|null 첫 내부 이미지 URL (없으면 null)
|
||||
*/
|
||||
private function firstInternalFromContent(string $rawContent): ?string
|
||||
{
|
||||
if (trim($rawContent) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($rawContent, true);
|
||||
|
||||
$htmls = is_array($decoded)
|
||||
? array_values(array_filter(
|
||||
[$decoded[config('app.locale')] ?? null, ...array_values($decoded)],
|
||||
static fn ($html) => is_string($html) && $html !== ''
|
||||
))
|
||||
: [$rawContent];
|
||||
|
||||
foreach ($htmls as $html) {
|
||||
$url = $this->firstInternalImageUrl($html);
|
||||
|
||||
if ($url !== null) {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 HTML 에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본).
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @return string|null 첫 내부 이미지 URL
|
||||
*/
|
||||
private function firstInternalImageUrl(string $html): ?string
|
||||
{
|
||||
foreach ($this->imageSources($html) as $src) {
|
||||
$resolved = $this->resolveInternal($src);
|
||||
|
||||
if ($resolved === null || mb_strlen($resolved) > self::MAX_URL_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 에서 모든 img src 를 문서 순서로 수집합니다 (동결 사본).
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @return array<int, string> src 목록
|
||||
*/
|
||||
private function imageSources(string $html): array
|
||||
{
|
||||
if (trim($html) === '' || ! class_exists(\DOMDocument::class)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$document = new \DOMDocument('1.0', 'UTF-8');
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
|
||||
try {
|
||||
$loaded = $document->loadHTML(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div>'
|
||||
.$html.'</div></body></html>',
|
||||
LIBXML_NOERROR | LIBXML_NOWARNING
|
||||
);
|
||||
|
||||
if (! $loaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sources = [];
|
||||
|
||||
foreach ($document->getElementsByTagName('img') as $img) {
|
||||
if (! $img instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$src = trim($img->getAttribute('src'));
|
||||
|
||||
if ($src !== '') {
|
||||
$sources[] = $src;
|
||||
}
|
||||
}
|
||||
|
||||
return $sources;
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* src 후보를 내부 URL 로 해석합니다 (동결 사본 — 정규화 규칙 포함).
|
||||
*
|
||||
* @param string $src img src 속성값
|
||||
* @return string|null 내부 URL (외부/비허용 스킴이면 null)
|
||||
*/
|
||||
private function resolveInternal(string $src): ?string
|
||||
{
|
||||
$trimmed = trim($src);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeForOriginCheck($trimmed);
|
||||
|
||||
if ($normalized === '' || str_starts_with($normalized, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($normalized, '/')) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$scheme = parse_url($normalized, PHP_URL_SCHEME);
|
||||
|
||||
if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) {
|
||||
$host = parse_url($normalized, PHP_URL_HOST);
|
||||
$appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
|
||||
if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) {
|
||||
$path = parse_url($normalized, PHP_URL_PATH);
|
||||
$query = parse_url($normalized, PHP_URL_QUERY);
|
||||
|
||||
return (is_string($path) && $path !== '' ? $path : '/')
|
||||
.(is_string($query) && $query !== '' ? '?'.$query : '');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* origin 판정 전 브라우저 URL 파서 동형 정규화 (동결 사본).
|
||||
*
|
||||
* @param string $url 원본 URL
|
||||
* @return string 정규화된 URL
|
||||
*/
|
||||
private function normalizeForOriginCheck(string $url): string
|
||||
{
|
||||
$stripped = str_replace(["\t", "\n", "\r"], '', $url);
|
||||
$slashed = str_replace('\\', '/', $stripped);
|
||||
|
||||
return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user