Merge branch 'develop'
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.8
|
||||
APP_VERSION=7.0.9
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.8
|
||||
APP_VERSION=7.0.9
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
|
||||
|
||||
|
||||
### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
|
||||
### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
|
||||
|
||||
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
|
||||
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
|
||||
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 21 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.9] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 알림 템플릿·본인인증 메시지에서 [기본값 복원]을 실행하면 언어팩이 제공한 번역(일본어 등)이 함께 지워지던 문제를 수정했습니다. 이제 복원 기본값에도 활성 언어팩의 번역이 포함되어, 복원 후에도 해당 언어 화면에 번역이 유지됩니다. 또한 이번 버전으로 업그레이드하면 이전 버전에서 이미 지워졌던 언어팩 번역이 활성 언어팩 기준으로 자동 복구됩니다.
|
||||
- 설치·복구 과정의 데이터 동기화에서 코어 알림 정의와 본인인증 메시지에 언어팩 번역이 주입되지 않을 수 있던 문제를 수정했습니다.
|
||||
- 언어팩 업데이트가 실패했을 때 사용 중이던 언어팩이 비활성 상태로 남아, 해당 언어의 화면·메일 문구가 안내 없이 기본 언어로 표시되던 문제를 수정했습니다. 이제 실패 시 이전 상태로 복원됩니다.
|
||||
- 언어팩을 다시 활성화하거나 업데이트할 때 관리자가 직접 수정해 둔 알림·본인인증 템플릿의 해당 언어 문구가 언어팩 기본값으로 덮어써지던 문제를 수정했습니다. 이제 관리자가 수정한 언어만 보존되고, 수정하지 않은 언어는 언어팩 갱신을 그대로 따라갑니다.
|
||||
- 언어팩 활성화 직후 같은 화면 흐름에서 일부 기본 데이터에 새 언어 번역이 반영되지 않던 문제를 수정했습니다.
|
||||
|
||||
## [7.0.8] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -289,7 +289,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.8 g7
|
||||
# (필요 시) mv g7-7.0.9 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.8-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.9-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
@@ -520,9 +520,9 @@ cp .env.example .env
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
|
||||
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.8-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.9-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
@@ -534,9 +534,9 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
|
||||
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
|
||||
|
||||
@@ -97,6 +97,20 @@ class SaveSettingsRequest extends FormRequest
|
||||
]);
|
||||
}
|
||||
|
||||
// 사이트 기본 OG 이미지도 동일하게 첨부 객체 배열 → 정수 ID 배열로 정규화
|
||||
$ogImageDefault = $this->input('seo.og_image_default');
|
||||
|
||||
if (is_array($ogImageDefault)) {
|
||||
$this->merge([
|
||||
'seo' => array_merge($this->input('seo', []), [
|
||||
'og_image_default' => array_map(
|
||||
fn ($item) => is_array($item) ? ($item['id'] ?? $item) : $item,
|
||||
$ogImageDefault
|
||||
),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// GeoIP 토글 필드: 신규 필드라 초기값이 settings에 없을 수 있음.
|
||||
// Toggle 컴포넌트가 undefined → true 전환 시 비표준 타입을 보낼 수 있으므로
|
||||
// boolean으로 명시 캐스팅하여 Laravel boolean 검증 호환 보장.
|
||||
@@ -243,6 +257,8 @@ class SaveSettingsRequest extends FormRequest
|
||||
'seo.bot_detection_enabled' => ['nullable', 'boolean'],
|
||||
'seo.bot_detection_library_enabled' => ['nullable', 'boolean'],
|
||||
'seo.og_default_site_name' => ['nullable', 'string', 'max:200'],
|
||||
'seo.og_image_default' => ['nullable', 'array'],
|
||||
'seo.og_image_default.*' => ['integer', Rule::exists(Attachment::class, 'id')],
|
||||
'seo.og_image_default_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_width_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_width_max', 8000)],
|
||||
'seo.og_image_default_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_height_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_height_max', 8000)],
|
||||
'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])],
|
||||
@@ -915,6 +931,7 @@ class SaveSettingsRequest extends FormRequest
|
||||
'general.maintenance_mode' => __('validation.attributes.maintenance_mode'),
|
||||
'general.asset_url_mode' => __('validation.attributes.asset_url_mode'),
|
||||
'general.site_logo' => __('validation.attributes.site_logo'),
|
||||
'seo.og_image_default' => __('validation.attributes.og_image_default'),
|
||||
// mail
|
||||
'mail.mailer' => __('validation.attributes.mailer'),
|
||||
'mail.host' => __('validation.attributes.smtp_host'),
|
||||
|
||||
@@ -112,11 +112,13 @@ class RunSeedersOnLanguagePackLifecycle
|
||||
*/
|
||||
private function refreshRegistry(): void
|
||||
{
|
||||
// Singleton 캐시된 registry/injector 가 stale 활성 팩 목록을 들고 있을 수 있으므로 forget
|
||||
$this->app->forgetInstance(LanguagePackRegistry::class);
|
||||
$this->app->forgetInstance(LanguagePackSeedInjector::class);
|
||||
|
||||
// boot 시점에 등록된 seed 필터 클로저들은 injector→registry "인스턴스"를 캡처하고 있다.
|
||||
// forgetInstance 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시(활성 팩 목록)가
|
||||
// 그대로 남아, 같은 프로세스에서 활성화된 신규 팩이 코어 시더의 번역 필터에 보이지
|
||||
// 않는다 (오류 없이 해당 로케일만 미주입). 싱글톤을 유지한 채 내부 캐시만 무효화해
|
||||
// 캡처된 참조에도 전파한다 (#597 에서 실측·교정).
|
||||
$registry = $this->app->make(LanguagePackRegistry::class);
|
||||
$registry->invalidate();
|
||||
config([
|
||||
'app.supported_locales' => $registry->getActiveCoreLocales(),
|
||||
'app.locale_names' => $registry->getLocaleNames(),
|
||||
|
||||
@@ -9,17 +9,19 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* `core.language_packs.after_activate` / `after_deactivate` 액션 훅을 받아 DB JSON 다국어
|
||||
* `core.language_packs.activated` / `deactivated` 액션 훅을 받아 DB JSON 다국어
|
||||
* 컬럼을 동기화하는 thin Listener (G7 표준 HookManager::doAction → addAction 메커니즘).
|
||||
*
|
||||
* 영속/도메인 책임은 모두 LanguagePackTranslationRepository 에 위임. 본 클래스는 seed 파일
|
||||
* 로딩 + Repository 호출 + 감사 로그 라우팅만 수행합니다.
|
||||
*
|
||||
* 정책 (계획서 §6.6, §9.4):
|
||||
* 정책 (계획서 §6.6, §9.4) — 보존 판정은 운영자 수정의 실제 기록 형식인 dot-path
|
||||
* 항목(`"{컬럼}.{로케일}"`, HasUserOverrides updating 이벤트가 기록)을 기준으로 한다:
|
||||
* - Case A: 기존 row 의 다국어 JSON 에 해당 locale 키 부재 → 추가
|
||||
* - Case B: locale 키 존재 + user_overrides 에 컬럼 등록 → 건너뜀 (사용자 수정 보존)
|
||||
* - Case C: locale 키 존재 + user_overrides 미등록 → 시드 값으로 덮어쓰기
|
||||
* - 비활성화/제거 시: user_overrides 등록 locale 은 JSON 에서 제거하지 않음
|
||||
* - Case B: locale 키 존재 + user_overrides 에 `컬럼.로케일` 등록 → 건너뜀 (사용자 수정 보존)
|
||||
* - Case C: locale 키 존재 + 해당 dot-path 미등록 → 시드 값으로 덮어쓰기
|
||||
* (컬럼 전체 항목 `"subject"` 등은 시더 재실행 보호 선언이라 팩 병합을 막지 않는다)
|
||||
* - 비활성화/제거 시: user_overrides 에 `컬럼.로케일` 등록된 locale 은 JSON 에서 제거하지 않음
|
||||
*/
|
||||
// audit:allow listener-must-implement-hooklistenerinterface reason: LanguagePackServiceProvider 가 HookManager::addAction 으로 직접 등록하는 명시 등록 패턴 (auto-discovery 대상 아님)
|
||||
class SyncDatabaseTranslations
|
||||
|
||||
@@ -30,6 +30,17 @@ class IdentityMessageTemplate extends Model
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
|
||||
* (NotificationTemplate 과 동형 — 미선언의 결과는 그쪽 주석 참조, #597 에서 교정)
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $translatableTrackableFields = [
|
||||
'subject',
|
||||
'body',
|
||||
];
|
||||
|
||||
/**
|
||||
* 활동 로그 추적 필드.
|
||||
*
|
||||
|
||||
@@ -27,6 +27,20 @@ class NotificationTemplate extends Model
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
|
||||
*
|
||||
* 미선언 시 수정이 컬럼 전체 항목(`"subject"`)으로 기록되어 시더 재실행이 컬럼을
|
||||
* 통째로 얼리고(다른 로케일 갱신·신규 팩 로케일 추가 불가), 언어팩 병합의
|
||||
* 사용자 수정 보존(Case B)도 로케일 단위로 판정할 수 없다 (#597 에서 교정).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $translatableTrackableFields = [
|
||||
'subject',
|
||||
'body',
|
||||
];
|
||||
|
||||
/**
|
||||
* 모델 이벤트 등록 — 템플릿 변경 시 알림 정의 캐시 자동 삭제.
|
||||
*/
|
||||
@@ -110,13 +124,6 @@ class NotificationTemplate extends Model
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 채널의 템플릿 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string $channel
|
||||
* @return Builder
|
||||
*/
|
||||
/**
|
||||
* 수신자 규칙이 설정되어 있는지 확인합니다.
|
||||
*
|
||||
@@ -127,6 +134,13 @@ class NotificationTemplate extends Model
|
||||
return ! empty($this->recipients);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 채널의 템플릿 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string $channel
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByChannel(Builder $query, string $channel): Builder
|
||||
{
|
||||
return $query->where('channel', $channel);
|
||||
|
||||
@@ -41,29 +41,37 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
*/
|
||||
public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array
|
||||
{
|
||||
$audit = [];
|
||||
$locale = $pack->locale;
|
||||
// 팩 주입은 시딩 컨텍스트다 — 바인딩 없이 저장하면 UserOverridesAwareBuilder 가
|
||||
// 주입 자체를 "사용자 수정"으로 오인해 user_overrides 를 오염시키고, 그 선언이
|
||||
// 이후 시더 재실행의 컬럼 갱신을 영구히 얼린다 (#597 에서 실측·교정).
|
||||
app()->instance('user_overrides.seeding', true);
|
||||
try {
|
||||
$audit = [];
|
||||
$locale = $pack->locale;
|
||||
|
||||
if (! empty($seedBundle['permissions'])) {
|
||||
$this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['roles'])) {
|
||||
$this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
|
||||
$this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['notifications'])) {
|
||||
$this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
|
||||
}
|
||||
if (! empty($seedBundle['identity_messages'])) {
|
||||
$this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
|
||||
}
|
||||
if (! empty($seedBundle['manifest'])) {
|
||||
$this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit);
|
||||
}
|
||||
if (! empty($seedBundle['permissions'])) {
|
||||
$this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['roles'])) {
|
||||
$this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
|
||||
$this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['notifications'])) {
|
||||
$this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
|
||||
}
|
||||
if (! empty($seedBundle['identity_messages'])) {
|
||||
$this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
|
||||
}
|
||||
if (! empty($seedBundle['manifest'])) {
|
||||
$this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit);
|
||||
}
|
||||
|
||||
return $audit;
|
||||
return $audit;
|
||||
} finally {
|
||||
app()->forgetInstance('user_overrides.seeding');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +81,23 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
* @return array<int, array<string, mixed>> 감사 로그 항목
|
||||
*/
|
||||
public function stripLocaleFromPack(LanguagePack $pack): array
|
||||
{
|
||||
// applySeedFromPack 과 동일 — 제거도 시딩 컨텍스트 (user_overrides 오염 차단)
|
||||
app()->instance('user_overrides.seeding', true);
|
||||
try {
|
||||
return $this->stripLocaleFromPackBody($pack);
|
||||
} finally {
|
||||
app()->forgetInstance('user_overrides.seeding');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* stripLocaleFromPack 의 본문 (시딩 바인딩 하에서 실행).
|
||||
*
|
||||
* @param LanguagePack $pack 비활성화된 언어팩
|
||||
* @return array<int, array<string, mixed>> 감사 로그 항목
|
||||
*/
|
||||
private function stripLocaleFromPackBody(LanguagePack $pack): array
|
||||
{
|
||||
$audit = [];
|
||||
$locale = $pack->locale;
|
||||
@@ -402,7 +427,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
}
|
||||
|
||||
$hasLocaleKey = array_key_exists($locale, $current);
|
||||
$isOverridden = isset($overrides[$column]);
|
||||
$isOverridden = $this->isColumnLocaleOverridden($overrides, $column, $locale);
|
||||
|
||||
if ($hasLocaleKey && $isOverridden) {
|
||||
$audit[] = [
|
||||
@@ -413,6 +438,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
'locale' => $locale,
|
||||
'reason' => 'user_overrides',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -443,7 +469,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($overrides[$column])) {
|
||||
if ($this->isColumnLocaleOverridden($overrides, $column, $locale)) {
|
||||
$audit[] = [
|
||||
'action' => 'preserved',
|
||||
'table' => $row->getTable(),
|
||||
@@ -452,6 +478,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
'locale' => $locale,
|
||||
'reason' => 'user_overrides',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -464,4 +491,27 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
$row->saveQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 컬럼·로케일이 운영자 수정(user_overrides)으로 보존 대상인지 판정합니다.
|
||||
*
|
||||
* 운영자 수정의 실제 기록 형식은 dot-path 리스트(`"{column}.{locale}"` —
|
||||
* HasUserOverrides 의 updating 이벤트가 기록, beta.4 가 레거시 항목을 dot-path 로
|
||||
* 전환)이므로 그 형식만 본다. 종전의 `isset($overrides[$column])` 판정은 어떤
|
||||
* 생산자도 쓰지 않는 연관 형식을 전제해 항상 false 였고, 그 결과 활성화/비활성화가
|
||||
* 운영자 수정 번역을 오류 없이 덮어쓰거나 제거했다 (#597 에서 발견·교정).
|
||||
*
|
||||
* 컬럼 전체 항목(`"subject"` 등)은 의도적으로 보존 사유로 보지 않는다 — 그 형식은
|
||||
* 시더 재실행(syncFromUpgrade)의 컬럼 보호 선언이며, 팩 병합까지 차단하면 팩
|
||||
* 업데이트가 자기 로케일 번역을 갱신하지 못하게 된다 (종전에도 차단된 적 없음).
|
||||
*
|
||||
* @param array<int, string> $overrides user_overrides 리스트
|
||||
* @param string $column 다국어 JSON 컬럼명
|
||||
* @param string $locale 대상 로케일
|
||||
* @return bool 보존 대상 여부
|
||||
*/
|
||||
protected function isColumnLocaleOverridden(array $overrides, string $column, string $locale): bool
|
||||
{
|
||||
return in_array($column.'.'.$locale, $overrides, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Seo;
|
||||
|
||||
use App\Seo\Concerns\LocalizesSeoValues;
|
||||
use App\Seo\Concerns\SubstitutesSeoVariables;
|
||||
use App\Services\SettingsService;
|
||||
|
||||
class SeoMetaResolver
|
||||
{
|
||||
@@ -515,6 +516,19 @@ class SeoMetaResolver
|
||||
$title = $this->stripHtml($this->safeEval($og['title'] ?? '', $context)) ?: $fallbackTitle;
|
||||
$description = $this->stripHtml($this->safeEval($og['description'] ?? '', $context)) ?: $fallbackDescription;
|
||||
$image = $this->absoluteUrl($this->safeEval($og['image'] ?? '', $context));
|
||||
|
||||
// 사이트 기본 OG 이미지 폴백 (공개 이슈 #22) — 도메인 콘텐츠 폴백(본문 캐시)은
|
||||
// 각 모듈 레이아웃/훅 바인딩이 공급하므로 코어는 사이트 기본값 한 단만 담당한다.
|
||||
// 체인: 레이아웃 선언(도메인 캐시 포함) → 사이트 기본 이미지 → 빈 값(태그 미출력).
|
||||
// secure_url 파생은 반드시 이 폴백 적용 후의 $image 기준이어야 한다.
|
||||
if ($image === '') {
|
||||
$defaultImage = app(SettingsService::class)->getOgDefaultImageUrl();
|
||||
|
||||
if (is_string($defaultImage) && $defaultImage !== '') {
|
||||
$image = $this->absoluteUrl($defaultImage);
|
||||
}
|
||||
}
|
||||
|
||||
$secureUrl = isset($og['image_secure_url'])
|
||||
? $this->absoluteUrl($this->safeEval($og['image_secure_url'], $context))
|
||||
: ($image !== '' && str_starts_with($image, 'https://') ? $image : '');
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\IdentityMessageDefinition;
|
||||
use App\Models\IdentityMessageTemplate;
|
||||
use App\Services\LanguagePack\LanguagePackSeedInjector;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -37,6 +38,7 @@ class IdentityMessageTemplateService
|
||||
private readonly IdentityMessageTemplateRepositoryInterface $repository,
|
||||
private readonly IdentityMessageDefinitionService $definitionService,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LanguagePackSeedInjector $seedInjector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -271,11 +273,19 @@ class IdentityMessageTemplateService
|
||||
{
|
||||
$coreDefinitions = $this->loadCoreMessageDefinitions();
|
||||
|
||||
return HookManager::applyFilters(
|
||||
$definitions = HookManager::applyFilters(
|
||||
'core.identity.filter_default_message_definitions',
|
||||
$coreDefinitions,
|
||||
[]
|
||||
);
|
||||
|
||||
// 활성 언어팩 seed 로케일 병합 — 시딩(seed.identity_messages.translations)과 같은 SSoT.
|
||||
// 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의
|
||||
// ko/en 만으로 대체해 영구 소실시킨다 (notification 쪽 실사례의 동형 — #597 보완 실측).
|
||||
// injectIdentityMessages 는 string 키('mail.purpose.signup' 등)로 seed 를 매칭하므로
|
||||
// loadCoreMessageDefinitions 가 config 키를 보존해 넘긴다. 확장 리스너가 정수 키로
|
||||
// append 한 항목은 seed 에 매칭되지 않아 그대로 통과한다.
|
||||
return $this->seedInjector->injectIdentityMessages($definitions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,13 +302,14 @@ class IdentityMessageTemplateService
|
||||
$common = $this->commonVariables();
|
||||
$result = [];
|
||||
|
||||
foreach ($messages as $data) {
|
||||
foreach ($messages as $key => $data) {
|
||||
if (($data['variables'] ?? null) === '__common__') {
|
||||
$data['variables'] = $common;
|
||||
}
|
||||
$data['extension_type'] = 'core';
|
||||
$data['extension_identifier'] = 'core';
|
||||
$result[] = $data;
|
||||
// config 키('mail.provider_default' 등)를 보존 — 언어팩 seed 매칭 키와 동일 (collectDefaultDefinitions 참조)
|
||||
$result[$key] = $data;
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
@@ -112,7 +112,7 @@ class LanguagePackSeedInjector
|
||||
}
|
||||
|
||||
foreach ($definitions as $defIdx => $def) {
|
||||
$type = $def['type'] ?? null;
|
||||
$type = $this->resolveDefinitionType($defIdx, $def);
|
||||
if (! $type || ! isset($seed[$type])) {
|
||||
continue;
|
||||
}
|
||||
@@ -166,7 +166,7 @@ class LanguagePackSeedInjector
|
||||
continue;
|
||||
}
|
||||
foreach ($definitions as $defIdx => $def) {
|
||||
$type = $def['type'] ?? null;
|
||||
$type = $this->resolveDefinitionType($defIdx, $def);
|
||||
if (! $type || ! isset($seed[$type])) {
|
||||
continue;
|
||||
}
|
||||
@@ -722,4 +722,30 @@ class LanguagePackSeedInjector
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정의 배열 항목에서 알림 type 을 해석합니다.
|
||||
*
|
||||
* 코어 시더(`loadConfigSeed('core.notification_definitions', ...)`)는 config 원형
|
||||
* (연관 배열 — 키가 곧 type, 항목에 type 필드 없음)을 그대로 필터에 넘기고,
|
||||
* 확장 시더(ModuleManager/PluginManager)와 [기본값 복원] 경로는 각 항목에 type
|
||||
* 필드를 포함한 리스트를 넘긴다. 양쪽 형태를 모두 수용한다 —
|
||||
* 문자열 키가 있으면 그것이 type 이고, 아니면 항목의 type 필드를 본다.
|
||||
* (형태 불일치 시 예외 없이 전 항목이 스킵되어 ja 가 조용히 주입되지 않는
|
||||
* 회귀가 있었다 — 2026-08-24 #597 보완 실측. NotificationSeedInjectionTest 가 고정.)
|
||||
*
|
||||
* @param int|string $defIdx 정의 배열의 키
|
||||
* @param mixed $def 정의 항목
|
||||
* @return string|null 해석된 type (해석 불가 시 null)
|
||||
*/
|
||||
private function resolveDefinitionType(int|string $defIdx, mixed $def): ?string
|
||||
{
|
||||
if (is_string($defIdx) && $defIdx !== '') {
|
||||
return $defIdx;
|
||||
}
|
||||
|
||||
$type = is_array($def) ? ($def['type'] ?? null) : null;
|
||||
|
||||
return is_string($type) && $type !== '' ? $type : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1814,10 +1814,21 @@ class LanguagePackService
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 복원 (DB 행이 finalizeInstall 에서 status 를 다시 쓰지 않은 경우만)
|
||||
// 상태 복원 — 파일은 위에서 백업으로 원상 복원되므로 상태도 이전 상태로 되돌린다.
|
||||
// Updating: 설치 트랜잭션 이전에 실패한 경우.
|
||||
// Installed: 설치 트랜잭션이 installed 를 기록한 뒤(활성화 단계 이전/도중) 실패한 경우 —
|
||||
// 여기서 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의
|
||||
// 백엔드 번역이 오류·로그 없이 통째로 폴백된다 (#597 보완 실측에서 유사 상태 실측).
|
||||
// 활성화가 성공적으로 끝난 뒤(active) 실패한 경우는 덮어쓰지 않는다.
|
||||
try {
|
||||
$current = $this->repository->findByIdentifier($identifier);
|
||||
if ($current && $current->status === LanguagePackStatus::Updating->value) {
|
||||
$restorable = [
|
||||
LanguagePackStatus::Updating->value,
|
||||
LanguagePackStatus::Installed->value,
|
||||
];
|
||||
if ($current
|
||||
&& $current->status !== $previousStatus
|
||||
&& in_array($current->status, $restorable, true)) {
|
||||
$this->repository->update($current, ['status' => $previousStatus]);
|
||||
}
|
||||
} catch (Throwable $statusError) {
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
|
||||
use App\Contracts\Repositories\NotificationTemplateRepositoryInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Services\LanguagePack\LanguagePackSeedInjector;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class NotificationTemplateService
|
||||
@@ -33,6 +34,7 @@ class NotificationTemplateService
|
||||
private readonly NotificationTemplateRepositoryInterface $templateRepository,
|
||||
private readonly NotificationDefinitionRepositoryInterface $definitionRepository,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LanguagePackSeedInjector $seedInjector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -189,6 +191,11 @@ class NotificationTemplateService
|
||||
['type' => $type, 'channel' => $channel]
|
||||
);
|
||||
|
||||
// 활성 언어팩 seed 로케일 병합 — 시딩(seed.notifications.translations)과 같은 SSoT.
|
||||
// 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의
|
||||
// ko/en 만으로 대체해 영구 소실시킨다 (2026-08-23 실사례 — #597 보완 실측).
|
||||
$definitions = $this->mergeLanguagePackLocalesIntoDefaults($definitions);
|
||||
|
||||
foreach ($definitions as $def) {
|
||||
if ($def['type'] === $type) {
|
||||
foreach ($def['templates'] as $tpl) {
|
||||
@@ -207,6 +214,36 @@ class NotificationTemplateService
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 정의 배열에 활성 언어팩 seed 로케일을 병합합니다.
|
||||
*
|
||||
* 코어 소유 정의는 활성 코어 팩(injectNotifications), 확장 소유 정의는
|
||||
* 해당 확장의 활성 모듈/플러그인 팩(injectExtensionNotifications)이 공급한다.
|
||||
* definition type 은 전역 고유(UNIQUE)이므로 확장별 호출이 다른 확장의
|
||||
* 정의에 오병합될 여지는 없다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $definitions type 필드를 포함한 기본 정의 배열
|
||||
* @return array<int, array<string, mixed>> 활성 팩 로케일이 병합된 정의 배열
|
||||
*/
|
||||
private function mergeLanguagePackLocalesIntoDefaults(array $definitions): array
|
||||
{
|
||||
$definitions = $this->seedInjector->injectNotifications($definitions);
|
||||
|
||||
$extensionIdentifiers = [];
|
||||
foreach ($definitions as $def) {
|
||||
$identifier = is_array($def) ? ($def['extension_identifier'] ?? null) : null;
|
||||
if (is_string($identifier) && $identifier !== '' && $identifier !== 'core') {
|
||||
$extensionIdentifiers[$identifier] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($extensionIdentifiers) as $identifier) {
|
||||
$definitions = $this->seedInjector->injectExtensionNotifications($definitions, $identifier);
|
||||
}
|
||||
|
||||
return $definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리보기용 렌더링.
|
||||
*
|
||||
|
||||
@@ -147,6 +147,9 @@ class SettingsService
|
||||
// 첨부파일 정보 추가 (general 카테고리에)
|
||||
$settings['general']['site_logo'] = $this->getSiteLogoAttachment();
|
||||
|
||||
// 사이트 기본 OG 이미지 첨부 정보 (seo 카테고리에 — site_logo 와 동형)
|
||||
$settings['seo']['og_image_default'] = $this->getAttachmentListSetting('seo.og_image_default');
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
@@ -437,16 +440,30 @@ class SettingsService
|
||||
*/
|
||||
private function getSiteLogoAttachment(): array
|
||||
{
|
||||
// JSON 설정에서 site_logo ID 배열 조회
|
||||
$siteLogoIds = $this->configRepository->get('general.site_logo', []);
|
||||
return $this->getAttachmentListSetting('general.site_logo');
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 ID 배열 설정을 첨부 목록(리소스 배열)으로 해석합니다.
|
||||
*
|
||||
* site_logo / og_image_default 처럼 "첨부 ID 배열 + 화면은 첨부 객체" 패턴의
|
||||
* 설정이 공유하는 로드 경로입니다.
|
||||
*
|
||||
* @param string $configKey 설정 키 (예: 'general.site_logo')
|
||||
* @return array 첨부 목록 (없으면 빈 배열)
|
||||
*/
|
||||
private function getAttachmentListSetting(string $configKey): array
|
||||
{
|
||||
// JSON 설정에서 첨부 ID 배열 조회
|
||||
$ids = $this->configRepository->get($configKey, []);
|
||||
|
||||
// 빈 배열이거나 배열이 아닌 경우
|
||||
if (empty($siteLogoIds) || ! is_array($siteLogoIds)) {
|
||||
if (empty($ids) || ! is_array($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ID 배열로 첨부파일 조회 (DB order 기준 정렬)
|
||||
$attachments = $this->attachmentRepository->findByIds($siteLogoIds);
|
||||
$attachments = $this->attachmentRepository->findByIds($ids);
|
||||
|
||||
if ($attachments->isEmpty()) {
|
||||
return [];
|
||||
@@ -455,6 +472,21 @@ class SettingsService
|
||||
return $attachments->map(fn ($attachment) => (new AttachmentResource($attachment))->toListArray())->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 기본 OG 이미지 URL 을 반환합니다.
|
||||
*
|
||||
* SeoMetaResolver 가 레이아웃 og.image 선언(도메인 캐시 포함)이 빈 값일 때
|
||||
* 마지막 폴백으로 사용합니다 (공개 이슈 #22 — 사이트 기본 공유 이미지).
|
||||
*
|
||||
* @return string|null 첫 번째 이미지 첨부의 다운로드 URL (미설정 시 null)
|
||||
*/
|
||||
public function getOgDefaultImageUrl(): ?string
|
||||
{
|
||||
$ids = $this->configRepository->get('seo.og_image_default', []);
|
||||
|
||||
return $this->getFirstImageUrl(is_array($ids) ? $ids : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 ID 배열에서 첫 번째 이미지의 URL을 반환합니다.
|
||||
*
|
||||
@@ -530,6 +562,22 @@ class SettingsService
|
||||
$tabSettings['site_logo'] = $this->resolveSiteLogoIds($tabSettings['site_logo']);
|
||||
}
|
||||
|
||||
// 사이트 기본 OG 이미지도 site_logo 와 동형으로 처리 (공개 이슈 #22)
|
||||
$removedOgImageIds = [];
|
||||
|
||||
if ($tab === 'seo' && is_array($tabSettings['og_image_default'] ?? null)) {
|
||||
$removedOgImageIds = $this->resolveRemovedAttachmentIds(
|
||||
'seo',
|
||||
'og_image_default',
|
||||
$tabSettings['og_image_default']
|
||||
);
|
||||
|
||||
$tabSettings['og_image_default'] = $this->resolveSubmittedAttachmentIds(
|
||||
'og_image_default',
|
||||
$tabSettings['og_image_default']
|
||||
);
|
||||
}
|
||||
|
||||
// 기존 설정과 병합 (탭별로 일부 필드만 전송되어도 기존 설정 유지)
|
||||
$existingSettings = $this->configRepository->getCategory($tab);
|
||||
$mergedSettings = array_merge($existingSettings, $tabSettings);
|
||||
@@ -548,6 +596,7 @@ class SettingsService
|
||||
|
||||
// 저장이 확정된 뒤에야 파일을 파기한다 (위 판정 시점 주석 참조).
|
||||
$this->purgeSiteLogoAttachments($removedSiteLogoIds);
|
||||
$this->purgeRemovedAttachments($removedOgImageIds, '기본 OG 이미지');
|
||||
|
||||
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
|
||||
// 모드가 바뀌면 그 URL 들이 전부 어긋나는데, 봇은 JavaScript 를 실행하지
|
||||
@@ -709,6 +758,18 @@ class SettingsService
|
||||
* @return array<int, int> 저장할 첨부파일 ID 배열
|
||||
*/
|
||||
private function resolveSiteLogoIds(array $submitted): array
|
||||
{
|
||||
return $this->resolveSubmittedAttachmentIds('site_logo', $submitted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제출된 첨부 목록에서 해당 컬렉션에 실재하는 첨부 ID 만 남깁니다.
|
||||
*
|
||||
* @param string $collection 첨부 컬렉션명 (예: 'site_logo', 'og_image_default')
|
||||
* @param array $submitted 제출값 (첨부 객체 배열 또는 ID 배열)
|
||||
* @return array<int, int> 저장할 첨부 ID 목록
|
||||
*/
|
||||
private function resolveSubmittedAttachmentIds(string $collection, array $submitted): array
|
||||
{
|
||||
$submittedIds = $this->extractAttachmentIds($submitted);
|
||||
|
||||
@@ -716,13 +777,72 @@ class SettingsService
|
||||
return [];
|
||||
}
|
||||
|
||||
$existingIds = $this->attachmentRepository->getByCollection('site_logo')
|
||||
$existingIds = $this->attachmentRepository->getByCollection($collection)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
return array_values(array_intersect($submittedIds, $existingIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 첨부 설정 ID 를 가려냅니다.
|
||||
*
|
||||
* 판정 기준·시점 규율은 resolveRemovedSiteLogoIds 와 동일합니다 — 직전 저장값에
|
||||
* 있었는데 이번 제출에서 빠진 id 만 파기 대상이며, 저장으로 값이 덮이기 전에
|
||||
* 판정해야 합니다.
|
||||
*
|
||||
* @param string $category 설정 카테고리 (예: 'seo')
|
||||
* @param string $key 설정 키 (예: 'og_image_default')
|
||||
* @param mixed $submitted 제출값 (미제출이면 null)
|
||||
* @return array<int, int> 파기 대상 첨부 ID 목록
|
||||
*/
|
||||
private function resolveRemovedAttachmentIds(string $category, string $key, mixed $submitted): array
|
||||
{
|
||||
if (! is_array($submitted)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$previousIds = $this->extractAttachmentIds(
|
||||
$this->configRepository->getCategory($category)[$key] ?? []
|
||||
);
|
||||
|
||||
if ($previousIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keptIds = $this->extractAttachmentIds($submitted);
|
||||
|
||||
return array_values(array_diff($previousIds, $keptIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 제거가 확정된 설정 첨부를 파일까지 파기합니다 (저장 성공 후에만 호출).
|
||||
*
|
||||
* @param array<int, int> $removedIds 파기 대상 첨부 ID 목록
|
||||
* @param string $label 로그 표기용 설정 이름
|
||||
*/
|
||||
private function purgeRemovedAttachments(array $removedIds, string $label): void
|
||||
{
|
||||
if ($removedIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($removedIds as $removedId) {
|
||||
// 설정 저장은 이미 확정된 뒤다 — 파기 실패가 저장 실패(422)로 위장되면
|
||||
// 운영자는 성공한 저장을 실패로 오인한다. 실패 파일은 로그로만 남긴다.
|
||||
try {
|
||||
$this->attachmentService->delete($removedId);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning($label.' 첨부 파기 실패 — 저장은 확정됨', [
|
||||
'attachment_id' => $removedId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info($label.' 첨부 제거', ['attachment_ids' => $removedIds]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 사이트 로고 첨부 ID 를 가려냅니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 본문 HTML 에서 이미지 URL 후보를 추출하는 공용 유틸리티.
|
||||
*
|
||||
* 게시글(board)·상품 설명(ecommerce)·페이지(page)처럼 에디터로 작성된 본문에서
|
||||
* "첫 번째 내부 이미지" 를 뽑아 목록 썸네일·og:image 폴백 캐시로 쓰기 위한 계약이다.
|
||||
* 내부/외부 판정은 origin 정규화 SSoT 인
|
||||
* {@see TrustedScriptHosts::normalizeForOriginCheck()} 를 그대로 재사용한다 —
|
||||
* 문자열상 path 로 보이지만 브라우저가 외부 origin 으로 읽는 형태
|
||||
* (`/\/evil.com/x.jpg` 등)를 내부로 오판하지 않기 위함이다.
|
||||
*
|
||||
* 다국어 JSON 콘텐츠(로케일별 HTML 배열)는 호출측이 로케일을 순회하며 이 클래스에는
|
||||
* 단일 HTML 문자열만 전달한다.
|
||||
*/
|
||||
final class HtmlImageExtractor
|
||||
{
|
||||
/**
|
||||
* 반환 URL 최대 길이 (캐시 컬럼 `string(1000)` 상한 정합).
|
||||
*/
|
||||
private const MAX_URL_LENGTH = 1000;
|
||||
|
||||
/**
|
||||
* 파서가 요구하는 문서 골격을 씌울 래퍼 요소 id.
|
||||
*/
|
||||
private const ROOT_ID = 'g7-html-image-extractor-root';
|
||||
|
||||
/**
|
||||
* HTML 에서 모든 `<img>` 의 `src` 속성을 문서 순서로 수집합니다.
|
||||
*
|
||||
* ext-dom 부재·파싱 실패 시에는 안전측(빈 배열)으로 폴백합니다.
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @return array<int, string> src 속성값 목록 (빈 값 제외, 문서 순서)
|
||||
*/
|
||||
public static function candidates(string $html): array
|
||||
{
|
||||
if (trim($html) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! 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 id="'
|
||||
.self::ROOT_ID.'">'.$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 $e) {
|
||||
Log::warning('본문 이미지 추출 실패 — 후보 없음으로 폴백', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 HTML 에서 첫 번째 **내부** 이미지 URL 을 반환합니다.
|
||||
|
||||
* 내부 판정 규칙 (후보를 문서 순서로 순회하며 첫 통과 값):
|
||||
* 1. {@see TrustedScriptHosts::normalizeForOriginCheck()} 로 정규화
|
||||
* 2. 정규화 결과 `//` 시작(protocol-relative) → 외부로 제외, `/` 단일 시작 → 내부
|
||||
* 3. http(s) 절대 URL 은 host 가 `config('app.url')` host 와 동일(대소문자 무시)할
|
||||
* 때만 통과하고 path 부터의 상대 형태로 변환해 반환 (응답 절대화는 소비측 규약)
|
||||
* 4. `$extraAllowedPrefixes` 중 하나로 시작(정규화 후 비교)하면 통과 — CDN 스토리지
|
||||
* 확장용이며 이 경우 원형 그대로 반환
|
||||
* 5. `data:` / `javascript:` 등 비 http(s)·비 path 스킴은 전부 제외
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @param array<int, string> $extraAllowedPrefixes 추가 허용 URL prefix 목록
|
||||
* @return string|null 첫 내부 이미지 URL (후보 없으면 null)
|
||||
*/
|
||||
public static function firstInternal(string $html, array $extraAllowedPrefixes = []): ?string
|
||||
{
|
||||
foreach (self::candidates($html) as $src) {
|
||||
$resolved = self::resolveInternal($src, $extraAllowedPrefixes);
|
||||
|
||||
if ($resolved === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 컬럼 상한 초과 URL 은 캐시 불가 — 다음 후보로 넘어간다
|
||||
if (mb_strlen($resolved) > self::MAX_URL_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 src 후보를 내부 URL 로 해석합니다.
|
||||
*
|
||||
* @param string $src img src 속성값
|
||||
* @param array<int, string> $extraAllowedPrefixes 추가 허용 URL prefix 목록
|
||||
* @return string|null 내부로 판정된 URL (외부/비허용 스킴이면 null)
|
||||
*/
|
||||
private static function resolveInternal(string $src, array $extraAllowedPrefixes): ?string
|
||||
{
|
||||
$trimmed = trim($src);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = TrustedScriptHosts::normalizeForOriginCheck($trimmed);
|
||||
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// protocol-relative 는 외부 origin
|
||||
if (str_starts_with($normalized, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 단일 슬래시 시작 = same-origin 경로
|
||||
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 : '');
|
||||
}
|
||||
}
|
||||
|
||||
// 확장이 선언한 허용 prefix (CDN 직접 URL 스토리지 등) — 원형 유지 반환
|
||||
foreach ($extraAllowedPrefixes as $prefix) {
|
||||
if (is_string($prefix) && $prefix !== '' && str_starts_with($normalized, $prefix)) {
|
||||
return $trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
// 그 외 스킴(data:/javascript:/blob: 등)·미허용 host 전부 제외
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.8'),
|
||||
'version' => env('APP_VERSION', '7.0.9'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"bot_detection_enabled": true,
|
||||
"bot_detection_library_enabled": true,
|
||||
"og_default_site_name": "",
|
||||
"og_image_default": [],
|
||||
"og_image_default_width": 1200,
|
||||
"og_image_default_height": 630,
|
||||
"twitter_default_card": "summary_large_image",
|
||||
@@ -225,6 +226,8 @@
|
||||
"bot_detection_enabled": { "type": "boolean", "sensitive": false },
|
||||
"bot_detection_library_enabled": { "type": "boolean", "sensitive": false },
|
||||
"og_default_site_name": { "type": "string", "sensitive": false },
|
||||
"og_image_default": { "type": "array", "sensitive": false, "transform": "attachments" },
|
||||
"og_image_default_url": { "type": "string", "sensitive": false, "computed": true, "source": "og_image_default", "transform": "first_image_url" },
|
||||
"og_image_default_width": { "type": "integer", "sensitive": false },
|
||||
"og_image_default_height": { "type": "integer", "sensitive": false },
|
||||
"twitter_default_card": { "type": "string", "sensitive": false },
|
||||
|
||||
@@ -72,20 +72,25 @@ class IdentityMessageDefinitionSeeder extends Seeder
|
||||
* `extension_type='core'` / `extension_identifier='core'` 자동 주입.
|
||||
* `variables: '__common__'` 마커는 commonVariables() 로 expand.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
* @return array<string, array<string, mixed>> config 복합 키('mail.purpose.signup' 등) => 정의
|
||||
*/
|
||||
private function getDefaultDefinitions(): array
|
||||
{
|
||||
$messages = config('core.identity_messages', []);
|
||||
$result = [];
|
||||
|
||||
foreach ($messages as $data) {
|
||||
foreach ($messages as $key => $data) {
|
||||
if (($data['variables'] ?? null) === '__common__') {
|
||||
$data['variables'] = $this->commonVariables();
|
||||
}
|
||||
$data['extension_type'] = 'core';
|
||||
$data['extension_identifier'] = 'core';
|
||||
$result[] = $data;
|
||||
// config 복합 키('mail.purpose.signup' 등)를 보존 — 언어팩 주입 필터
|
||||
// (seed.identity_messages.translations)가 이 키로 seed 를 매칭한다.
|
||||
// 리스트로 만들면 전 항목이 매칭 불가로 스킵되어 팩 로케일이 오류 없이
|
||||
// 주입되지 않는다 (#597 에서 실측·교정 — IdentityMessageTemplateService
|
||||
// ::loadCoreMessageDefinitions 의 동형 보존과 짝을 이룬다).
|
||||
$result[$key] = $data;
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
@@ -259,6 +259,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
|
||||
| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
|
||||
|
||||
@@ -214,6 +214,7 @@ HTTP/1.1 200
|
||||
| seo.bot_detection_enabled | body | boolean | 아니오 | — | 검색엔진 봇 감지 사용 여부 (봇 요청에 SEO 렌더링 적용) |
|
||||
| seo.bot_detection_library_enabled | body | boolean | 아니오 | — | 봇 감지 라이브러리 사용 여부 (User-Agent 목록 대신 라이브러리 판정) |
|
||||
| seo.og_default_site_name | body | string | 아니오 | max 200 | seo.og default site 이름 (식별자) |
|
||||
| seo.og_image_default | body | array | 아니오 | 항목: 존재하는 첨부 ID | 사이트 기본 공유 이미지(og:image) 첨부 ID 배열 — 화면별 og:image 가 없을 때 폴백으로 사용. 화면 제출 형태(첨부 객체 배열)도 수용하며 정수 ID 배열로 정규화된다 (7.0.9+) |
|
||||
| seo.og_image_default_width | body | integer | 아니오 | min 0, max 8000 | 기본 Open Graph 이미지 너비 (px) |
|
||||
| seo.og_image_default_height | body | integer | 아니오 | min 0, max 8000 | 기본 Open Graph 이미지 높이 (px) |
|
||||
| seo.twitter_default_card | body | string | 아니오 | — | 기본 트위터 카드 유형 (summary 등) |
|
||||
|
||||
@@ -611,6 +611,49 @@ user_id 없는 비회원(주문자 이메일/이름만 보유)도 회원과 동
|
||||
- 중복 제거 키는 네임스페이스 분리: 회원 `user:{id}` / 비회원 `guest:{이메일 해시}` — null-id 게스트가 하나로 뭉개지지 않습니다.
|
||||
- `exclude_trigger_user` 는 회원(user_id) 기준이라 게스트 수신자를 깨뜨리지 않습니다.
|
||||
|
||||
### 채널 메타의 화면 표시 키 (hidden_tab · tab_channels · tab_label_key · hidden_template_editor)
|
||||
|
||||
확장이 `core.notification.filter_available_channels` 훅으로 등록하는 채널 메타는 임의 필드를 보존해 프론트(`availableChannels` 데이터소스)까지 그대로 도달한다. 관리자 알림 설정 화면(코어 `admin_settings` · 게시판 `admin_board_settings` · 이커머스 `admin_ecommerce_settings`)은 다음 범용 키를 해석한다. 특정 확장 이름을 알지 못하며, 키가 없으면 종전과 동일하게 동작한다.
|
||||
|
||||
| 키 | 타입 | 효과 |
|
||||
| --- | --- | --- |
|
||||
| `hidden_tab` | bool | 채널 서브탭만 숨긴다. 채널 토글 카드·발송 축은 그대로 |
|
||||
| `tab_channels` | string[] | 이 채널의 탭이 대표하는 채널 id 목록. 목록 중 하나라도 활성 저장이면 탭을 노출한다(여러 채널을 하나의 탭으로 묶을 때) |
|
||||
| `tab_label_key` | string | 탭 라벨용 프론트 lang 키(`$t` 해석). 카드 라벨(`name_key`)과 분리 |
|
||||
| `hidden_template_editor` | bool | 알림 템플릿 [편집] 모달에서 코어의 언어탭·제목·본문·클릭 URL·변수 안내·[미리보기]·코어 [저장]을 숨긴다. 그 채널의 본문 규격이 코어 템플릿과 달라 확장이 편집기와 저장을 대신할 때 선언한다. 수신자 규칙·[취소]는 남는다 |
|
||||
|
||||
`hidden_template_editor` 를 선언한 확장은 편집 모달의 확장 지점 두 곳에 자기 UI 를 주입한다(3면 공통 이름 — extension_point 파일 1본으로 세 화면에 주입된다).
|
||||
|
||||
| extension_point | 위치 | props |
|
||||
| --- | --- | --- |
|
||||
| `notification_template_form_sections` | 수신자 규칙 다음, 코어 언어탭 앞 | `definition` · `template` · `channel` · `modalId` · `stateKey`(면별 코어 모달 상태 키) · `saveEndpoint`(코어 채널 템플릿 PUT) · `refetchDataSourceId`(면별 알림 정의 목록) |
|
||||
| `notification_template_form_footer_actions` | [취소] 와 코어 [저장] 사이 | 위와 동일 |
|
||||
|
||||
확장은 이 props 만으로 코어 저장 계약을 이행한다 — 코어 엔드포인트·상태 키를 리터럴로 적지 않는다. 코어 채널 템플릿의 수신자 규칙은 `_global?.[extensionPointProps.stateKey]?.recipients` 로 읽어 바뀐 경우에만 `saveEndpoint` 로 PUT 한다(매번 PUT 하면 코어가 행을 "사용자 수정" 으로 표시한다). 선례: `plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_*.json`. 행 하단 확장 지점 `notification_definition_row_footer`(props `definition` · `activeChannel`)는 상태 요약 같은 읽기 전용 UI 용이다.
|
||||
|
||||
### 정의 타입 라벨의 공급처
|
||||
|
||||
관리자 알림 설정 화면의 정의 목록 제목은 DB `name` 컬럼이 아니라 프론트 다국어 키
|
||||
`admin.settings.notification_definitions.types.{type}` 로 렌더된다. 이 키의 공급처는 정의 소유자를 따른다.
|
||||
|
||||
| 정의 소유 | 라벨 위치 |
|
||||
| --- | --- |
|
||||
| 코어 (`config/core.php` `notification_definitions`) | admin 템플릿 `lang/partial/{ko,en}/admin.json` 의 `settings.notification_definitions.types` (ja 는 템플릿 언어팩) |
|
||||
| 모듈 | 각 모듈의 알림 설정 화면과 lang 이 담당 |
|
||||
|
||||
키가 없으면 예외도 경고도 없이 원시 키 문자열이 화면 제목으로 그대로 노출된다. 코어 정의를
|
||||
추가하는 변경은 반드시 admin 템플릿 라벨(ko·en)과 템플릿 언어팩(ja)을 함께 추가해야 하며,
|
||||
코어 정의 전수 ↔ 템플릿 라벨 패리티는 정적 검사(테스트)가 자동 대조한다.
|
||||
|
||||
## 채널 템플릿을 시드하는 확장의 [기본값 복원] 의무
|
||||
|
||||
확장이 시딩 필터(`seed.notifications.translations` 등)로 자기 채널의 템플릿 행을 알림 정의에 끼워 넣으면, 같은 증강을
|
||||
`core.notification.filter_default_definitions` 에도 걸어야 한다. 관리자 화면의 [기본값 복원]은
|
||||
`NotificationTemplateService::getDefaultTemplateData()` 가 이 필터를 통과시킨 정의 배열에서 해당 채널의 template 을 찾아
|
||||
복원하므로, 시드 출처와 복원 출처가 같은 함수가 아니면 그 채널 행의 복원은 "기본 템플릿 데이터를 찾을 수 없습니다" 로 끝난다
|
||||
(시드는 되는데 복원만 안 되는 상태라 오류 로그도 남지 않는다). 모듈이 자기 정의를 이 필터에 보태는 priority(20) 뒤에서 돌아야
|
||||
모듈 정의도 증강된다. 선례: `plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php`.
|
||||
|
||||
### 채널별 비회원 발송 허용 (allow_guest)
|
||||
|
||||
채널이 비회원 발송을 허용하는지는 `config/notification.php` 채널 메타의 `allow_guest` 로 선언합니다.
|
||||
|
||||
@@ -133,6 +133,14 @@ core.activity_log.before_prune core.activity_log.after_prune
|
||||
core.notification_log.before_prune core.notification_log.after_prune
|
||||
core.schedule.before_prune_history core.schedule.after_prune_history
|
||||
|
||||
# 본문 첫 내부 이미지 썸네일 캐시 (Filter, 공개#22) — 각 모델 saving 이벤트가 발행.
|
||||
# 값 = 추출된 첫 내부 이미지 URL(없으면 null), 인자 = (값, 모델, 전체 후보 src 배열).
|
||||
# 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null 반환)할 수 있다.
|
||||
# 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다.
|
||||
sirsoft-board.post.filter_content_thumbnail
|
||||
sirsoft-ecommerce.product.filter_content_thumbnail
|
||||
sirsoft-page.page.filter_content_thumbnail
|
||||
|
||||
# 업로드 트라이어드 — 사용자 첨부 업로드 지점의 표준 3훅 패턴
|
||||
# before_upload(액션) → filter_upload_file(필터: UploadedFile 을 받아 변형본을 반환.
|
||||
# 저장 파일명·MIME·크기가 모두 반환 파일 기준이 된다) → after_upload(액션)
|
||||
|
||||
@@ -287,6 +287,48 @@ foreach ($config['categories'] as $cat) { ... }
|
||||
- `permissions.json` → `{scope}.{target}.permissions.translations` 필터 (3-레벨 트리: module/categories/permissions)
|
||||
- 기타 → `seed.{target}.{entity}.translations` 필터 (단순 entity 시드)
|
||||
|
||||
### 복원 경로의 언어팩 병합
|
||||
|
||||
[기본값 복원](알림 템플릿·본인인증 메시지)은 위 시딩 필터가 아니라 자체 기본값 필터
|
||||
(`core.notification.filter_default_definitions` / `core.identity.filter_default_message_definitions`)를
|
||||
탄다. 복원 기본값에는 **활성 언어팩 seed 로케일이 반드시 병합**되어야 한다 — 병합하지 않으면
|
||||
복원이 팩이 주입해 둔 로케일(ja 등)을 config 의 ko/en 만으로 대체해 영구 소실시키며,
|
||||
오류도 로그도 남지 않는다. 코어 서비스(NotificationTemplateService·IdentityMessageTemplateService)가
|
||||
필터 적용 후 시딩과 같은 주입기(SSoT)로 병합하며, 정적 검사(테스트)가 이 계약을 고정한다.
|
||||
|
||||
시딩 필터의 페이로드는 **발화 주체의 원형 키를 보존**해야 한다 — 코어 알림 시더는 config
|
||||
원형(연관 배열, 키가 곧 type), 본인인증은 복합 키(`mail.purpose.signup` 등)가 매칭 키다.
|
||||
키를 버리고 리스트로 만들면 주입기가 전 항목을 스킵해 팩 로케일이 오류 없이 주입되지 않는다.
|
||||
알림 주입기는 두 형태(문자열 키 우선, 없으면 type 필드)를 수용한다.
|
||||
|
||||
### 사용자 수정 보존 판정과 시딩 컨텍스트
|
||||
|
||||
언어팩 병합·제거의 사용자 수정 보존 판정은 운영자 수정의 실제 기록 형식인 **dot-path 항목**
|
||||
(`user_overrides` 의 `"{컬럼}.{로케일}"`)을 기준으로 한다. 컬럼 전체 항목(`"subject"`)은 시더
|
||||
재실행의 컬럼 보호 선언이며 팩 병합을 막지 않는다 — 막으면 팩 업데이트가 자기 로케일 번역을
|
||||
갱신하지 못한다.
|
||||
|
||||
이를 위해 두 가지가 함께 지켜져야 한다:
|
||||
|
||||
- 다국어 JSON 컬럼을 추적하는 모델은 `translatableTrackableFields` 를 선언한다 — 미선언 시
|
||||
운영자 수정이 컬럼 전체 항목으로 기록되어 로케일 단위 보존이 불가능해진다.
|
||||
- 언어팩 주입·제거처럼 시스템이 수행하는 저장은 시딩 컨텍스트(`user_overrides.seeding` 바인딩)
|
||||
안에서 실행한다 — 바인딩 없이 저장하면 주입 자체가 "사용자 수정"으로 오인 기록되어 이후
|
||||
시더 재실행의 컬럼 갱신이 영구히 얼어붙는다.
|
||||
|
||||
### 실패한 업데이트의 상태 복원
|
||||
|
||||
`language-pack:update` 가 설치 트랜잭션 이후(활성화 단계)에 실패하면 팩 상태를 이전 상태로
|
||||
복원한다. 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의 백엔드 번역이
|
||||
안내 없이 기본 로케일로 폴백된다.
|
||||
|
||||
### 같은 프로세스 활성화와 필터 인스턴스
|
||||
|
||||
시더 번역 필터 클로저는 boot 시점의 injector→registry **인스턴스**를 캡처한다. 활성 팩 목록
|
||||
캐시를 갱신할 때는 싱글톤을 유지한 채 `LanguagePackRegistry::invalidate()` 로 내부 캐시만
|
||||
비운다 — `forgetInstance` 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시가 남아, 같은
|
||||
프로세스에서 활성화된 신규 팩이 시더 필터에 보이지 않는다(오류 없이 해당 로케일만 미주입).
|
||||
|
||||
### IDV 도메인 lang pack 커버리지
|
||||
|
||||
`config/core.php` 의 본인인증 SSoT 4개 블록 중 lang pack seed 대상은 1개:
|
||||
@@ -450,7 +492,20 @@ G7 는 일본어 번들 언어팩 12종을 공식 제공합니다. ko 원본을
|
||||
- 통화/숫자 단위 변환 금지 (런타임 처리)
|
||||
- 공식체(です·ます) 강제, 친근체 금지
|
||||
- 한글 미포함 value(영문/숫자/기호만) 는 번역 스킵 → 원문 유지
|
||||
- 고정 용어집 50항목으로 일관성 강제 (예: `관리자 → 管理者`, `장바구니 → カート`)
|
||||
- 고정 용어집으로 일관성 강제 (예: `관리자 → 管理者`, `장바구니 → カート`)
|
||||
|
||||
### 용어집 등록 의무
|
||||
|
||||
번역 결과의 표기는 고정 용어집이 정한다. 용어집에 없는 도메인 용어는 어간만 번역되어
|
||||
**원본 로케일 문자가 값에 그대로 남는다** — 예외도 오류도 나지 않고, 키 대칭 검사도 값은 보지 않으므로
|
||||
그 로케일로 화면을 열기 전까지 드러나지 않는다.
|
||||
|
||||
- 새 도메인 용어를 도입하면 번역 빌드 **전에** 용어집에 등록한다
|
||||
- 이미 생성된 값을 손으로 고쳤다면 용어집도 함께 고친다 — 용어집을 두면 다음 키에서 같은 오역이 되풀이된다
|
||||
- 같은 용어는 팩 전체에서 한 표기로 통일한다 (용어집이 단일 출처)
|
||||
|
||||
정적 검사가 번역 값의 원본 로케일 문자 잔존을 검출한다. 매니페스트와 CHANGELOG,
|
||||
그리고 한국어 주석은 대상이 아니다.
|
||||
|
||||
### 설치 및 활성화
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.8] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 사이트 기본 공유 이미지(og:image) 설정의 검증 속성 라벨 번역을 추가했습니다.
|
||||
|
||||
## [1.0.7] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1129,6 +1129,7 @@ return [
|
||||
'maintenance_mode' => 'メンテナンスモード',
|
||||
'asset_url_mode' => 'アセットURL方式',
|
||||
'site_logo' => 'サイトロゴ',
|
||||
'og_image_default' => '既定の共有画像',
|
||||
'bot_user_agents' => 'ボット User-Agent 一覧',
|
||||
'bot_detection_enabled' => 'ボット検出の使用',
|
||||
'bot_detection_library_enabled' => 'ボット検出ライブラリの使用',
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 core Japanese language pack (bundled)",
|
||||
"ja": "G7 コア 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.8",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.3] - 2026-08-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- 배송업체 이름(한진·롯데·로젠)이 일본어 화면에서 "택배" 부분만 한국어로 표시되던 문제를 수정했습니다. 관리자 배송정책 화면과 초기 배송업체 데이터 모두 일본어 표기로 통일했습니다.
|
||||
- 모듈 소개 문구의 제품명이 한국어로 표시되던 문제를 수정했습니다.
|
||||
|
||||
## [1.1.2] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
+3
-3
@@ -16,9 +16,9 @@
|
||||
},
|
||||
"carrier": {
|
||||
"cj": "CJ大韓通運",
|
||||
"hanjin": "韓進택배",
|
||||
"lotte": "ロッテ택배",
|
||||
"logen": "ロジェン택배",
|
||||
"hanjin": "ハンジン宅配",
|
||||
"lotte": "ロッテ宅配",
|
||||
"logen": "ロジェン宅配",
|
||||
"ups": "UPS",
|
||||
"fedex": "FedEx",
|
||||
"dhl": "DHL",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-ecommerce",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "eコマース",
|
||||
"description": "그누보드7 eコマース モジュール - 商品、注文、決済管理"
|
||||
"description": "グヌボード7 eコマース モジュール - 商品、注文、決済管理"
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
"name": "CJ大韓通運"
|
||||
},
|
||||
"hanjin": {
|
||||
"name": "韓進택배"
|
||||
"name": "ハンジン宅配"
|
||||
},
|
||||
"lotte": {
|
||||
"name": "ロッテ택배"
|
||||
"name": "ロッテ宅配"
|
||||
},
|
||||
"logen": {
|
||||
"name": "ロジェン택배"
|
||||
"name": "ロジェン宅配"
|
||||
},
|
||||
"ups": {
|
||||
"name": "UPS"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
이 언어팩의 모든 주요 변경사항을 기록합니다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 비즈뿌리오 메시지 발송 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩 초기 제공
|
||||
- 관리자 환경설정 문구(비즈뿌리오 아이디·비밀번호·발신번호·알림톡 발신프로필키·결과 수신 주소, 검수 모드와 운영 전환 경고)의 일본어 번역
|
||||
- 알림 편집 창에 통합된 알림톡 템플릿·문자(SMS) 섹션의 제목·안내 문구와 승인 여부 배지(승인됨 / 미승인)의 일본어 번역
|
||||
- 검수자 전달 의견 입력란의 라벨·안내 문구의 일본어 번역
|
||||
- 알림톡 템플릿 작성·검수 신청·승인 관리 화면 문구의 일본어 번역
|
||||
- 템플릿 통합 관리 화면과 발송 이력 화면 문구의 일본어 번역
|
||||
- 발송 결과 안내 문구의 일본어 번역
|
||||
- 템플릿 작성 시 입력값 오류 안내의 일본어 번역 — 항목 이름까지 일본어로 표시됩니다.
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'action' => [],
|
||||
'description' => [],
|
||||
];
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'channel' => [
|
||||
'sms' => 'SMS',
|
||||
'lms' => 'LMS',
|
||||
'alimtalk' => 'アラートトーク',
|
||||
],
|
||||
'channel_group' => [
|
||||
'text' => '文字',
|
||||
'alimtalk' => '通知トーク',
|
||||
],
|
||||
'status' => [
|
||||
'pending' => '待機中',
|
||||
'sent' => '送信中',
|
||||
'success' => '成功',
|
||||
'failed' => '失敗',
|
||||
],
|
||||
'source' => [
|
||||
'auto' => '自動',
|
||||
'manual' => '手動',
|
||||
'bulk' => '一括',
|
||||
],
|
||||
'result_category' => [
|
||||
'success' => '成功',
|
||||
'retry' => '再試行',
|
||||
'permanent_failure' => '永久失敗',
|
||||
'balance_low' => '残高不足',
|
||||
],
|
||||
'channels' => [
|
||||
'source_label' => 'ビズプリオ',
|
||||
'sms' => [
|
||||
'name' => 'ビズプリオ 文字',
|
||||
'description' => 'ビズプリオを通じてテキスト(SMS/LMS)で通知を送信します。',
|
||||
],
|
||||
'alimtalk' => [
|
||||
'name' => 'ビズプリオ アラートトーク',
|
||||
'description' => 'ビズプリオを通じてカカオアラートトークで通知を送信します。',
|
||||
],
|
||||
],
|
||||
'readiness' => [
|
||||
'sms_credentials_missing' => 'ビズプリオのアイディとパスワードを設定してください。',
|
||||
'sms_sender_number_missing' => '発信番号を設定してください。',
|
||||
'alimtalk_api_key_missing' => 'カカオ管理API キーを設定してください。',
|
||||
'alimtalk_sender_key_missing' => 'アラートトーク発信プロフィールキーを設定してください。',
|
||||
],
|
||||
'settings' => [
|
||||
'bizppurio_id_attribute' => 'ビズプリオアイディ',
|
||||
'password_attribute' => 'パスワード',
|
||||
'sender_number_attribute' => '発信番号',
|
||||
],
|
||||
'webhook' => [
|
||||
'received' => 'レポートを受け取りました。',
|
||||
],
|
||||
'error' => [
|
||||
'credentials_missing' => 'ビズプリオのアイディとパスワードを先に設定してください。',
|
||||
'token_issue_failed' => 'ビズプリオ認証トークン発行に失敗しました。',
|
||||
'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)',
|
||||
'send_failed' => 'メッセージ送信リクエストに失敗しました。',
|
||||
'send_retryable' => 'メッセージ送信が一時的に失敗しました。(コード: :code)',
|
||||
'invalid_response' => 'ビズプリオレスポンスを解析できません。',
|
||||
'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。',
|
||||
'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。',
|
||||
'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。',
|
||||
'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。',
|
||||
'image_unreadable' => 'アップロードした画像ファイルを読み込めません。',
|
||||
],
|
||||
'send_skipped' => [
|
||||
'alimtalk_template_not_approved' => '承認されたアラートトークテンプレートがないか、アラートトーク送信がオフになっているため送信をスキップしました。(通知タイプ: :type)',
|
||||
'sms_template_missing' => 'SMS本文が設定されていないため送信をスキップしました。(通知タイプ: :type)',
|
||||
'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)',
|
||||
'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)',
|
||||
],
|
||||
'template' => [
|
||||
'created' => '通知テンプレートを保存しました。',
|
||||
'updated' => '通知テンプレートを更新しました。',
|
||||
'requested' => '審査を申請しました。承認結果は自動的に同期され、[更新]ですぐに確認できます。',
|
||||
'request_cancelled' => '審査申請を取り消しました。',
|
||||
'approval_cancelled' => '承認を取り消しました。この通知のアラートトーク送信が停止されました。',
|
||||
'released' => '休眠状態を解除しました。',
|
||||
'synced' => 'カカオ審査ステータスを同期しました。',
|
||||
'deleted' => '通知テンプレートを削除しました。',
|
||||
'image_uploaded' => '画像をアップロードしました。',
|
||||
'content_locked' => '現在のステータス(:status)ではアラートトークの内容を修正できません。審査中の場合は申請の取り消しを、承認済みの場合は承認の取り消しを先に行ってください。',
|
||||
'content_missing' => 'アラートトークテンプレートの内容を先に作成してください。',
|
||||
'request_not_allowed' => '現在のステータス(:status)では審査を申請できません。',
|
||||
'cancel_request_not_allowed' => '審査中ステータスではないため、申請を取り消せません。(現在: :status)',
|
||||
'cancel_approval_not_allowed' => '承認ステータスではないため、承認を取り消せません。(現在: :status)',
|
||||
'release_not_allowed' => '休眠ステータスではないため、解除できません。(現在: :status)',
|
||||
'code_generation_failed' => 'テンプレートコードを採番できませんでした。しばらくしてからもう一度お試しください。',
|
||||
],
|
||||
'validation' => [
|
||||
'link_mo_required' => 'ウェブリンク(WL)ボタンにはモバイルリンクが必要です。',
|
||||
'link_and_required' => 'アプリリンク(AL)ボタンにはAndroidスキームが必要です。',
|
||||
'link_ios_required' => 'アプリリンク(AL)ボタンにはiOSスキームが必要です。',
|
||||
'tel_number_required' => '電話(TN)ボタンには電話番号が必要です。',
|
||||
'plugin_id_required' => 'プラグイン(P1~P3)ボタンにはプラグインIDが必要です。',
|
||||
'highlight_title_too_long' => 'アイテムハイライトのタイトルは:max文字以内である必要があります。',
|
||||
'highlight_description_too_long' => 'アイテムハイライトの説明は:max文字以内である必要があります。',
|
||||
'image_ratio_invalid' => '画像は横:縦 2:1 の比率である必要があります。(例: 1000×500px)',
|
||||
|
||||
// 検証エラー文に使うフィールドラベル (FormRequest::attributes)
|
||||
'attributes' => [
|
||||
'notification_type' => '通知タイプ',
|
||||
'content' => 'アラートトーク内容',
|
||||
'template_name' => 'テンプレート名',
|
||||
'message_type' => 'メッセージタイプ',
|
||||
'emphasize_type' => '強調タイプ',
|
||||
'template_content' => '本文',
|
||||
'comment' => '審査担当者への伝達事項',
|
||||
'preview_message' => 'プレビュー文言',
|
||||
'category_code' => 'カテゴリ',
|
||||
'security_flag' => 'セキュリティテンプレート',
|
||||
'extra' => '付加情報',
|
||||
'title' => '強調タイトル',
|
||||
'subtitle' => '強調サブタイトル',
|
||||
'header' => 'ヘッダー',
|
||||
'image_name' => '画像ファイル名',
|
||||
'image_url' => '画像URL',
|
||||
'item' => 'アイテムリスト',
|
||||
'item_list' => 'アイテム一覧',
|
||||
'item_title' => 'アイテム名',
|
||||
'item_description' => 'アイテム説明',
|
||||
'summary' => '要約',
|
||||
'summary_title' => '要約タイトル',
|
||||
'summary_description' => '要約説明',
|
||||
'highlight' => 'ハイライト',
|
||||
'highlight_title' => 'ハイライトタイトル',
|
||||
'highlight_description' => 'ハイライト説明',
|
||||
'highlight_image_url' => 'ハイライト画像URL',
|
||||
'represent_link' => '代表リンク',
|
||||
'buttons' => 'ボタン',
|
||||
'button_name' => 'ボタン名',
|
||||
'button_link_type' => 'ボタンリンクタイプ',
|
||||
'quick_replies' => 'クイックリプライ',
|
||||
'quick_reply_name' => 'クイックリプライ名',
|
||||
'quick_reply_link_type' => 'クイックリプライリンクタイプ',
|
||||
'link_mo' => 'モバイルリンク',
|
||||
'link_pc' => 'PCリンク',
|
||||
'link_and' => 'Androidスキーム',
|
||||
'link_ios' => 'iOSスキーム',
|
||||
'tel_number' => '電話番号',
|
||||
'plugin_id' => 'プラグインID',
|
||||
'image' => '画像ファイル',
|
||||
'sms_body' => 'SMS本文',
|
||||
'alimtalk_enabled' => 'アラートトーク利用',
|
||||
'fallback_sms_enabled' => '代替SMS利用',
|
||||
'sms_only' => 'SMS単独送信',
|
||||
'is_active' => '有効',
|
||||
'status' => 'ステータス',
|
||||
'search' => '検索キーワード',
|
||||
'page' => 'ページ',
|
||||
'per_page' => '1ページあたりの件数',
|
||||
],
|
||||
],
|
||||
'token_check' => [
|
||||
'success' => '認証が正常に確認されました。ユーザーIDとパスワードが正しいです。',
|
||||
'failed' => '認証の確認に失敗しました。詳細な理由をご確認ください。',
|
||||
],
|
||||
];
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'1000' => '成功',
|
||||
'2000' => 'メッセージが無効です',
|
||||
'3001' => '認証情報が無効です(Basic)',
|
||||
'3002' => 'トークンが無効です(期限切れ·廃止)',
|
||||
'3003' => 'IPが無効です',
|
||||
'3004' => 'アカウントが無効です',
|
||||
'3005' => '認証情報が無効です(Bearer)',
|
||||
'3006' => 'アカウントが存在しません',
|
||||
'3007' => 'アカウントパスワードが無効です',
|
||||
'3009' => 'アカウントが停止状態です',
|
||||
'3010' => 'アクセス許可IPが一致しません',
|
||||
'3011' => '不明なエラー(bizppurio)',
|
||||
'3013' => '完了処理されていないメッセージ',
|
||||
'4100' => '成功',
|
||||
'4400' => '電波の弱い地域',
|
||||
'4401' => '電源オフ',
|
||||
'4402' => 'ストレージ超過',
|
||||
'4410' => '不正な番号',
|
||||
'4414' => '結番·停止',
|
||||
'4420' => 'その他端末エラー',
|
||||
'4430' => 'スパム',
|
||||
'4431' => '送信制限受信拒否(スパム)',
|
||||
'4443' => 'スパムブロック',
|
||||
'5002' => 'リクエストが多すぎます',
|
||||
'5003' => '一時的な送信エラー',
|
||||
'5004' => '一時的な送信エラー',
|
||||
'5005' => '一時的な送信エラー',
|
||||
'6600' => '成功',
|
||||
'6603' => '電波の弱い地域',
|
||||
'6604' => '電源オフ',
|
||||
'6606' => '不正な番号',
|
||||
'6621' => 'メッセージ長超過',
|
||||
'6641' => 'スパムブロック',
|
||||
'7000' => '成功',
|
||||
'7103' => '発信プロフィールキーが無効です',
|
||||
'7106' => '削除された発信キー',
|
||||
'7107' => 'ブロックされた発信キー',
|
||||
'7204' => 'メッセージ内容がテンプレートと不一致です',
|
||||
'7206' => 'シリアルナンバー形式が不一致です',
|
||||
'7306' => 'カカオシステムエラー',
|
||||
'7307' => '処理遅延',
|
||||
'7308' => '電話番号エラー',
|
||||
'7320' => '受信ブロック',
|
||||
'7325' => '変数長超過',
|
||||
'7421' => 'タイムアウト',
|
||||
'7436' => 'ウォレット残高不足(アラートトーク)',
|
||||
'7437' => 'メッセージリクエスト失敗',
|
||||
'7523' => '080受信拒否(スパム)',
|
||||
'9000' => '一時的なシステムエラー',
|
||||
'9070' => '残高不足(SMS)',
|
||||
'9071' => '後払い限度額超過',
|
||||
];
|
||||
@@ -0,0 +1,304 @@
|
||||
{
|
||||
"name": "ビズプリオ メッセージ発送",
|
||||
"description": "ビズプリオ 連動 SMS/LMS・カカオ アラートトーク 発送プラグインです。",
|
||||
"settings": {
|
||||
"title": "ビズプリオ メッセージ発送 設定",
|
||||
"description": "ビズプリオ 連動情報と発送 設定を管理します。",
|
||||
"save": "保存",
|
||||
"saving": "保存 中...",
|
||||
"save_success": "設定が保存されました。",
|
||||
"save_failed": "設定 保存に失敗しました。",
|
||||
"test_mode": {
|
||||
"label": "検査モード",
|
||||
"hint": "検査モードでは ビズプリオ 検査ドメインで発送されます。オフにすると運用ドメインで実際に発送されます。",
|
||||
"account_notice": "検査と運用は別の ビズプリオ アカウントで運営することをお勧めします。"
|
||||
},
|
||||
"live_mode_warning_title": "運用環境で発送されます",
|
||||
"live_mode_warning_body": "検査モードがオフになっているため、実際の顧客にSMS・アラートトークが発送され、発送費用が請求されます。検査段階では検査モードをオンにしてください。",
|
||||
"sections": {
|
||||
"api": {
|
||||
"title": "API 連動",
|
||||
"description": "発送システムとカカオ管理システムに共通に使用する連動情報です。"
|
||||
},
|
||||
"sending": {
|
||||
"title": "発送 設定",
|
||||
"description": "文字・アラートトーク発送に使用する発信情報です。"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"bizppurio_id": {
|
||||
"label": "ビズプリオ ID",
|
||||
"hint": "発送・カカオ管理に共通に使用する ビズプリオ ID です。"
|
||||
},
|
||||
"password": {
|
||||
"label": "ビズプリオモジュールパスワード",
|
||||
"hint": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)"
|
||||
},
|
||||
"connection_check": {
|
||||
"label": "接続確認",
|
||||
"hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
|
||||
"button": "接続確認",
|
||||
"checking": "確認中...",
|
||||
"success": "認証が正常に確認されました。IDとパスワードが正しいです。",
|
||||
"failed": "認証確認に失敗しました。",
|
||||
"unsaved_changes": "変更内容を先に保存してください。"
|
||||
},
|
||||
"api_key": {
|
||||
"label": "API キー",
|
||||
"hint": "API キーは ビズプリオ カスタマーセンターに ID とともに申し込むと確認後、発行されます。"
|
||||
},
|
||||
"sender_number": {
|
||||
"label": "発信番号",
|
||||
"hint": "文字・アラートトーク発送に使用する発信電話番号です。"
|
||||
},
|
||||
"sender_key": {
|
||||
"label": "アラートトーク 発信プロフィール キー",
|
||||
"hint": "アラートトーク 発送・テンプレート確認に使用する発信プロフィール キー(40文字)です。"
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"section_title": "レポート受信 設定",
|
||||
"hint": "ビズプリオ は文字・アラートトーク発送後、成功/失敗の結果をこのアドレスに転送(URL PUSH)するレポート受信方式を提供します。以下のアドレスを ビズプリオ ビジネスチーム(または管理コンソールのレポート受信 設定)に登録してください。",
|
||||
"note": "登録すると発送結果が履歴に自動記録され、実際に到達したかどうかを確認できます。登録前は発送リクエストまで確認され、最終結果(成功/失敗)は更新されません。",
|
||||
"copy": "コピー",
|
||||
"copied": "レポート受信アドレスがクリップボードにコピーされました。"
|
||||
},
|
||||
"tabs": {
|
||||
"connection": "環境設定",
|
||||
"templates": "通知テンプレート管理"
|
||||
},
|
||||
"preparation": {
|
||||
"intro": "文字・カカオ アラートトークを発送するには、まず ビズプリオ コンソールで以下の事前準備を完了する必要があります。",
|
||||
"sms_label": "文字(SMS/LMS)",
|
||||
"sms_sender": "発信番号を登録してください。",
|
||||
"kakao_label": "カカオ アラートトーク",
|
||||
"kakao_channel": "カカオトーク ビジネスチャネルを作成し、発信プロフィールを登録してください。",
|
||||
"kakao_template": "アラートトークテンプレートは管理者の通知設定(ビズプリオタブ)で作成して審査を申請します。",
|
||||
"kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。",
|
||||
"console_link": "ビズプリオ コンソールを開く"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"bizppurio_tab": "ビズプリオ"
|
||||
},
|
||||
"binding": {
|
||||
"variables_hint": "提供変数(クリックすると本文のカーソル位置に挿入されます)"
|
||||
},
|
||||
"banner": {
|
||||
"not_ready": "発送に必要な 設定が完了していません。",
|
||||
"setup_action": "設定する",
|
||||
"test_mode": "検査モードです — 実際の発送は行われません。"
|
||||
},
|
||||
"dispatch_result": {
|
||||
"column_header": "文字・アラートトーク 結果",
|
||||
"inspection_label": "検査",
|
||||
"low_balance": "残高不足",
|
||||
"fallback": "SMS 代替発送 {status}",
|
||||
"detail_title": "文字・アラートトーク 発送 結果",
|
||||
"channel_label": "発送チャネル: {channel}",
|
||||
"sent_content_label": "実際の発送内容",
|
||||
"sent_content_hint": "アラートトーク は承認確定されたテンプレート内容で発送され、上記の\"本文\"と異なる場合があります。"
|
||||
},
|
||||
"editor": {
|
||||
"data_source": {
|
||||
"dispatch_results": "ビズプリオ送信結果"
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"tab_guide": "各通知の[編集]でアラートトークのテンプレートとSMS本文をまとめて作成し、審査を申請してください。カカオの承認後にアラートトークが自動送信され、代替SMS・SMS単独送信も同じ画面で設定します。",
|
||||
"status": {
|
||||
"unwritten": "未作成",
|
||||
"draft": "作成中",
|
||||
"requested": "審査中",
|
||||
"approved": "承認済み",
|
||||
"rejected": "却下",
|
||||
"stopped": "停止",
|
||||
"blocked": "ブロック",
|
||||
"dormant": "休眠"
|
||||
},
|
||||
"row": {
|
||||
"alimtalk_label": "アラートトーク",
|
||||
"requested_on": "{date} 申請",
|
||||
"synced_on": "{date} 同期",
|
||||
"alimtalk_enabled": "送信を使用",
|
||||
"btn_view_reason": "理由を表示",
|
||||
"btn_compose": "アラートトークテンプレート作成",
|
||||
"btn_edit": "編集",
|
||||
"btn_edit_approved": "編集 (承認取り消し)",
|
||||
"btn_request": "審査申請",
|
||||
"btn_cancel_request": "申請取り消し",
|
||||
"btn_release": "休眠解除",
|
||||
"btn_refresh": "更新",
|
||||
"requested_toast": "審査を申請しました。",
|
||||
"cancel_request_toast": "審査申請を取り消しました。",
|
||||
"released_toast": "休眠状態を解除しました。",
|
||||
"synced_toast": "カカオ審査ステータスを同期しました。",
|
||||
"fallback_sms": "代替SMS",
|
||||
"sms_only": "SMS単独",
|
||||
"sms_body_prefix": "本文:",
|
||||
"sms_body_missing": "SMS本文未設定",
|
||||
"btn_edit_sms": "SMS本文を編集",
|
||||
"sms_label": "SMS",
|
||||
"on": "使用",
|
||||
"off": "未使用",
|
||||
"alimtalk_disabled": "送信停止中"
|
||||
},
|
||||
"form": {
|
||||
"modal_title": "アラートトークテンプレート — {name}",
|
||||
"rejected_banner": "却下されたテンプレートです。以下の理由を確認し、修正後に再度申請してください。",
|
||||
"sender_profile": "発信プロフィール",
|
||||
"sender_profile_placeholder": "発信プロフィール選択(参考用)",
|
||||
"sender_profile_hint": "審査申請は環境設定に保存された発信プロフィールキーで行われます。",
|
||||
"category": "カテゴリ",
|
||||
"category_placeholder": "カテゴリ選択",
|
||||
"name": "テンプレート名",
|
||||
"message_type": "メッセージ種別",
|
||||
"templateMessageType_options": {
|
||||
"BA": "基本型",
|
||||
"EX": "付加情報型",
|
||||
"AD": "チャネル追加型",
|
||||
"MI": "複合型"
|
||||
},
|
||||
"emphasize_type": "強調種別",
|
||||
"templateEmphasizeType_options": {
|
||||
"NONE": "なし",
|
||||
"TEXT": "強調表記",
|
||||
"IMAGE": "画像",
|
||||
"ITEM_LIST": "アイテムリスト"
|
||||
},
|
||||
"emphasize_title": "強調表記タイトル",
|
||||
"emphasize_subtitle": "強調表記サブタイトル",
|
||||
"image": "画像",
|
||||
"image_hint": "jpg/png · 500KB以下 · 横500px以上 · 横:縦 2:1 (アップロードするとURLが自動入力されます)",
|
||||
"image_uploading": "画像をアップロード中...",
|
||||
"image_upload_failed": "画像のアップロードに失敗しました。",
|
||||
"content": "本文",
|
||||
"extra": "付加情報",
|
||||
"header": "ヘッダー",
|
||||
"header_hint": "本文上部に表示される最大16文字の文言(任意)",
|
||||
"buttons": "ボタン (最大5個)",
|
||||
"button_add": "ボタン追加",
|
||||
"button_name": "ボタン名 (最大14文字)",
|
||||
"quick_replies": "クイックリンク (最大10個)",
|
||||
"quick_reply_add": "クイックリンク追加",
|
||||
"link_mo": "モバイルリンク (必須)",
|
||||
"link_pc": "PCリンク (任意)",
|
||||
"link_and": "Androidスキーム (必須)",
|
||||
"link_ios": "iOSスキーム (必須)",
|
||||
"tel_number": "電話番号 (必須)",
|
||||
"plugin_id": "プラグインID (必須)",
|
||||
"item_highlight": "アイテムハイライト (任意)",
|
||||
"hl_title": "ハイライトタイトル",
|
||||
"hl_description": "ハイライト説明",
|
||||
"hl_image_url": "サムネイル画像URL (任意)",
|
||||
"item_list": "アイテム一覧 (2~10個)",
|
||||
"item_list_hint": "アイテムは最小2個、最大10個です。(タイトル6文字・説明23文字以内)",
|
||||
"item_add": "アイテム追加",
|
||||
"item_title": "タイトル",
|
||||
"item_description": "説明",
|
||||
"item_summary": "要約(価格情報)",
|
||||
"summary_description": "要約値 (14文字以内)",
|
||||
"btn_remove_item": "アイテム削除",
|
||||
"btn_remove_button": "ボタン削除",
|
||||
"btn_remove_quick_reply": "クイックリプライ削除",
|
||||
"btn_save": "保存",
|
||||
"btn_save_request": "保存して審査申請",
|
||||
"request_comment": "審査担当者への伝達事項",
|
||||
"request_comment_placeholder": "例: #{name}=山田太郎, #{order_number}=20260823-0001",
|
||||
"request_comment_hint": "審査申請時にカカオの審査担当者へ一緒に送られます(任意・500文字以内)。カカオの審査ガイドは本文の変数ごとに例示値を求めるため、変数ごとの例を書いておくと差し戻しを減らせます。保存はされず、申請時にのみ送信されます。",
|
||||
"saved_toast": "通知テンプレートを保存しました。",
|
||||
"requested_toast": "審査を申請しました。承認されるとアラートトーク送信が開始されます。"
|
||||
},
|
||||
"sms": {
|
||||
"modal_title": "SMS本文 — {name}",
|
||||
"hint": "代替SMSとSMS単独送信に共通で使用する本文です。#{変数} は送信時に自動置換されます。",
|
||||
"btn_save": "保存",
|
||||
"saved_toast": "SMS本文を保存しました。"
|
||||
},
|
||||
"cancel_approval": {
|
||||
"modal_title": "承認取り消しの確認",
|
||||
"warning": "「{name}」テンプレートの承認を取り消すと、直ちにこの通知のアラートトーク送信が停止されます。(代替SMS・SMS単独送信はSMS設定に従って継続されます。)修正後に再度審査を申請して承認を受けると、アラートトーク送信が再開されます。",
|
||||
"btn_confirm": "承認を取り消して編集",
|
||||
"done_toast": "承認を取り消しました。修正後に再度審査を申請してください。"
|
||||
},
|
||||
"rejection": {
|
||||
"modal_title": "却下理由 — {name}",
|
||||
"btn_close": "閉じる"
|
||||
},
|
||||
"approval": {
|
||||
"approved": "承認済み",
|
||||
"not_approved": "未承認"
|
||||
},
|
||||
"section": {
|
||||
"alimtalk_title": "アラートトークテンプレート",
|
||||
"sms_title": "SMS",
|
||||
"alimtalk_enabled": "アラートトーク送信を使用",
|
||||
"locked_hint": "審査中または承認済みのテンプレートは内容を変更できません。変更するには先に[申請取消]または[編集(承認取消)]を押してください。",
|
||||
"saved_toast": "通知テンプレートを保存しました。",
|
||||
"loading": "テンプレートを読み込み中…"
|
||||
}
|
||||
},
|
||||
"manage": {
|
||||
"guide": "すべての通知のビズプリオテンプレート状態を一目で管理します。作成・審査申請は通知設定画面と同じように動作します。",
|
||||
"filter_all": "すべてのステータス",
|
||||
"search_placeholder": "通知名/タイプ検索",
|
||||
"btn_search": "検索",
|
||||
"total_count": "合計 {count}件",
|
||||
"btn_compose": "作成",
|
||||
"btn_edit": "編集",
|
||||
"btn_delete": "削除",
|
||||
"empty": "登録されたビズプリオ通知テンプレートがありません。",
|
||||
"empty_hint": "管理者の通知設定のビズプリオタブでアラートトークテンプレートを作成するか、SMSを設定するとここに表示されます。",
|
||||
"sms_only": "単独",
|
||||
"sms_fallback": "代替",
|
||||
"owner": {
|
||||
"core": "コア",
|
||||
"module": "モジュール",
|
||||
"plugin": "プラグイン"
|
||||
},
|
||||
"columns": {
|
||||
"notification": "通知",
|
||||
"owner": "所属",
|
||||
"status": "アラートトークステータス",
|
||||
"sms": "SMS",
|
||||
"requested_at": "申請日",
|
||||
"synced_at": "同期",
|
||||
"actions": "管理"
|
||||
},
|
||||
"delete": {
|
||||
"modal_title": "テンプレート削除",
|
||||
"warning": "「{name}」のビズプリオテンプレート設定を削除します。カカオに登録されたテンプレートは削除可能なステータス(登録/却下)の場合のみ一緒に削除されます。",
|
||||
"btn_confirm": "削除",
|
||||
"done_with_kakao": "テンプレートを削除しました。(カカオ側テンプレートを含む)",
|
||||
"done_db_only": "テンプレート設定を削除しました。カカオ側テンプレートはステータス制約により残っています。"
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"readiness": {
|
||||
"title": "アラートトークテンプレートを使用するには、以下の設定が必要です",
|
||||
"go_settings": "環境設定に移動",
|
||||
"missing_label": "未設定項目",
|
||||
"api_key_missing": "カカオ管理API キー",
|
||||
"sender_key_missing": "アラートトーク送信プロフィールキー",
|
||||
"note": "上記項目を環境設定タブで入力すると、テンプレートの作成・審査申請が可能になります。実際の送信は検査モードをオフにして運用に切り替えた後、承認されたテンプレートのみ使用できます。"
|
||||
},
|
||||
"list": {
|
||||
"refresh": "更新"
|
||||
},
|
||||
"link_type": {
|
||||
"WL": "ウェブリンク",
|
||||
"AL": "アプリリンク",
|
||||
"DS": "配送追跡",
|
||||
"BK": "ボットキーワード",
|
||||
"MD": "メッセージ転送",
|
||||
"AC": "チャネル追加",
|
||||
"BC": "相談トーク転換",
|
||||
"BT": "ボット転換",
|
||||
"TN": "電話をかける",
|
||||
"MP": "地図表示",
|
||||
"P1": "画像セキュア送信",
|
||||
"P2": "個人情報利用",
|
||||
"P3": "ワンクリック決済"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"identifier": "g7-plugin-sirsoft-message_bizppurio-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-message_bizppurio",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Bizppurio メッセージ発送",
|
||||
"description": "Bizppurio 連動 SMS/LMS・カカオ アラート トーク発送プラグインです。コア通知システムチャネルとして文字・アラートトークを発送し、発送結果を webhook で受信します。"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"bizppurio_balance_low": {
|
||||
"definition": {
|
||||
"name": "Bizssprrio残高不足",
|
||||
"description": "Bizssprrio ウォレット残高が不足しており、SMS/アラートトーク送信に失敗した場合、管理者に送信されます"
|
||||
},
|
||||
"templates": {
|
||||
"mail": {
|
||||
"subject": "[{app_name}] Bizssprrio 残高が不足しています",
|
||||
"body": "{name}様、Bizssprrio ウォレット残高が不足しているため、{channel_label} 送信に失敗しました (コード: {result_code})。チャージ後、送信が正常化されます。"
|
||||
},
|
||||
"database": {
|
||||
"subject": "Bizssprrio残高不足",
|
||||
"body": "Bizssprrio残高不足により、{channel_label} 送信に失敗しました (コード: {result_code})。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"sirsoft-message_bizppurio": {
|
||||
"name": "ビズプリオ メッセージ発送",
|
||||
"description": "ビズプリオ メッセージ発送プラグインが提供する権限"
|
||||
},
|
||||
"sirsoft-message_bizppurio.messaging": {
|
||||
"name": "メッセージ発送",
|
||||
"description": "メッセージ発送ドメイン権限 (閲覧·管理)"
|
||||
},
|
||||
"sirsoft-message_bizppurio.messaging.view": {
|
||||
"name": "メッセージ閲覧",
|
||||
"description": "発送履歴·アラートトークテンプレート閲覧 (モニタリング)"
|
||||
},
|
||||
"sirsoft-message_bizppurio.messaging.manage": {
|
||||
"name": "メッセージ管理",
|
||||
"description": "環境設定·アラートトークテンプレート登録/検証·イベント連動管理"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,13 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.7] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 환경설정 SEO 탭의 기본 공유 이미지(og:image) 라벨·설명 번역을 추가했습니다.
|
||||
- 알림 설정 화면의 「사이트맵 재생성 완료」・「사이트맵 재생성 실패」 알림 이름 번역을 추가했습니다.
|
||||
|
||||
## [1.0.6] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1423,6 +1423,8 @@
|
||||
"og_image_default_width": "OG 画像 デフォルト横幅 (px)",
|
||||
"og_image_default_height": "OG 画像 デフォルト高さ (px)",
|
||||
"og_image_default_size_hint": "サムネイルの実際のサイズが不明な場合のデフォルト値です。Facebookは推奨 1200×630、最小 200×200。",
|
||||
"og_image_default": "既定の共有画像",
|
||||
"og_image_default_desc": "画面ごとの共有画像がない場合に使用するサイト既定の画像です。リンク共有時のプレビューカードに表示されます。",
|
||||
"twitter_default_card": "Twitterカード デフォルト タイプ",
|
||||
"twitter_default_card_hint": "大きい画像のカードは summary_large_image、小さいカードは summary を推奨します。",
|
||||
"twitter_default_site": "Twitter サイト ハンドル",
|
||||
@@ -1860,7 +1862,9 @@
|
||||
"types": {
|
||||
"welcome": "会員登録ウェルカム",
|
||||
"reset_password": "パスワード再設定",
|
||||
"password_changed": "パスワード変更通知"
|
||||
"password_changed": "パスワード変更通知",
|
||||
"sitemap_regenerated": "サイトマップ再生成完了",
|
||||
"sitemap_regenerate_failed": "サイトマップ再生成失敗"
|
||||
},
|
||||
"channels_label": "チャネル:",
|
||||
"edit_modal": {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
|
||||
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-admin_basic",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.2] - 2026-08-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- 첫 화면 소개 문구의 제품명이 한국어로 표시되던 문제를 수정했습니다.
|
||||
|
||||
## [1.1.1] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"welcome": {
|
||||
"title": "いらっしゃいませ!",
|
||||
"description": "그누보드7 コミュニティへようこそ。",
|
||||
"description": "グヌボード7 コミュニティへようこそ。",
|
||||
"login_prompt": "ログインしてコミュニティに参加してください。",
|
||||
"greeting": "こんにちは、{{name}}さん!"
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
|
||||
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-basic",
|
||||
|
||||
@@ -1272,6 +1272,7 @@ return [
|
||||
'maintenance_mode' => 'maintenance mode',
|
||||
'asset_url_mode' => 'asset URL mode',
|
||||
'site_logo' => 'site logo',
|
||||
'og_image_default' => 'default share image',
|
||||
// SEO settings (additional)
|
||||
'bot_user_agents' => 'bot user agents',
|
||||
'bot_detection_enabled' => 'bot detection',
|
||||
|
||||
@@ -1265,6 +1265,7 @@ return [
|
||||
'maintenance_mode' => '점검 모드',
|
||||
'asset_url_mode' => '자산 주소 방식',
|
||||
'site_logo' => '사이트 로고',
|
||||
'og_image_default' => '기본 공유 이미지',
|
||||
// SEO 설정 (추가)
|
||||
'bot_user_agents' => '봇 User-Agent 목록',
|
||||
'bot_detection_enabled' => '봇 감지 사용',
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
|
||||
- 게시판 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
|
||||
|
||||
## [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",
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
// e2e:allow 레이아웃 조건식 실평가 전용 — 브라우저 흐름은 비즈뿌리오 플러그인 spec 이 담당한다.
|
||||
/**
|
||||
* 게시판 알림 설정 — 채널 서브탭 필터 조합 실평가 (#597 §14.2 T8)
|
||||
*
|
||||
* 이 면의 서브탭 필터는 admin_basic·이커머스와 같은 식을 공유한다. 그 동일성은
|
||||
* admin_basic 쪽 패리티 테스트가 고정하지만, 그 테스트는 **admin_basic 템플릿의
|
||||
* 러너에서만** 수집된다 — 게시판 모듈만 단독으로 Vitest 를 돌리면 이 면의 필터는
|
||||
* 한 번도 평가되지 않는다. 그래서 각 면이 자기 파일을 자기 러너에서 평가한다.
|
||||
*
|
||||
* 검증 대상: 확장이 선언한 통합 탭 메타(hidden_tab / tab_channels)를 필터가 실제로
|
||||
* 해석하는가. 문자열 동일성 단언은 `!==` 를 `===` 로 바꾸는 오타를 잡지 못한다.
|
||||
*
|
||||
* @scenario resource=notification_definitions_tab,endpoint=admin_board_settings,observation=tab_filter
|
||||
*
|
||||
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const TAB_LAYOUT = path.resolve(
|
||||
__dirname,
|
||||
'../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json',
|
||||
);
|
||||
|
||||
/** availableChannels 응답 형태 (코어 2 + 비즈뿌리오 2) */
|
||||
const CHANNELS = [
|
||||
{ id: 'mail', source: 'core', name: '메일' },
|
||||
{ id: 'database', source: 'core', name: '사이트내 알림' },
|
||||
{ id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true },
|
||||
{
|
||||
id: 'alimtalk',
|
||||
source: 'sirsoft-message_bizppurio',
|
||||
name: '비즈뿌리오 알림톡',
|
||||
tab_channels: ['sms', 'alimtalk'],
|
||||
tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다.
|
||||
*
|
||||
* @param node 탐색할 JSON 노드
|
||||
* @returns 서브탭 컨테이너 노드 (없으면 null)
|
||||
*/
|
||||
function findSubTabsContainer(node: unknown): Record<string, any> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findSubTabsContainer(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
|
||||
const record = node as Record<string, any>;
|
||||
if (typeof record.id === 'string' && record.id.endsWith('channel_sub_tabs')) return record;
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const found = findSubTabsContainer(value);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서브탭 컨테이너 안에서 iteration 을 가진 탭 버튼 노드를 찾습니다.
|
||||
*
|
||||
* @param node 탐색할 JSON 노드
|
||||
* @returns iteration 보유 노드 (없으면 null)
|
||||
*/
|
||||
function findIterationNode(node: unknown): Record<string, any> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findIterationNode(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
|
||||
const record = node as Record<string, any>;
|
||||
if (record.iteration && typeof record.iteration.source === 'string') return record;
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const found = findIterationNode(value);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('게시판 알림 설정 — 채널 서브탭 필터 실평가 (#597)', () => {
|
||||
const layout = JSON.parse(fs.readFileSync(TAB_LAYOUT, 'utf-8'));
|
||||
const container = findSubTabsContainer(layout);
|
||||
const tabNode = container ? findIterationNode(container) : null;
|
||||
const expr = String(tabNode?.iteration?.source ?? '').replace(/^\{\{|\}\}$/g, '');
|
||||
|
||||
/**
|
||||
* 필터 표현식 원문을 실행해 노출 탭 id 목록을 반환합니다.
|
||||
*
|
||||
* @param saved _local.form.notifications.channels 저장값
|
||||
* @param channels availableChannels 채널 목록
|
||||
* @returns 필터를 통과한 채널 id 배열
|
||||
*/
|
||||
function visibleTabs(
|
||||
saved: Array<{ id: string; is_active: boolean }>,
|
||||
channels: Array<Record<string, any>> = CHANNELS,
|
||||
): string[] {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('availableChannels', '_local', `return ${expr};`);
|
||||
const result = fn(
|
||||
{ data: { channels } },
|
||||
{ form: { notifications: { channels: saved } } },
|
||||
) as Array<{ id: string }>;
|
||||
return result.map((c) => c.id);
|
||||
}
|
||||
|
||||
it('서브탭 필터 표현식을 레이아웃에서 추출할 수 있다', () => {
|
||||
expect(container, '*channel_sub_tabs 컨테이너를 찾지 못했다').toBeTruthy();
|
||||
expect(expr, '탭 iteration source 가 비어 있다').not.toBe('');
|
||||
});
|
||||
|
||||
it('sms·alimtalk 모두 활성 → 통합 탭 1개만 노출, sms 개별 탭은 숨김', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: true },
|
||||
{ id: 'alimtalk', is_active: true },
|
||||
])).toEqual(['mail', 'database', 'alimtalk']);
|
||||
});
|
||||
|
||||
it('sms 만 활성(alimtalk 비활성) → tab_channels 규칙으로 통합 탭 노출', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: true },
|
||||
{ id: 'alimtalk', is_active: false },
|
||||
])).toEqual(['mail', 'database', 'alimtalk']);
|
||||
});
|
||||
|
||||
it('sms·alimtalk 모두 비활성 → 통합 탭 미노출', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: false },
|
||||
{ id: 'alimtalk', is_active: false },
|
||||
])).toEqual(['mail', 'database']);
|
||||
});
|
||||
|
||||
it('확장 채널 저장값 자체가 없으면(미저장=opt-in 전) 통합 탭 미노출', () => {
|
||||
expect(visibleTabs([])).toEqual(['mail', 'database']);
|
||||
});
|
||||
|
||||
it('코어 채널은 저장값이 명시적 false 일 때만 숨는다 (기본 노출)', () => {
|
||||
expect(visibleTabs([{ id: 'mail', is_active: false }])).toEqual(['database']);
|
||||
});
|
||||
|
||||
it('tab_channels 미선언 확장 채널은 자기 자신의 활성 저장 기준으로 판정된다', () => {
|
||||
const withPlain = [...CHANNELS, { id: 'push', source: 'some-plugin', name: '푸시' }];
|
||||
expect(visibleTabs([{ id: 'push', is_active: true }], withPlain)).toEqual(['mail', 'database', 'push']);
|
||||
expect(visibleTabs([{ id: 'push', is_active: false }], withPlain)).toEqual(['mail', 'database']);
|
||||
});
|
||||
});
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio)
|
||||
// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다.
|
||||
/**
|
||||
* 게시판 알림 템플릿 편집 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (코어 admin_basic 모달과 동형, 면 식별자만 다름)
|
||||
*
|
||||
* 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록:
|
||||
* - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞)
|
||||
* - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이)
|
||||
* - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다
|
||||
* (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일.
|
||||
*
|
||||
* 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은
|
||||
* 기대값을 함께 고치면 통과한다.
|
||||
*
|
||||
* @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import modal from '../../../layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json';
|
||||
|
||||
type Node = Record<string, any>;
|
||||
const PREFIX = 'board_';
|
||||
const STATE_KEY = 'board_notification_template_form_modal';
|
||||
const MODAL_ID = 'modal_board_notification_template_edit';
|
||||
const REFETCH_ID = 'boardNotificationDefinitions';
|
||||
|
||||
const findById = (node: unknown, id: string): Node | null => {
|
||||
if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; }
|
||||
if (node && typeof node === 'object') {
|
||||
if ((node as Node).id === id) return node as Node;
|
||||
for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; }
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => {
|
||||
if (Array.isArray(node)) {
|
||||
const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id);
|
||||
if (idx >= 0) return { arr: node, idx };
|
||||
for (const n of node) { const r = findParentArray(n, id); if (r) return r; }
|
||||
return null;
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; }
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const evalIf = (expr: string, scope: Record<string, unknown>): boolean => {
|
||||
const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
|
||||
const names = Object.keys(scope);
|
||||
// eslint-disable-next-line no-new-func
|
||||
return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n])));
|
||||
};
|
||||
const scope = (channel: string, hidden: boolean | undefined, extra: Record<string, unknown> = {}) => ({
|
||||
availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } },
|
||||
_global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } },
|
||||
});
|
||||
|
||||
const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId'];
|
||||
|
||||
describe('게시판 알림 템플릿 편집 모달 — 확장 슬롯 2종', () => {
|
||||
it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => {
|
||||
const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`);
|
||||
expect(loc).toBeTruthy();
|
||||
const { arr, idx } = loc!;
|
||||
expect(arr[idx].type).toBe('extension_point');
|
||||
expect(arr[idx].name).toBe('notification_template_form_sections');
|
||||
expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`);
|
||||
expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`);
|
||||
});
|
||||
|
||||
it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => {
|
||||
const footer = findById(modal, `${PREFIX}template_modal_footer`)!;
|
||||
const right = footer.children[1].children as Node[];
|
||||
expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']);
|
||||
expect(right[1].name).toBe('notification_template_form_footer_actions');
|
||||
expect(JSON.stringify(right[0])).toContain('$t:common.cancel');
|
||||
expect(JSON.stringify(right[2])).toContain('$t:common.save');
|
||||
});
|
||||
|
||||
it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => {
|
||||
const ep = findById(modal, id)!;
|
||||
expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort());
|
||||
expect(ep.props.modalId).toBe(MODAL_ID);
|
||||
expect(ep.props.stateKey).toBe(STATE_KEY);
|
||||
expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID);
|
||||
expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`);
|
||||
expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`);
|
||||
expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`);
|
||||
expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('게시판 알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => {
|
||||
const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info'];
|
||||
|
||||
it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => {
|
||||
const node = findById(modal, `${PREFIX}${id}`)!;
|
||||
expect(node.if, `${id} if`).toBeTruthy();
|
||||
expect(evalIf(node.if, scope('alimtalk', true))).toBe(false);
|
||||
expect(evalIf(node.if, scope('mail', undefined))).toBe(true);
|
||||
expect(evalIf(node.if, scope('sms', false))).toBe(true);
|
||||
});
|
||||
|
||||
it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => {
|
||||
const node = findById(modal, `${PREFIX}template_click_url_input`)!;
|
||||
expect(evalIf(node.if, scope('database', undefined))).toBe(true);
|
||||
expect(evalIf(node.if, scope('mail', undefined))).toBe(false);
|
||||
expect(evalIf(node.if, scope('database', true))).toBe(false);
|
||||
});
|
||||
|
||||
it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => {
|
||||
const node = findById(modal, `${PREFIX}template_variables_info`)!;
|
||||
const noVars = { ...scope('mail', undefined) } as Node;
|
||||
noVars._global[STATE_KEY].definition = { variables: [] };
|
||||
expect(evalIf(node.if, noVars)).toBe(false);
|
||||
});
|
||||
|
||||
it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => {
|
||||
const footer = findById(modal, `${PREFIX}template_modal_footer`)!;
|
||||
const preview = footer.children[0].children[0] as Node;
|
||||
const right = footer.children[1].children as Node[];
|
||||
const cancel = right[0];
|
||||
const save = right[2];
|
||||
for (const b of [preview, save]) {
|
||||
expect(evalIf(b.if, scope('alimtalk', true))).toBe(false);
|
||||
expect(evalIf(b.if, scope('mail', undefined))).toBe(true);
|
||||
}
|
||||
expect(cancel.if).toBeUndefined();
|
||||
});
|
||||
|
||||
it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => {
|
||||
for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) {
|
||||
const node = findById(modal, `${PREFIX}${id}`)!;
|
||||
expect(node, id).toBeTruthy();
|
||||
expect(String(node.if ?? '')).not.toContain('hidden_template_editor');
|
||||
}
|
||||
});
|
||||
});
|
||||
+52
-18
@@ -500,8 +500,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
}
|
||||
"props": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -528,6 +527,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "board_template_form_sections_ext",
|
||||
"comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.",
|
||||
"type": "extension_point",
|
||||
"name": "notification_template_form_sections",
|
||||
"props": {
|
||||
"definition": "{{_global.board_notification_template_form_modal?.definition}}",
|
||||
"template": "{{_global.board_notification_template_form_modal?.template}}",
|
||||
"channel": "{{_global.board_notification_template_form_modal?.template?.channel ?? ''}}",
|
||||
"modalId": "modal_board_notification_template_edit",
|
||||
"stateKey": "board_notification_template_form_modal",
|
||||
"saveEndpoint": "{{'/api/admin/notification-templates/' + _global.board_notification_template_form_modal?.template?.id}}",
|
||||
"refetchDataSourceId": "boardNotificationDefinitions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "board_template_lang_tabs",
|
||||
"type": "basic",
|
||||
@@ -559,7 +573,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
|
||||
},
|
||||
{
|
||||
"id": "board_template_subject_input",
|
||||
@@ -613,7 +628,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
|
||||
},
|
||||
{
|
||||
"id": "board_template_body_input",
|
||||
@@ -668,13 +684,14 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
|
||||
},
|
||||
{
|
||||
"id": "board_template_click_url_input",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.board_notification_template_form_modal?.template?.channel === 'database'}}",
|
||||
"if": "{{(_global.board_notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -724,7 +741,7 @@
|
||||
"id": "board_template_variables_info",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(_global.board_notification_template_form_modal?.definition?.variables ?? []).length > 0}}",
|
||||
"if": "{{((_global.board_notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}",
|
||||
"props": {
|
||||
"className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4"
|
||||
},
|
||||
@@ -841,17 +858,18 @@
|
||||
"target": "modal_board_notification_template_preview"
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -879,6 +897,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "board_template_form_footer_ext",
|
||||
"comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.",
|
||||
"type": "extension_point",
|
||||
"name": "notification_template_form_footer_actions",
|
||||
"props": {
|
||||
"definition": "{{_global.board_notification_template_form_modal?.definition}}",
|
||||
"template": "{{_global.board_notification_template_form_modal?.template}}",
|
||||
"channel": "{{_global.board_notification_template_form_modal?.template?.channel ?? ''}}",
|
||||
"modalId": "modal_board_notification_template_edit",
|
||||
"stateKey": "board_notification_template_form_modal",
|
||||
"saveEndpoint": "{{'/api/admin/notification-templates/' + _global.board_notification_template_form_modal?.template?.id}}",
|
||||
"refetchDataSourceId": "boardNotificationDefinitions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
@@ -978,7 +1011,8 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -140,7 +140,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"iteration": {
|
||||
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}",
|
||||
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.hidden_tab !== true && (c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0))}}",
|
||||
"item_var": "ch",
|
||||
"index_var": "chIdx"
|
||||
},
|
||||
@@ -160,7 +160,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{ch.name ?? ch.id}}"
|
||||
"text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
|
||||
@@ -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,21 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.2.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
|
||||
### Changed
|
||||
|
||||
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
|
||||
- 이커머스 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
|
||||
|
||||
### 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",
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
// e2e:allow 레이아웃 조건식 실평가 전용 — 브라우저 흐름은 비즈뿌리오 플러그인 spec 이 담당한다.
|
||||
/**
|
||||
* 이커머스 알림 설정 — 채널 서브탭 필터 조합 실평가 (#597 §14.2 T8)
|
||||
*
|
||||
* 이 면의 서브탭 필터는 admin_basic·게시판와 같은 식을 공유한다. 그 동일성은
|
||||
* admin_basic 쪽 패리티 테스트가 고정하지만, 그 테스트는 **admin_basic 템플릿의
|
||||
* 러너에서만** 수집된다 — 이커머스 모듈만 단독으로 Vitest 를 돌리면 이 면의 필터는
|
||||
* 한 번도 평가되지 않는다. 그래서 각 면이 자기 파일을 자기 러너에서 평가한다.
|
||||
*
|
||||
* 검증 대상: 확장이 선언한 통합 탭 메타(hidden_tab / tab_channels)를 필터가 실제로
|
||||
* 해석하는가. 문자열 동일성 단언은 `!==` 를 `===` 로 바꾸는 오타를 잡지 못한다.
|
||||
*
|
||||
* @scenario resource=notification_definitions_tab,endpoint=admin_ecommerce_settings,observation=tab_filter
|
||||
*
|
||||
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const TAB_LAYOUT = path.resolve(
|
||||
__dirname,
|
||||
'../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json',
|
||||
);
|
||||
|
||||
/** availableChannels 응답 형태 (코어 2 + 비즈뿌리오 2) */
|
||||
const CHANNELS = [
|
||||
{ id: 'mail', source: 'core', name: '메일' },
|
||||
{ id: 'database', source: 'core', name: '사이트내 알림' },
|
||||
{ id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true },
|
||||
{
|
||||
id: 'alimtalk',
|
||||
source: 'sirsoft-message_bizppurio',
|
||||
name: '비즈뿌리오 알림톡',
|
||||
tab_channels: ['sms', 'alimtalk'],
|
||||
tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다.
|
||||
*
|
||||
* @param node 탐색할 JSON 노드
|
||||
* @returns 서브탭 컨테이너 노드 (없으면 null)
|
||||
*/
|
||||
function findSubTabsContainer(node: unknown): Record<string, any> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findSubTabsContainer(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
|
||||
const record = node as Record<string, any>;
|
||||
if (typeof record.id === 'string' && record.id.endsWith('channel_sub_tabs')) return record;
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const found = findSubTabsContainer(value);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서브탭 컨테이너 안에서 iteration 을 가진 탭 버튼 노드를 찾습니다.
|
||||
*
|
||||
* @param node 탐색할 JSON 노드
|
||||
* @returns iteration 보유 노드 (없으면 null)
|
||||
*/
|
||||
function findIterationNode(node: unknown): Record<string, any> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findIterationNode(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
|
||||
const record = node as Record<string, any>;
|
||||
if (record.iteration && typeof record.iteration.source === 'string') return record;
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const found = findIterationNode(value);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('이커머스 알림 설정 — 채널 서브탭 필터 실평가 (#597)', () => {
|
||||
const layout = JSON.parse(fs.readFileSync(TAB_LAYOUT, 'utf-8'));
|
||||
const container = findSubTabsContainer(layout);
|
||||
const tabNode = container ? findIterationNode(container) : null;
|
||||
const expr = String(tabNode?.iteration?.source ?? '').replace(/^\{\{|\}\}$/g, '');
|
||||
|
||||
/**
|
||||
* 필터 표현식 원문을 실행해 노출 탭 id 목록을 반환합니다.
|
||||
*
|
||||
* @param saved _local.form.notifications.channels 저장값
|
||||
* @param channels availableChannels 채널 목록
|
||||
* @returns 필터를 통과한 채널 id 배열
|
||||
*/
|
||||
function visibleTabs(
|
||||
saved: Array<{ id: string; is_active: boolean }>,
|
||||
channels: Array<Record<string, any>> = CHANNELS,
|
||||
): string[] {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('availableChannels', '_local', `return ${expr};`);
|
||||
const result = fn(
|
||||
{ data: { channels } },
|
||||
{ form: { notifications: { channels: saved } } },
|
||||
) as Array<{ id: string }>;
|
||||
return result.map((c) => c.id);
|
||||
}
|
||||
|
||||
it('서브탭 필터 표현식을 레이아웃에서 추출할 수 있다', () => {
|
||||
expect(container, '*channel_sub_tabs 컨테이너를 찾지 못했다').toBeTruthy();
|
||||
expect(expr, '탭 iteration source 가 비어 있다').not.toBe('');
|
||||
});
|
||||
|
||||
it('sms·alimtalk 모두 활성 → 통합 탭 1개만 노출, sms 개별 탭은 숨김', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: true },
|
||||
{ id: 'alimtalk', is_active: true },
|
||||
])).toEqual(['mail', 'database', 'alimtalk']);
|
||||
});
|
||||
|
||||
it('sms 만 활성(alimtalk 비활성) → tab_channels 규칙으로 통합 탭 노출', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: true },
|
||||
{ id: 'alimtalk', is_active: false },
|
||||
])).toEqual(['mail', 'database', 'alimtalk']);
|
||||
});
|
||||
|
||||
it('sms·alimtalk 모두 비활성 → 통합 탭 미노출', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: false },
|
||||
{ id: 'alimtalk', is_active: false },
|
||||
])).toEqual(['mail', 'database']);
|
||||
});
|
||||
|
||||
it('확장 채널 저장값 자체가 없으면(미저장=opt-in 전) 통합 탭 미노출', () => {
|
||||
expect(visibleTabs([])).toEqual(['mail', 'database']);
|
||||
});
|
||||
|
||||
it('코어 채널은 저장값이 명시적 false 일 때만 숨는다 (기본 노출)', () => {
|
||||
expect(visibleTabs([{ id: 'mail', is_active: false }])).toEqual(['database']);
|
||||
});
|
||||
|
||||
it('tab_channels 미선언 확장 채널은 자기 자신의 활성 저장 기준으로 판정된다', () => {
|
||||
const withPlain = [...CHANNELS, { id: 'push', source: 'some-plugin', name: '푸시' }];
|
||||
expect(visibleTabs([{ id: 'push', is_active: true }], withPlain)).toEqual(['mail', 'database', 'push']);
|
||||
expect(visibleTabs([{ id: 'push', is_active: false }], withPlain)).toEqual(['mail', 'database']);
|
||||
});
|
||||
});
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio)
|
||||
// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다.
|
||||
/**
|
||||
* 이커머스 알림 템플릿 편집 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (코어 admin_basic 모달과 동형, 면 식별자만 다름)
|
||||
*
|
||||
* 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록:
|
||||
* - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞)
|
||||
* - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이)
|
||||
* - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다
|
||||
* (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일.
|
||||
*
|
||||
* 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은
|
||||
* 기대값을 함께 고치면 통과한다.
|
||||
*
|
||||
* @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import modal from '../../../layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json';
|
||||
|
||||
type Node = Record<string, any>;
|
||||
const PREFIX = 'ecommerce_';
|
||||
const STATE_KEY = 'ecommerce_notification_template_form_modal';
|
||||
const MODAL_ID = 'modal_ecommerce_notification_template_edit';
|
||||
const REFETCH_ID = 'ecommerceNotificationDefinitions';
|
||||
|
||||
const findById = (node: unknown, id: string): Node | null => {
|
||||
if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; }
|
||||
if (node && typeof node === 'object') {
|
||||
if ((node as Node).id === id) return node as Node;
|
||||
for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; }
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => {
|
||||
if (Array.isArray(node)) {
|
||||
const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id);
|
||||
if (idx >= 0) return { arr: node, idx };
|
||||
for (const n of node) { const r = findParentArray(n, id); if (r) return r; }
|
||||
return null;
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; }
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const evalIf = (expr: string, scope: Record<string, unknown>): boolean => {
|
||||
const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
|
||||
const names = Object.keys(scope);
|
||||
// eslint-disable-next-line no-new-func
|
||||
return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n])));
|
||||
};
|
||||
const scope = (channel: string, hidden: boolean | undefined, extra: Record<string, unknown> = {}) => ({
|
||||
availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } },
|
||||
_global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } },
|
||||
});
|
||||
|
||||
const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId'];
|
||||
|
||||
describe('이커머스 알림 템플릿 편집 모달 — 확장 슬롯 2종', () => {
|
||||
it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => {
|
||||
const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`);
|
||||
expect(loc).toBeTruthy();
|
||||
const { arr, idx } = loc!;
|
||||
expect(arr[idx].type).toBe('extension_point');
|
||||
expect(arr[idx].name).toBe('notification_template_form_sections');
|
||||
expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`);
|
||||
expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`);
|
||||
});
|
||||
|
||||
it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => {
|
||||
const footer = findById(modal, `${PREFIX}template_modal_footer`)!;
|
||||
const right = footer.children[1].children as Node[];
|
||||
expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']);
|
||||
expect(right[1].name).toBe('notification_template_form_footer_actions');
|
||||
expect(JSON.stringify(right[0])).toContain('$t:common.cancel');
|
||||
expect(JSON.stringify(right[2])).toContain('$t:common.save');
|
||||
});
|
||||
|
||||
it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => {
|
||||
const ep = findById(modal, id)!;
|
||||
expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort());
|
||||
expect(ep.props.modalId).toBe(MODAL_ID);
|
||||
expect(ep.props.stateKey).toBe(STATE_KEY);
|
||||
expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID);
|
||||
expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`);
|
||||
expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`);
|
||||
expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`);
|
||||
expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('이커머스 알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => {
|
||||
const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info'];
|
||||
|
||||
it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => {
|
||||
const node = findById(modal, `${PREFIX}${id}`)!;
|
||||
expect(node.if, `${id} if`).toBeTruthy();
|
||||
expect(evalIf(node.if, scope('alimtalk', true))).toBe(false);
|
||||
expect(evalIf(node.if, scope('mail', undefined))).toBe(true);
|
||||
expect(evalIf(node.if, scope('sms', false))).toBe(true);
|
||||
});
|
||||
|
||||
it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => {
|
||||
const node = findById(modal, `${PREFIX}template_click_url_input`)!;
|
||||
expect(evalIf(node.if, scope('database', undefined))).toBe(true);
|
||||
expect(evalIf(node.if, scope('mail', undefined))).toBe(false);
|
||||
expect(evalIf(node.if, scope('database', true))).toBe(false);
|
||||
});
|
||||
|
||||
it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => {
|
||||
const node = findById(modal, `${PREFIX}template_variables_info`)!;
|
||||
const noVars = { ...scope('mail', undefined) } as Node;
|
||||
noVars._global[STATE_KEY].definition = { variables: [] };
|
||||
expect(evalIf(node.if, noVars)).toBe(false);
|
||||
});
|
||||
|
||||
it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => {
|
||||
const footer = findById(modal, `${PREFIX}template_modal_footer`)!;
|
||||
const preview = footer.children[0].children[0] as Node;
|
||||
const right = footer.children[1].children as Node[];
|
||||
const cancel = right[0];
|
||||
const save = right[2];
|
||||
for (const b of [preview, save]) {
|
||||
expect(evalIf(b.if, scope('alimtalk', true))).toBe(false);
|
||||
expect(evalIf(b.if, scope('mail', undefined))).toBe(true);
|
||||
}
|
||||
expect(cancel.if).toBeUndefined();
|
||||
});
|
||||
|
||||
it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => {
|
||||
for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) {
|
||||
const node = findById(modal, `${PREFIX}${id}`)!;
|
||||
expect(node, id).toBeTruthy();
|
||||
expect(String(node.if ?? '')).not.toContain('hidden_template_editor');
|
||||
}
|
||||
});
|
||||
});
|
||||
+52
-18
@@ -500,8 +500,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
}
|
||||
"props": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -528,6 +527,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ecommerce_template_form_sections_ext",
|
||||
"comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.",
|
||||
"type": "extension_point",
|
||||
"name": "notification_template_form_sections",
|
||||
"props": {
|
||||
"definition": "{{_global.ecommerce_notification_template_form_modal?.definition}}",
|
||||
"template": "{{_global.ecommerce_notification_template_form_modal?.template}}",
|
||||
"channel": "{{_global.ecommerce_notification_template_form_modal?.template?.channel ?? ''}}",
|
||||
"modalId": "modal_ecommerce_notification_template_edit",
|
||||
"stateKey": "ecommerce_notification_template_form_modal",
|
||||
"saveEndpoint": "{{'/api/admin/notification-templates/' + _global.ecommerce_notification_template_form_modal?.template?.id}}",
|
||||
"refetchDataSourceId": "ecommerceNotificationDefinitions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ecommerce_template_lang_tabs",
|
||||
"type": "basic",
|
||||
@@ -559,7 +573,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
|
||||
},
|
||||
{
|
||||
"id": "ecommerce_template_subject_input",
|
||||
@@ -613,7 +628,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
|
||||
},
|
||||
{
|
||||
"id": "ecommerce_template_body_input",
|
||||
@@ -668,13 +684,14 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
|
||||
},
|
||||
{
|
||||
"id": "ecommerce_template_click_url_input",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.ecommerce_notification_template_form_modal?.template?.channel === 'database'}}",
|
||||
"if": "{{(_global.ecommerce_notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -724,7 +741,7 @@
|
||||
"id": "ecommerce_template_variables_info",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(_global.ecommerce_notification_template_form_modal?.definition?.variables ?? []).length > 0}}",
|
||||
"if": "{{((_global.ecommerce_notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}",
|
||||
"props": {
|
||||
"className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4"
|
||||
},
|
||||
@@ -841,17 +858,18 @@
|
||||
"target": "modal_ecommerce_notification_definition_preview"
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -879,6 +897,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ecommerce_template_form_footer_ext",
|
||||
"comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.",
|
||||
"type": "extension_point",
|
||||
"name": "notification_template_form_footer_actions",
|
||||
"props": {
|
||||
"definition": "{{_global.ecommerce_notification_template_form_modal?.definition}}",
|
||||
"template": "{{_global.ecommerce_notification_template_form_modal?.template}}",
|
||||
"channel": "{{_global.ecommerce_notification_template_form_modal?.template?.channel ?? ''}}",
|
||||
"modalId": "modal_ecommerce_notification_template_edit",
|
||||
"stateKey": "ecommerce_notification_template_form_modal",
|
||||
"saveEndpoint": "{{'/api/admin/notification-templates/' + _global.ecommerce_notification_template_form_modal?.template?.id}}",
|
||||
"refetchDataSourceId": "ecommerceNotificationDefinitions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
@@ -978,7 +1011,8 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -139,7 +139,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"iteration": {
|
||||
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}",
|
||||
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.hidden_tab !== true && (c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0))}}",
|
||||
"item_var": "ch",
|
||||
"index_var": "chIdx"
|
||||
},
|
||||
@@ -159,7 +159,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{ch.name ?? ch.id}}"
|
||||
"text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
|
||||
+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 {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user