공개 (@Tuwasduliebst 제보) 대응. - ckeditor5: 미참조 이미지 정리 엔진 신설 — 참조 판정(hash + 파일명 2토큰, filter_reference_sources 훅으로 확장 콘텐츠 등록) + 정리 커맨드(기본 꺼짐 옵트인, 3중 게이트) + 업로드 관리 화면(권한 분리·참조 배지·개별/선택 삭제) + 기설치본 설정 백필 upgrade step - board: 임시 첨부 GC(상시) + 소프트삭제 첨부 영구 정리(옵트인) + 설정 UI, 저장 payload 누락(F4)·빈 temp 디렉토리 잔존(F5) 수정 - ecommerce: 임시 상품 이미지 GC + 카테고리 삭제 시 이미지 파일 미삭제(S4) 수정 - page: 임시 첨부 GC - 코어: 고아 첨부 정리 커맨드(옵트인, site_logo 보호) + 사이트 로고 교체 시 구 로고 재편입 차단(S6) + DB 백업 미구현 501 정직화(B3) + 플러그인 관리자 메뉴 설치·업데이트 자동 동기화(F1·F7) - 동반: 플러그인 설정 검증 오류 필드명 라벨화(F3), Toggle 노드 type 정정 수평 전개(marketing 1.0.2 bump·gdpr·ecommerce), ja 언어팩 4종, API 문서, hooks.md, 시나리오 매니페스트 8종, Playwright E2E 4종
1579 lines
59 KiB
PHP
1579 lines
59 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Contracts\Extension\CacheInterface;
|
|
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
|
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
|
use App\Extension\HookManager;
|
|
use App\Http\Resources\AttachmentResource;
|
|
use App\Seo\Contracts\SeoCacheManagerInterface;
|
|
use App\Support\ConfigCacheHelper;
|
|
use App\Support\ExtensionSettingsMirror;
|
|
use App\Support\OpcacheStatus;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/**
|
|
* 시스템 설정 서비스
|
|
*
|
|
* JSON 파일 기반 설정 관리를 담당합니다.
|
|
*/
|
|
class SettingsService
|
|
{
|
|
public function __construct(
|
|
private ConfigRepositoryInterface $configRepository,
|
|
private AttachmentRepositoryInterface $attachmentRepository,
|
|
private CacheInterface $cache,
|
|
private AttachmentService $attachmentService
|
|
) {}
|
|
|
|
/**
|
|
* 시스템 설정 캐시를 무효화합니다.
|
|
*
|
|
* 설정 저장 시 4곳에서 중복되던 로직을 단일 메서드로 통합했습니다.
|
|
* config 캐시는 clear 후 즉시 재생성한다(ConfigCacheHelper). config:clear 만
|
|
* 하면 config:cache 가 비워진 채 재생성되지 않아 이후 모든 요청이 config 파일을
|
|
* 재파싱하는 성능 손실이 발생하기 때문이다.
|
|
*/
|
|
private function invalidateSettingsCache(): void
|
|
{
|
|
$this->cache->forget('settings.system');
|
|
ConfigCacheHelper::rebuild();
|
|
|
|
// 같은 프로세스의 in-memory 미러도 즉시 다시 채운다 (공개이슈 #109).
|
|
// 이 호출이 없으면 상주 프로세스(큐 워커·schedule:work·Reverb)는 저장 후에도
|
|
// 부팅 시점의 옛 값을 영원히 읽는다 — FPM 에서만 드러나지 않는 결함이다.
|
|
app(ExtensionSettingsMirror::class)->refreshCore();
|
|
}
|
|
|
|
/**
|
|
* 큐 워커에 정상 종료 후 재시작 신호를 보냅니다.
|
|
*
|
|
* drivers 카테고리(queue/broadcasting/cache 등)는 long-running worker 에 영향을 준다.
|
|
* SettingsServiceProvider 는 worker boot 시점에 한 번만 config 를 적용하므로, 재시작
|
|
* 신호가 없으면 워커가 부팅 시점의 옛 드라이버로 계속 동작한다.
|
|
*
|
|
* 신호 전송 실패가 설정 저장을 되돌리지는 않는다 (경고 로깅 후 계속).
|
|
*/
|
|
private function restartQueueWorkers(): void
|
|
{
|
|
try {
|
|
Artisan::call('queue:restart');
|
|
} catch (\Throwable $e) {
|
|
Log::warning('queue:restart 실행 실패', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 자산 URL 방식 변경에 따라 SEO 프리렌더 캐시를 비웁니다.
|
|
*
|
|
* SEO 캐시에는 생성 시점의 자산 URL 이 문자열로 구워져 있어, 모드가 바뀌면
|
|
* 그 URL 들이 전부 어긋난다. 사람 방문자는 브라우저 자가 복구가 살리지만
|
|
* 검색엔진 봇은 JavaScript 를 실행하지 않으므로 캐시를 비워 재생성시켜야 한다.
|
|
*
|
|
* 캐시 삭제 실패가 설정 저장 자체를 되돌리지는 않는다 — 설정은 이미 저장됐고,
|
|
* 캐시는 TTL 만료나 `seo:clear` 로도 회복 가능한 부수 상태다.
|
|
*/
|
|
private function clearSeoCacheForAssetUrlMode(): void
|
|
{
|
|
try {
|
|
app(SeoCacheManagerInterface::class)->clearAll();
|
|
} catch (\Throwable $e) {
|
|
Log::warning('자산 URL 방식 변경 후 SEO 캐시 삭제 실패 — seo:clear 로 수동 삭제 필요', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모든 시스템 설정을 조회합니다.
|
|
*
|
|
* nested 구조로 반환합니다:
|
|
* { general: { site_name: '...' }, mail: { ... }, ... }
|
|
*
|
|
* 프론트엔드 스키마의 frontend_key 매핑을 적용하여
|
|
* JSON 레이아웃에서 사용하는 필드명으로 변환합니다.
|
|
*
|
|
* 스키마의 frontend_name/merge_into 설정에 따라 카테고리를 자동 병합합니다.
|
|
* (예: cache, debug → advanced)
|
|
*
|
|
* @return array 시스템 설정 배열
|
|
*/
|
|
public function getAllSettings(): array
|
|
{
|
|
$settings = [];
|
|
$schema = $this->configRepository->getFrontendSchema();
|
|
|
|
// 모든 카테고리 설정 로드 (nested 구조)
|
|
foreach ($this->configRepository->all() as $category => $categorySettings) {
|
|
$categorySchema = $schema[$category] ?? null;
|
|
$fields = $categorySchema['fields'] ?? [];
|
|
|
|
// 스키마의 frontend_name 또는 merge_into에 따라 타겟 카테고리 결정
|
|
$targetCategory = $categorySchema['frontend_name'] ?? $categorySchema['merge_into'] ?? $category;
|
|
|
|
// 타겟 카테고리 초기화
|
|
if (! isset($settings[$targetCategory])) {
|
|
$settings[$targetCategory] = [];
|
|
}
|
|
|
|
// 원본 카테고리도 유지 (debug, cache 등 개별 접근용)
|
|
if ($targetCategory !== $category && ! isset($settings[$category])) {
|
|
$settings[$category] = [];
|
|
}
|
|
|
|
foreach ($categorySettings as $key => $value) {
|
|
// frontend_key가 정의되어 있으면 해당 키로 변환
|
|
$outputKey = $fields[$key]['frontend_key'] ?? $key;
|
|
|
|
// 타겟 카테고리에 병합
|
|
$settings[$targetCategory][$outputKey] = $value;
|
|
|
|
// 원본 카테고리에도 저장 (개별 접근용)
|
|
if ($targetCategory !== $category) {
|
|
$settings[$category][$outputKey] = $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 첨부파일 정보 추가 (general 카테고리에)
|
|
$settings['general']['site_logo'] = $this->getSiteLogoAttachment();
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/**
|
|
* 특정 카테고리의 설정을 조회합니다.
|
|
*
|
|
* @param string $category 카테고리명
|
|
* @return array 설정 배열
|
|
*/
|
|
public function getSettings(string $category): array
|
|
{
|
|
return $this->configRepository->getCategory($category);
|
|
}
|
|
|
|
/**
|
|
* 프론트엔드에서 사용할 전체 설정을 조회합니다.
|
|
*
|
|
* config/settings/defaults.json의 frontend_schema를 기반으로
|
|
* 각 카테고리별 설정을 동적으로 구조화하여 반환합니다.
|
|
* 민감한 정보(sensitive: true)는 제외합니다.
|
|
*
|
|
* @return array 카테고리별 설정 배열
|
|
*/
|
|
public function getFrontendSettings(): array
|
|
{
|
|
$schema = $this->configRepository->getFrontendSchema();
|
|
$result = [];
|
|
|
|
foreach ($schema as $category => $categorySchema) {
|
|
// _description 등 메타 키 건너뛰기
|
|
if (str_starts_with($category, '_')) {
|
|
continue;
|
|
}
|
|
|
|
// expose가 false이면 건너뛰기
|
|
if (! ($categorySchema['expose'] ?? false)) {
|
|
continue;
|
|
}
|
|
|
|
// merge_into가 있으면 해당 카테고리에 병합
|
|
$targetCategory = $categorySchema['frontend_name'] ?? $categorySchema['merge_into'] ?? $category;
|
|
|
|
// 설정값 조회
|
|
$settings = $this->getSettings($category);
|
|
$formattedSettings = $this->formatCategorySettings($settings, $categorySchema);
|
|
|
|
// 타겟 카테고리에 병합
|
|
if (isset($result[$targetCategory])) {
|
|
$result[$targetCategory] = array_merge($result[$targetCategory], $formattedSettings);
|
|
} else {
|
|
$result[$targetCategory] = $formattedSettings;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 프론트엔드에 노출할 app config 값을 반환합니다.
|
|
*
|
|
* config/frontend.php의 app_config 스키마를 기반으로
|
|
* config() 함수에서 값을 조회하여 반환합니다.
|
|
*
|
|
* @return array<string, mixed> 프론트엔드용 앱 설정 배열
|
|
*/
|
|
public function getAppConfigForFrontend(): array
|
|
{
|
|
$fields = config('frontend.app_config', []);
|
|
$result = [];
|
|
|
|
foreach ($fields as $frontendKey => $fieldSchema) {
|
|
$configKey = $fieldSchema['config_key'] ?? null;
|
|
if (! $configKey) {
|
|
continue;
|
|
}
|
|
|
|
$value = config($configKey);
|
|
|
|
if ($frontendKey === 'supportedTimezones' && is_array($value)) {
|
|
$result[$frontendKey] = $this->buildTimezoneOptions($value);
|
|
|
|
continue;
|
|
}
|
|
|
|
// 언어 셀렉터에 노출할 활성 로케일은 config 의 허용 집합과
|
|
// 활성 코어 언어팩의 합집합. 언어팩 설치/활성화 직후 SSR 결과도
|
|
// 즉시 반영되도록 LanguagePackService 를 통해 동적 산출한다.
|
|
if ($frontendKey === 'supportedLocales' && is_array($value)) {
|
|
try {
|
|
$result[$frontendKey] = app(LanguagePackService::class)->getActiveLocales();
|
|
} catch (\Throwable $e) {
|
|
// 부트 초기/마이그레이션 미수행 환경에서는 config 값으로 폴백
|
|
$result[$frontendKey] = $this->castValue($value, $fieldSchema);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$result[$frontendKey] = $this->castValue($value, $fieldSchema);
|
|
}
|
|
|
|
// 확장이 요청 컨텍스트 기반 값(예: 기기 유형)을 appConfig 에 주입할 수 있도록 필터 훅 제공.
|
|
// config/frontend.php 는 정적 config 값만 담으므로, 요청별로 달라지는 값은 이 훅으로 확장이 채운다.
|
|
// 프론트는 window.G7Config.appConfig → _global.appConfig 로 그대로 받는다.
|
|
return HookManager::applyFilters('core.frontend.filter_app_config', $result);
|
|
}
|
|
|
|
/**
|
|
* IANA 타임존 배열을 프론트엔드 Select 옵션 형식으로 변환합니다.
|
|
*
|
|
* 각 타임존의 현재 시점 UTC 오프셋을 계산하여
|
|
* "(UTC±HH:MM) Asia/Seoul" 형식의 라벨을 생성합니다.
|
|
* DST 전환 시점에 자동으로 정확한 오프셋이 반영됩니다.
|
|
*
|
|
* 정렬 순서: 오프셋 오름차순 → 식별자 알파벳순
|
|
*
|
|
* @param array<int, string> $timezones IANA 타임존 식별자 배열
|
|
* @return array<int, array{value: string, label: string}> Select 옵션 배열
|
|
*/
|
|
private function buildTimezoneOptions(array $timezones): array
|
|
{
|
|
$now = new \DateTime('now', new \DateTimeZone('UTC'));
|
|
|
|
$items = [];
|
|
foreach ($timezones as $tz) {
|
|
if (! is_string($tz) || $tz === '') {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$offsetSec = (new \DateTimeZone($tz))->getOffset($now);
|
|
} catch (\Exception) {
|
|
continue;
|
|
}
|
|
|
|
$sign = $offsetSec >= 0 ? '+' : '-';
|
|
$h = intdiv(abs($offsetSec), 3600);
|
|
$m = intdiv(abs($offsetSec) % 3600, 60);
|
|
|
|
$items[] = [
|
|
'value' => $tz,
|
|
'label' => sprintf('(UTC%s%02d:%02d) %s', $sign, $h, $m, $tz),
|
|
'offset' => $offsetSec,
|
|
];
|
|
}
|
|
|
|
usort($items, fn ($a, $b) => [$a['offset'], $a['value']] <=> [$b['offset'], $b['value']]);
|
|
|
|
return array_map(
|
|
fn ($i) => ['value' => $i['value'], 'label' => $i['label']],
|
|
$items
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 스키마에 따라 카테고리 설정을 포맷팅합니다.
|
|
*
|
|
* @param array $settings 원본 설정 배열
|
|
* @param array $categorySchema 카테고리 스키마
|
|
* @return array 포맷팅된 설정 배열
|
|
*/
|
|
private function formatCategorySettings(array $settings, array $categorySchema): array
|
|
{
|
|
$fields = $categorySchema['fields'] ?? [];
|
|
$result = [];
|
|
|
|
foreach ($fields as $fieldName => $fieldSchema) {
|
|
// 민감 정보는 건너뛰기
|
|
if ($fieldSchema['sensitive'] ?? false) {
|
|
continue;
|
|
}
|
|
|
|
// 필드 레벨 expose: false는 건너뛰기
|
|
if (isset($fieldSchema['expose']) && $fieldSchema['expose'] === false) {
|
|
continue;
|
|
}
|
|
|
|
// 출력 키 결정 (frontend_key가 있으면 사용)
|
|
$outputKey = $fieldSchema['frontend_key'] ?? $fieldName;
|
|
|
|
// computed 필드는 별도 처리
|
|
if ($fieldSchema['computed'] ?? false) {
|
|
$result[$outputKey] = $this->computeFieldValue($settings, $fieldSchema);
|
|
|
|
continue;
|
|
}
|
|
|
|
// 값 조회 및 타입 캐스팅
|
|
$value = $settings[$fieldName] ?? null;
|
|
$result[$outputKey] = $this->castValue($value, $fieldSchema);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* computed 필드의 값을 계산합니다.
|
|
*
|
|
* @param array $settings 설정 배열
|
|
* @param array $fieldSchema 필드 스키마
|
|
* @return mixed 계산된 값
|
|
*/
|
|
private function computeFieldValue(array $settings, array $fieldSchema): mixed
|
|
{
|
|
$sourceField = $fieldSchema['source'] ?? null;
|
|
$transform = $fieldSchema['transform'] ?? null;
|
|
|
|
if (! $sourceField || ! $transform) {
|
|
return null;
|
|
}
|
|
|
|
$sourceValue = $settings[$sourceField] ?? null;
|
|
|
|
return match ($transform) {
|
|
'format_extensions' => $this->formatExtensions($sourceValue),
|
|
'first_image_url' => $this->getFirstImageUrl($sourceValue),
|
|
default => $sourceValue,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 확장자 배열/문자열을 .jpg,.png 형식으로 변환합니다.
|
|
*
|
|
* @param mixed $extensions 확장자 배열 또는 문자열
|
|
* @return string 포맷팅된 확장자 문자열
|
|
*/
|
|
private function formatExtensions(mixed $extensions): string
|
|
{
|
|
if (empty($extensions)) {
|
|
return '';
|
|
}
|
|
|
|
// 배열인 경우 문자열로 변환
|
|
if (is_array($extensions)) {
|
|
$extensions = implode(',', $extensions);
|
|
}
|
|
|
|
// 확장자 변환: 'jpg,jpeg,png' → '.jpg,.jpeg,.png'
|
|
$extArray = array_map('trim', explode(',', $extensions));
|
|
$formatted = array_map(fn ($ext) => '.'.ltrim($ext, '.'), $extArray);
|
|
|
|
return implode(',', $formatted);
|
|
}
|
|
|
|
/**
|
|
* 스키마 타입에 따라 값을 캐스팅합니다.
|
|
*
|
|
* @param mixed $value 원본 값
|
|
* @param array $fieldSchema 필드 스키마
|
|
* @return mixed 캐스팅된 값
|
|
*/
|
|
private function castValue(mixed $value, array $fieldSchema): mixed
|
|
{
|
|
$type = $fieldSchema['type'] ?? 'string';
|
|
$transform = $fieldSchema['transform'] ?? null;
|
|
|
|
// transform이 있으면 먼저 처리
|
|
if ($transform === 'join_comma' && is_array($value)) {
|
|
$value = implode(',', $value);
|
|
}
|
|
|
|
// null이면 타입별 기본값 반환
|
|
if ($value === null) {
|
|
return match ($type) {
|
|
'boolean' => false,
|
|
'integer' => 0,
|
|
'string' => '',
|
|
'array' => [],
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
return match ($type) {
|
|
'boolean' => (bool) $value,
|
|
'integer' => (int) $value,
|
|
'string' => (string) $value,
|
|
'array' => (array) $value,
|
|
default => $value,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* site_logo 설정의 첨부파일 목록을 조회합니다.
|
|
*
|
|
* JSON 설정에 저장된 attachment ID 배열을 기반으로 첨부파일 정보를 조회합니다.
|
|
*
|
|
* @return array 첨부파일 정보 배열
|
|
*/
|
|
private function getSiteLogoAttachment(): array
|
|
{
|
|
// JSON 설정에서 site_logo ID 배열 조회
|
|
$siteLogoIds = $this->configRepository->get('general.site_logo', []);
|
|
|
|
// 빈 배열이거나 배열이 아닌 경우
|
|
if (empty($siteLogoIds) || ! is_array($siteLogoIds)) {
|
|
return [];
|
|
}
|
|
|
|
// ID 배열로 첨부파일 조회 (DB order 기준 정렬)
|
|
$attachments = $this->attachmentRepository->findByIds($siteLogoIds);
|
|
|
|
if ($attachments->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
return $attachments->map(fn ($attachment) => (new AttachmentResource($attachment))->toListArray())->toArray();
|
|
}
|
|
|
|
/**
|
|
* 첨부파일 ID 배열에서 첫 번째 이미지의 URL을 반환합니다.
|
|
*
|
|
* @param mixed $attachmentIds 첨부파일 ID 배열
|
|
* @return string|null 첫 번째 이미지의 다운로드 URL 또는 null
|
|
*/
|
|
private function getFirstImageUrl(mixed $attachmentIds): ?string
|
|
{
|
|
if (empty($attachmentIds) || ! is_array($attachmentIds)) {
|
|
return null;
|
|
}
|
|
|
|
// ID 배열로 첨부파일 조회
|
|
$attachments = $this->attachmentRepository->findByIds($attachmentIds);
|
|
|
|
if ($attachments->isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
// 첫 번째 이미지 파일 찾기
|
|
$firstImage = $attachments->first(fn ($attachment) => $attachment->is_image);
|
|
|
|
return $firstImage?->download_url;
|
|
}
|
|
|
|
/**
|
|
* 환경설정 값을 저장합니다.
|
|
*
|
|
* @param array $settings 카테고리별 설정 데이터
|
|
* @return bool 저장 성공 여부
|
|
*/
|
|
public function saveSettings(array $settings): bool
|
|
{
|
|
// Before 훅
|
|
HookManager::doAction('core.settings.before_save', $settings);
|
|
|
|
// 필터 훅 - 설정 데이터 변형
|
|
$settings = HookManager::applyFilters('core.settings.filter_save_data', $settings);
|
|
|
|
try {
|
|
// _tab 키로 카테고리 결정
|
|
$tab = $settings['_tab'] ?? 'general';
|
|
unset($settings['_tab']);
|
|
|
|
// nested 구조에서 해당 탭의 설정 추출
|
|
// 예: { general: { site_name: '...' } } → { site_name: '...' }
|
|
$tabSettings = $settings[$tab] ?? $settings;
|
|
|
|
// frontend_key를 원본 키로 역변환
|
|
$tabSettings = $this->reverseFrontendKeys($tabSettings);
|
|
|
|
// advanced 탭은 cache와 debug 두 카테고리로 분리
|
|
if ($tab === 'advanced') {
|
|
$result = $this->saveAdvancedSettings($tabSettings);
|
|
|
|
// After 훅
|
|
HookManager::doAction('core.settings.after_save', $tab, $tabSettings, $result);
|
|
|
|
return $result;
|
|
}
|
|
|
|
// general 탭인 경우 site_logo 첨부파일 연결.
|
|
// site_logo 를 제출하지 않은 저장(다른 필드만 변경)은 기존 저장값을 그대로 둔다 —
|
|
// 이때 컬렉션을 다시 훑으면 미참조 첨부가 설정으로 딸려 들어온다.
|
|
$removedSiteLogoIds = [];
|
|
|
|
if ($tab === 'general' && is_array($tabSettings['site_logo'] ?? null)) {
|
|
// 파기 대상 판정은 저장 **전에** 한다 — 저장 후에는 직전 저장값을 알 수 없다.
|
|
// 실제 파기는 저장이 성공한 뒤에 수행한다: 저장이 실패했는데 파일만 사라지면
|
|
// 설정에는 이미 없는 첨부 id 가 남아 로고가 깨진다.
|
|
$removedSiteLogoIds = $this->resolveRemovedSiteLogoIds($tabSettings['site_logo']);
|
|
|
|
$tabSettings['site_logo'] = $this->resolveSiteLogoIds($tabSettings['site_logo']);
|
|
}
|
|
|
|
// 기존 설정과 병합 (탭별로 일부 필드만 전송되어도 기존 설정 유지)
|
|
$existingSettings = $this->configRepository->getCategory($tab);
|
|
$mergedSettings = array_merge($existingSettings, $tabSettings);
|
|
|
|
// 자산 URL 방식이 바뀌는지 저장 **전에** 판정한다 (이슈 #486).
|
|
// 저장 후에는 이전 값을 알 수 없어 변경 여부를 판별할 수 없다.
|
|
$assetUrlModeChanged = $tab === 'general'
|
|
&& array_key_exists('asset_url_mode', $tabSettings)
|
|
&& ($existingSettings['asset_url_mode'] ?? null) !== $tabSettings['asset_url_mode'];
|
|
|
|
// 해당 카테고리 설정 저장
|
|
$result = $this->configRepository->saveCategory($tab, $mergedSettings);
|
|
|
|
if ($result) {
|
|
$this->invalidateSettingsCache();
|
|
|
|
// 저장이 확정된 뒤에야 파일을 파기한다 (위 판정 시점 주석 참조).
|
|
$this->purgeSiteLogoAttachments($removedSiteLogoIds);
|
|
|
|
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
|
|
// 모드가 바뀌면 그 URL 들이 전부 어긋나는데, 봇은 JavaScript 를 실행하지
|
|
// 않아 브라우저 자가 복구가 닿지 않는다 → 캐시를 비워 재생성시킨다.
|
|
// CLI(`g7:asset-url-mode`)와 동일한 처리 (계획서 §알려진 한계).
|
|
if ($assetUrlModeChanged) {
|
|
$this->clearSeoCacheForAssetUrlMode();
|
|
}
|
|
|
|
// drivers 탭은 queue/broadcasting/cache 등 long-running worker에 영향
|
|
// SettingsServiceProvider는 worker boot 시점에 한 번만 config 적용하므로
|
|
// 워커가 정상 종료 후 재시작되도록 신호 전송 (cache 기반, 즉시 종료 X)
|
|
if ($tab === 'drivers') {
|
|
$this->restartQueueWorkers();
|
|
}
|
|
}
|
|
|
|
// After 훅
|
|
HookManager::doAction('core.settings.after_save', $tab, $mergedSettings, $result);
|
|
|
|
return $result;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'settings' => [__('settings.save_failed').': '.$e->getMessage()],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* frontend_key를 원본 저장소 키로 역변환합니다.
|
|
*
|
|
* @param array $settings 프론트엔드 키 기반 설정
|
|
* @return array 원본 키 기반 설정
|
|
*/
|
|
private function reverseFrontendKeys(array $settings): array
|
|
{
|
|
$schema = $this->configRepository->getFrontendSchema();
|
|
$reverseMap = $this->buildReverseKeyMap($schema);
|
|
|
|
$result = [];
|
|
foreach ($settings as $key => $value) {
|
|
$originalKey = $reverseMap[$key] ?? $key;
|
|
$result[$originalKey] = $value;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* frontend_key → 원본 키 역변환 맵을 생성합니다.
|
|
*
|
|
* @param array $schema 프론트엔드 스키마
|
|
* @return array 역변환 맵
|
|
*/
|
|
private function buildReverseKeyMap(array $schema): array
|
|
{
|
|
$map = [];
|
|
foreach ($schema as $category => $categorySchema) {
|
|
if (str_starts_with($category, '_')) {
|
|
continue;
|
|
}
|
|
|
|
$fields = $categorySchema['fields'] ?? [];
|
|
foreach ($fields as $fieldName => $fieldSchema) {
|
|
$frontendKey = $fieldSchema['frontend_key'] ?? null;
|
|
if ($frontendKey) {
|
|
$map[$frontendKey] = $fieldName;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* advanced 탭 설정의 카테고리별 필드 분류표를 만듭니다.
|
|
*
|
|
* 분류표는 스키마(`frontend_schema.*.merge_into === 'advanced'`)에서 도출합니다.
|
|
* 손으로 열거하면 고급 탭에 카테고리가 새로 합류할 때 분류표가 뒤처지고, 그 값은
|
|
* 어느 카테고리에도 담기지 않은 채 조용히 버려집니다(저장은 성공으로 보고됨).
|
|
*
|
|
* 아래 기본 목록은 스키마가 노출하지 않지만 고급 탭이 계속 저장해 온 레거시 필드
|
|
* (cache 카테고리 전반, debug.log_level)를 보존하기 위한 것입니다.
|
|
*
|
|
* @return array<string, array<int, string>> 카테고리 → 원본 필드명 목록
|
|
*/
|
|
private function buildAdvancedCategoryFieldMap(): array
|
|
{
|
|
// (frontend_key → 원본 키 역변환 후의 키 기준)
|
|
$map = [
|
|
'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl', 'seo_sitemap_ttl'],
|
|
'debug' => ['mode', 'sql_query_log', 'log_level'],
|
|
];
|
|
|
|
foreach ($this->configRepository->getFrontendSchema() as $category => $categorySchema) {
|
|
if (str_starts_with($category, '_') || ($categorySchema['merge_into'] ?? null) !== 'advanced') {
|
|
continue;
|
|
}
|
|
|
|
$fields = array_keys($categorySchema['fields'] ?? []);
|
|
$map[$category] = array_values(array_unique(array_merge($map[$category] ?? [], $fields)));
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* advanced 탭 설정을 소속 카테고리로 분리하여 저장합니다.
|
|
*
|
|
* @param array $settings 저장할 설정 배열
|
|
* @return bool 저장 성공 여부
|
|
*/
|
|
private function saveAdvancedSettings(array $settings): bool
|
|
{
|
|
$categoryFieldMap = $this->buildAdvancedCategoryFieldMap();
|
|
|
|
// 설정을 카테고리별로 분류
|
|
$categorized = array_fill_keys(array_keys($categoryFieldMap), []);
|
|
|
|
foreach ($settings as $key => $value) {
|
|
foreach ($categoryFieldMap as $category => $fields) {
|
|
if (in_array($key, $fields)) {
|
|
$categorized[$category][$key] = $value;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$result = true;
|
|
|
|
// 각 카테고리별 병합 저장
|
|
foreach ($categorized as $category => $categorySettings) {
|
|
if (empty($categorySettings)) {
|
|
continue;
|
|
}
|
|
|
|
$existing = $this->configRepository->getCategory($category);
|
|
$merged = array_merge($existing, $categorySettings);
|
|
$result = $result && $this->configRepository->saveCategory($category, $merged);
|
|
}
|
|
|
|
if ($result) {
|
|
$this->invalidateSettingsCache();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 저장할 사이트 로고 첨부 ID 목록을 결정합니다.
|
|
*
|
|
* 기준은 **이번 저장 요청이 제출한 목록**입니다. 컬렉션 전체를 다시 훑으면, 저장에 실패했거나
|
|
* 작성 중 이탈해 남은 미참조 첨부까지 설정에 다시 편입되어(운영자가 올린 적 없는 로고가
|
|
* 되살아나는) 누적이 발생합니다.
|
|
*
|
|
* 제출값에 있더라도 실제로 존재하지 않는 첨부(다른 경로로 이미 삭제된 id)는 걸러냅니다.
|
|
*
|
|
* @param array<int, mixed> $submitted 제출된 site_logo 값 (첨부 객체 배열 또는 ID 배열)
|
|
* @return array<int, int> 저장할 첨부파일 ID 배열
|
|
*/
|
|
private function resolveSiteLogoIds(array $submitted): array
|
|
{
|
|
$submittedIds = $this->extractAttachmentIds($submitted);
|
|
|
|
if ($submittedIds === []) {
|
|
return [];
|
|
}
|
|
|
|
$existingIds = $this->attachmentRepository->getByCollection('site_logo')
|
|
->pluck('id')
|
|
->all();
|
|
|
|
return array_values(array_intersect($submittedIds, $existingIds));
|
|
}
|
|
|
|
/**
|
|
* 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 사이트 로고 첨부 ID 를 가려냅니다.
|
|
*
|
|
* 판정 기준은 **직전 저장값**입니다. 직전에 저장돼 있었는데 이번 제출에서 빠진 id 만
|
|
* 운영자가 명시적으로 뺀 것이고, 직전 저장값에도 없던 id 는 이번에 새로 올라온 첨부입니다.
|
|
* 그래서 이 판정은 저장으로 값이 덮이기 **전에** 수행해야 합니다.
|
|
*
|
|
* 저장할 목록 자체는 제출값이 정합니다(resolveSiteLogoIds) — 컬렉션 전체를 훑으면 저장에
|
|
* 실패했거나 이탈로 남은 미참조 첨부가 설정에 되살아납니다.
|
|
*
|
|
* @param mixed $submitted 제출된 site_logo 값 (첨부 객체 배열 또는 ID 배열, 미제출이면 null)
|
|
* @return array<int, int> 파기 대상 첨부 ID 목록
|
|
*/
|
|
private function resolveRemovedSiteLogoIds(mixed $submitted): array
|
|
{
|
|
// site_logo 를 아예 제출하지 않은 저장(다른 필드만 변경)은 판정 대상이 아니다.
|
|
if (! is_array($submitted)) {
|
|
return [];
|
|
}
|
|
|
|
$previousIds = $this->extractAttachmentIds(
|
|
$this->configRepository->getCategory('general')['site_logo'] ?? []
|
|
);
|
|
|
|
if ($previousIds === []) {
|
|
return [];
|
|
}
|
|
|
|
$keptIds = $this->extractAttachmentIds($submitted);
|
|
|
|
return array_values(array_diff($previousIds, $keptIds));
|
|
}
|
|
|
|
/**
|
|
* 제거가 확정된 사이트 로고 첨부를 파일까지 파기합니다.
|
|
*
|
|
* 설정 저장이 성공한 뒤에만 호출합니다 — 저장이 실패했는데 파일이 먼저 사라지면 설정에는
|
|
* 이미 없는 첨부 id 가 남아 로고가 깨집니다.
|
|
*
|
|
* @param array<int, int> $removedIds 파기 대상 첨부 ID 목록
|
|
*/
|
|
private function purgeSiteLogoAttachments(array $removedIds): void
|
|
{
|
|
if ($removedIds === []) {
|
|
return;
|
|
}
|
|
|
|
foreach ($removedIds as $removedId) {
|
|
$this->attachmentService->delete($removedId);
|
|
}
|
|
|
|
Log::info('사이트 로고 첨부 제거', ['attachment_ids' => $removedIds]);
|
|
}
|
|
|
|
/**
|
|
* 첨부 목록 값에서 첨부 ID 만 추출합니다.
|
|
*
|
|
* 저장값은 ID 배열이지만 화면 제출값은 첨부 객체 배열이라 두 형태를 모두 받습니다.
|
|
*
|
|
* @param mixed $value 첨부 목록 값
|
|
* @return array<int, int> 첨부 ID 목록
|
|
*/
|
|
private function extractAttachmentIds(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$ids = [];
|
|
|
|
foreach ($value as $item) {
|
|
$id = is_array($item) ? ($item['id'] ?? null) : $item;
|
|
|
|
if (is_numeric($id)) {
|
|
$ids[] = (int) $id;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($ids));
|
|
}
|
|
|
|
/**
|
|
* 특정 키의 설정값을 조회합니다.
|
|
*
|
|
* @param string $key 설정 키 (도트 노테이션 지원)
|
|
* @param mixed $default 기본값
|
|
* @return mixed 설정값
|
|
*/
|
|
public function getSetting(string $key, mixed $default = null): mixed
|
|
{
|
|
return $this->configRepository->get($key, $default);
|
|
}
|
|
|
|
/**
|
|
* 단일 설정 값을 저장합니다.
|
|
*
|
|
* 벌크 저장(saveSettings)이 수행하는 부수효과 중 저장 키에 해당하는 것을 함께 수행한다
|
|
* (공개 #114 동종). 예전에는 값만 쓰고 SEO 프리렌더 캐시 삭제·큐 워커 재시작 신호를
|
|
* 건너뛰어, 같은 값을 어느 경로로 바꾸느냐에 따라 시스템 상태가 달라졌다.
|
|
*
|
|
* 키는 **원본 저장소 키**로 받는다 — 벌크 저장의 `reverseFrontendKeys()`(화면 키 → 저장소
|
|
* 키 역변환)를 적용하지 않는다. 이 경로의 프로그램 호출자(본인인증 플러그인 설치/삭제의
|
|
* `identity.purpose_providers.*`)가 저장소 키를 직접 넘기고 있어, 역변환을 끼우면 그
|
|
* 호출들이 엉뚱한 키에 저장된다.
|
|
*
|
|
* 벌크 위임도 하지 않는다 — 벌크의 shallow `array_merge` 로는 깊은 키를 저장할 때
|
|
* 형제 매핑이 통째로 소실된다.
|
|
*
|
|
* @param string $key 설정 키 (예: 'general.site_name')
|
|
* @param mixed $value 저장할 값
|
|
* @return bool 저장 성공 여부
|
|
*/
|
|
public function setSetting(string $key, mixed $value): bool
|
|
{
|
|
// Before 훅
|
|
HookManager::doAction('core.settings.before_set', $key, $value);
|
|
|
|
try {
|
|
// 자산 URL 방식이 바뀌는지 저장 **전에** 판정한다 (이슈 #486 동형).
|
|
// 저장 후에는 이전 값을 알 수 없어 변경 여부를 판별할 수 없다.
|
|
$assetUrlModeChanged = $key === 'general.asset_url_mode'
|
|
&& $this->configRepository->get($key) !== $value;
|
|
|
|
$result = $this->configRepository->set($key, $value);
|
|
|
|
if ($result) {
|
|
// 인라인 무효화 대신 공통 경로를 탄다 — saveSettings/saveAdvancedSettings 와
|
|
// 같은 처리를 받아야 미러 재채움·디스크 config 캐시 재생성이 빠지지 않는다.
|
|
$this->invalidateSettingsCache();
|
|
|
|
if ($assetUrlModeChanged) {
|
|
$this->clearSeoCacheForAssetUrlMode();
|
|
}
|
|
|
|
if (str_starts_with($key, 'drivers.') || $key === 'drivers') {
|
|
$this->restartQueueWorkers();
|
|
}
|
|
}
|
|
|
|
// After 훅
|
|
HookManager::doAction('core.settings.after_set', $key, $value, $result);
|
|
|
|
return $result;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'setting' => [__('settings.save_individual_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 설정을 백업합니다.
|
|
*
|
|
* @return string 백업 파일 경로
|
|
*/
|
|
public function backupSettings(): string
|
|
{
|
|
return $this->configRepository->backup();
|
|
}
|
|
|
|
/**
|
|
* 백업에서 설정을 복원합니다.
|
|
*
|
|
* @param string $backupPath 백업 파일 경로
|
|
* @return bool 복원 성공 여부
|
|
*/
|
|
public function restoreSettings(string $backupPath): bool
|
|
{
|
|
$result = $this->configRepository->restore($backupPath);
|
|
|
|
if ($result) {
|
|
// 복원도 설정 전체를 갈아엎는 쓰기다 — 캐시만 비우고 미러를 두면
|
|
// 같은 프로세스가 복원 전 값을 계속 읽는다 (저장 경로와 동일 결함, 공개이슈 #109).
|
|
$this->invalidateSettingsCache();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 테스트 메일을 발송합니다.
|
|
*
|
|
* @param string $toEmail 수신자 이메일
|
|
* @param array $overrideSettings 폼에서 전달된 메일 설정 (저장된 값보다 우선)
|
|
* @return array 발송 결과
|
|
*/
|
|
public function sendTestMail(string $toEmail, array $overrideSettings = []): array
|
|
{
|
|
try {
|
|
$savedSettings = $this->configRepository->getCategory('mail');
|
|
$mailSettings = array_merge($savedSettings, $overrideSettings);
|
|
|
|
// 메일 설정 임시 적용 (드라이버별)
|
|
$mailer = $mailSettings['mailer'] ?? 'smtp';
|
|
config(['mail.default' => $mailer]);
|
|
|
|
match ($mailer) {
|
|
'smtp' => config([
|
|
'mail.mailers.smtp.host' => $mailSettings['host'] ?? '',
|
|
'mail.mailers.smtp.port' => (int) ($mailSettings['port'] ?? 587),
|
|
'mail.mailers.smtp.username' => $mailSettings['username'] ?? '',
|
|
'mail.mailers.smtp.password' => $mailSettings['password'] ?? '',
|
|
'mail.mailers.smtp.encryption' => $mailSettings['encryption'] ?? 'tls',
|
|
]),
|
|
'mailgun' => config([
|
|
'mail.mailers.mailgun.transport' => 'mailgun',
|
|
'services.mailgun.domain' => $mailSettings['mailgun_domain'] ?? '',
|
|
'services.mailgun.secret' => $mailSettings['mailgun_secret'] ?? '',
|
|
'services.mailgun.endpoint' => ! empty($mailSettings['mailgun_endpoint']) ? $mailSettings['mailgun_endpoint'] : 'api.mailgun.net',
|
|
]),
|
|
'ses' => config([
|
|
'mail.mailers.ses.transport' => 'ses',
|
|
'services.ses.key' => $mailSettings['ses_key'] ?? '',
|
|
'services.ses.secret' => $mailSettings['ses_secret'] ?? '',
|
|
'services.ses.region' => $mailSettings['ses_region'] ?? 'ap-northeast-2',
|
|
]),
|
|
default => null,
|
|
};
|
|
|
|
config([
|
|
'mail.from.address' => $mailSettings['from_address'] ?? '',
|
|
'mail.from.name' => $mailSettings['from_name'] ?? '그누보드7',
|
|
]);
|
|
|
|
$subject = __('settings.test_mail_subject', ['app_name' => config('app.name')]);
|
|
$body = __('settings.test_mail_body');
|
|
|
|
Mail::raw($body, function ($message) use ($toEmail, $subject) {
|
|
$message->to($toEmail)
|
|
->subject($subject);
|
|
|
|
// X-G7 헤더 추가 (발송 이력 로깅용)
|
|
$message->getHeaders()->addTextHeader('X-G7-Source', 'test_mail');
|
|
$message->getHeaders()->addTextHeader('X-G7-Extension-Type', 'core');
|
|
$message->getHeaders()->addTextHeader('X-G7-Extension-Id', 'core');
|
|
});
|
|
|
|
HookManager::doAction('core.mail.after_send', [
|
|
'recipientEmail' => $toEmail,
|
|
'senderEmail' => $mailSettings['from_address'] ?? config('mail.from.address'),
|
|
'senderName' => $mailSettings['from_name'] ?? config('mail.from.name'),
|
|
'subject' => $subject,
|
|
'body' => $body,
|
|
'extensionType' => 'core',
|
|
'extensionIdentifier' => 'core',
|
|
'source' => 'test_mail',
|
|
]);
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => __('settings.test_mail_sent'),
|
|
'subject' => $subject,
|
|
'body' => $body,
|
|
];
|
|
} catch (\Exception $e) {
|
|
HookManager::doAction('core.mail.send_failed', [
|
|
'recipientEmail' => $toEmail,
|
|
'senderEmail' => $mailSettings['from_address'] ?? config('mail.from.address'),
|
|
'senderName' => $mailSettings['from_name'] ?? config('mail.from.name'),
|
|
'subject' => $subject ?? null,
|
|
'body' => $body ?? null,
|
|
'extensionType' => 'core',
|
|
'extensionIdentifier' => 'core',
|
|
'source' => 'test_mail',
|
|
'errorMessage' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => __('settings.test_mail_failed'),
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 시스템 캐시 키 접두사.
|
|
*
|
|
* clearCache() 및 getSystemInfo() 에서 공유하여 사용합니다.
|
|
*/
|
|
private const SYSTEM_INFO_CACHE_PREFIX = 'settings.system_info';
|
|
|
|
/**
|
|
* 시스템 캐시 TTL (초).
|
|
*
|
|
* CPU/메모리 총량 등 거의 변하지 않는 하드웨어 정보가 대부분이고,
|
|
* Windows 환경에서 PowerShell(CIM) 호출이 수백 ms ~ 수 초 걸려
|
|
* 탭 전환 UX를 저해하므로 1시간 동안 재사용한다.
|
|
*/
|
|
private const SYSTEM_INFO_CACHE_TTL = 3600;
|
|
|
|
/**
|
|
* 시스템 환경 정보를 조회합니다.
|
|
*
|
|
* 하드웨어/환경 정보는 1시간 캐시하며, 실시간성이 요구되는
|
|
* server_time 은 매 호출마다 갱신합니다.
|
|
*
|
|
* @return array 시스템 정보 배열
|
|
*/
|
|
public function getSystemInfo(): array
|
|
{
|
|
$locale = app()->getLocale();
|
|
$key = self::SYSTEM_INFO_CACHE_PREFIX.'.'.$locale;
|
|
|
|
$info = $this->cache->remember(
|
|
$key,
|
|
fn () => $this->buildSystemInfo(),
|
|
self::SYSTEM_INFO_CACHE_TTL
|
|
);
|
|
|
|
$info['server_time'] = now()->format('Y-m-d H:i:s');
|
|
|
|
return $info;
|
|
}
|
|
|
|
/**
|
|
* 시스템 환경 정보 페이로드를 실제로 수집합니다.
|
|
*
|
|
* PowerShell/파일 I/O 가 포함되어 비용이 크므로 getSystemInfo() 에서
|
|
* 캐시로 감싸 호출합니다.
|
|
*
|
|
* @return array 시스템 정보 배열 (server_time 제외)
|
|
*/
|
|
private function buildSystemInfo(): array
|
|
{
|
|
return [
|
|
// php_uname / ini_get 은 disable_functions 로 차단될 수 있어 개별 격리한다.
|
|
'os_info' => $this->safeSystemProbe('os_info', fn () => php_uname('s').' '.php_uname('r'), __('common.unknown')),
|
|
'web_server' => $_SERVER['SERVER_SOFTWARE'] ?? __('common.unknown'),
|
|
'php_version' => PHP_VERSION,
|
|
'mysql_version' => $this->safeSystemProbe('mysql_version', fn () => $this->getDatabaseInfo(), __('common.unknown')),
|
|
'g7_version' => config('app.version', '1.0.0'),
|
|
'g7_release_year' => config('app.release_year', '2026'),
|
|
'laravel_version' => app()->version(),
|
|
'environment' => app()->environment(),
|
|
'cpu_info' => $this->safeSystemProbe('cpu_info', fn () => $this->getCpuInfo(), __('common.unknown')),
|
|
'memory_usage' => $this->safeSystemProbe('memory_usage', fn () => $this->getMemoryUsage(), $this->unknownUsage()),
|
|
'disk_usage' => $this->safeSystemProbe('disk_usage', fn () => $this->getDiskUsage(), $this->unknownUsage()),
|
|
'php_memory_limit' => $this->safeSystemProbe('php_memory_limit', fn () => ini_get('memory_limit'), __('common.unknown')),
|
|
'max_execution_time' => $this->safeSystemProbe('max_execution_time', fn () => ini_get('max_execution_time').__('settings.seconds'), __('common.unknown')),
|
|
'upload_max_filesize' => $this->safeSystemProbe('upload_max_filesize', fn () => ini_get('upload_max_filesize'), __('common.unknown')),
|
|
// 판정은 App\Support\OpcacheStatus 가 SSoT — 인스톨러 요구사항 화면과 같은 답을 낸다.
|
|
// enabled 가 null 이면 "확인 불가"(ini_get 차단 환경).
|
|
'opcache' => $this->safeSystemProbe('opcache', fn () => $this->getOpcacheStatus(), ['loaded' => false, 'enabled' => null]),
|
|
'install_path' => base_path(),
|
|
'config_path' => storage_path('app/settings'),
|
|
'log_path' => storage_path('logs'),
|
|
'upload_path' => storage_path('app/public'),
|
|
'php_extensions' => $this->safeSystemProbe('php_extensions', fn () => $this->getPhpExtensions(), ['required' => [], 'optional' => []]),
|
|
'database_config' => $this->safeSystemProbe('database_config', fn () => $this->getDatabaseConfig(), ['has_read_write_split' => false, 'write' => [], 'read' => []]),
|
|
'timezone' => config('app.timezone'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* OPcache 활성화 상태를 조회합니다.
|
|
*
|
|
* 판정은 App\Support\OpcacheStatus 가 SSoT 이며, 인스톨러 요구사항 화면과
|
|
* 같은 답을 냅니다. `enabled` 가 null 이면 ini_get 이 차단된 "확인 불가" 상태입니다.
|
|
*
|
|
* @return array{loaded: bool, enabled: bool|null} OPcache 상태
|
|
*/
|
|
protected function getOpcacheStatus(): array
|
|
{
|
|
return OpcacheStatus::probe();
|
|
}
|
|
|
|
/**
|
|
* 시스템 정보 probe 를 안전하게 실행합니다.
|
|
*
|
|
* probe 가 예외(ErrorException·Error·disable_functions 로 인한 Error 포함)를
|
|
* 던지면 전파하지 않고 폴백값을 반환하고 경고 로그를 남깁니다. 개별 항목 수집
|
|
* 실패가 system-info API 전체를 500 으로 만들지 않도록 격리합니다.
|
|
*
|
|
* @param string $label 실패 로그 식별용 항목명 (예: 'cpu_info')
|
|
* @param callable $cb 실행할 probe 콜백
|
|
* @param mixed $fallback 실패 시 반환할 폴백 값
|
|
* @return mixed probe 결과 또는 폴백
|
|
*/
|
|
private function safeSystemProbe(string $label, callable $cb, mixed $fallback): mixed
|
|
{
|
|
try {
|
|
return $cb();
|
|
} catch (\Throwable $e) {
|
|
Log::warning('시스템 정보 수집 실패', [
|
|
'item' => $label,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return $fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 메모리/디스크 사용량 조회 실패 시 사용할 폴백 배열을 반환합니다.
|
|
*
|
|
* total/used/free/percentage 형식을 memory_usage·disk_usage 와 동일하게 유지합니다.
|
|
*
|
|
* @return array 사용량 폴백 배열
|
|
*/
|
|
private function unknownUsage(): array
|
|
{
|
|
return [
|
|
'total' => __('common.unknown'),
|
|
'used' => __('common.unknown'),
|
|
'free' => __('common.unknown'),
|
|
'percentage' => 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 시스템 캐시를 정리합니다.
|
|
*
|
|
* @return bool 캐시 정리 성공 여부
|
|
*/
|
|
public function clearCache(): bool
|
|
{
|
|
try {
|
|
foreach (config('app.supported_locales', ['ko', 'en']) as $locale) {
|
|
$this->cache->forget(self::SYSTEM_INFO_CACHE_PREFIX.'.'.$locale);
|
|
}
|
|
|
|
Artisan::call('cache:clear');
|
|
Artisan::call('route:clear');
|
|
Artisan::call('view:clear');
|
|
|
|
// config 는 clear 후 즉시 재생성 (비운 채 두면 이후 모든 요청이 config 재파싱).
|
|
ConfigCacheHelper::rebuild();
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 시스템을 최적화합니다 (캐시 생성).
|
|
*
|
|
* @return bool 최적화 성공 여부
|
|
*/
|
|
public function optimizeSystem(): bool
|
|
{
|
|
try {
|
|
Artisan::call('config:cache');
|
|
Artisan::call('route:cache');
|
|
Artisan::call('view:cache');
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 어플리케이션 키를 재생성합니다.
|
|
*
|
|
* @param string $password 최고 관리자 비밀번호
|
|
* @return array 결과 (success, app_key 또는 error)
|
|
*/
|
|
public function regenerateAppKey(string $password): array
|
|
{
|
|
$admin = Auth::user();
|
|
if (! $admin || ! Hash::check($password, $admin->password)) {
|
|
return ['success' => false, 'error' => __('settings.invalid_password')];
|
|
}
|
|
|
|
try {
|
|
$newKey = 'base64:'.base64_encode(random_bytes(32));
|
|
|
|
$envPath = base_path('.env');
|
|
$envContent = file_get_contents($envPath);
|
|
$envContent = preg_replace('/^APP_KEY=.*/m', 'APP_KEY='.$newKey, $envContent);
|
|
file_put_contents($envPath, $envContent);
|
|
|
|
// .env 변경 반영 + config 캐시 재생성 (clear 만 하면 캐시 비활성 상태로 잔존).
|
|
ConfigCacheHelper::rebuild();
|
|
|
|
return [
|
|
'success' => true,
|
|
'app_key' => $this->maskAppKey($newKey),
|
|
];
|
|
} catch (\Exception $e) {
|
|
return ['success' => false, 'error' => __('settings.app_key_regenerate_failed')];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 앱 키를 마스킹하여 반환합니다.
|
|
*
|
|
* @param string|null $key 마스킹할 키
|
|
* @return string 마스킹된 키 문자열
|
|
*/
|
|
public function maskAppKey(?string $key = null): string
|
|
{
|
|
$key = $key ?? config('app.key');
|
|
if (empty($key)) {
|
|
return '';
|
|
}
|
|
|
|
if (strlen($key) <= 20) {
|
|
return str_repeat('*', strlen($key));
|
|
}
|
|
|
|
return substr($key, 0, 12).str_repeat('*', 32);
|
|
}
|
|
|
|
/**
|
|
* 데이터베이스 연결 정보를 조회합니다.
|
|
*
|
|
* @return string 데이터베이스 정보 문자열
|
|
*/
|
|
private function getDatabaseInfo(): string
|
|
{
|
|
try {
|
|
$connection = DB::connection();
|
|
$driver = $connection->getDriverName();
|
|
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? __('common.unknown');
|
|
|
|
return ucfirst($driver).' '.$version;
|
|
} catch (\Throwable $e) {
|
|
Log::warning('시스템 정보 수집 실패', [
|
|
'item' => 'mysql_version',
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return __('common.unknown');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 서버의 물리 메모리 사용량을 조회합니다.
|
|
*
|
|
* 디스크 사용량과 동일한 형식(total/used/free/percentage)으로 반환합니다.
|
|
* OS 별 조회 방식:
|
|
* - Linux: /proc/meminfo 파싱 (MemTotal, MemAvailable)
|
|
* - Windows: PowerShell (Get-CimInstance Win32_OperatingSystem)
|
|
*
|
|
* @return array 메모리 사용량 정보 배열
|
|
*/
|
|
private function getMemoryUsage(): array
|
|
{
|
|
$total = 0;
|
|
$free = 0;
|
|
|
|
if (PHP_OS_FAMILY === 'Linux' && is_readable('/proc/meminfo')) {
|
|
$memInfo = @file_get_contents('/proc/meminfo');
|
|
if ($memInfo !== false) {
|
|
if (preg_match('/MemTotal:\s+(\d+)\s*kB/i', $memInfo, $m)) {
|
|
$total = (int) $m[1] * 1024;
|
|
}
|
|
if (preg_match('/MemAvailable:\s+(\d+)\s*kB/i', $memInfo, $m)) {
|
|
$free = (int) $m[1] * 1024;
|
|
} elseif (preg_match('/MemFree:\s+(\d+)\s*kB/i', $memInfo, $m)) {
|
|
$free = (int) $m[1] * 1024;
|
|
}
|
|
}
|
|
} elseif (PHP_OS_FAMILY === 'Windows') {
|
|
$script = '$os = Get-CimInstance Win32_OperatingSystem; '
|
|
.'Write-Output $os.TotalVisibleMemorySize; '
|
|
.'Write-Output $os.FreePhysicalMemory';
|
|
$output = @shell_exec('powershell -NoProfile -NonInteractive -Command "'.$script.'" 2>&1');
|
|
if ($output) {
|
|
$lines = array_values(array_filter(array_map('trim', explode("\n", $output)), fn ($l) => $l !== ''));
|
|
if (isset($lines[0], $lines[1]) && is_numeric($lines[0]) && is_numeric($lines[1])) {
|
|
$total = (int) $lines[0] * 1024;
|
|
$free = (int) $lines[1] * 1024;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($total <= 0) {
|
|
return $this->unknownUsage();
|
|
}
|
|
|
|
$used = max(0, $total - $free);
|
|
|
|
return [
|
|
'total' => $this->formatBytes($total),
|
|
'used' => $this->formatBytes($used),
|
|
'free' => $this->formatBytes($free),
|
|
'percentage' => round(($used / $total) * 100, 2),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 디스크 사용량 정보를 조회합니다.
|
|
*
|
|
* @return array 디스크 사용량 정보 배열
|
|
*/
|
|
private function getDiskUsage(): array
|
|
{
|
|
$path = PHP_OS_FAMILY === 'Windows' ? 'C:' : '/';
|
|
|
|
// open_basedir 제한 환경에서 disk_*_space 가 warning → ErrorException 이 되지 않도록
|
|
// @ 억제 + numeric 체크 (공개#40, getMemoryUsage 폴백 패턴 재사용).
|
|
$totalRaw = @disk_total_space($path);
|
|
$freeRaw = @disk_free_space($path);
|
|
$total = is_numeric($totalRaw) ? (int) $totalRaw : 0;
|
|
$free = is_numeric($freeRaw) ? (int) $freeRaw : 0;
|
|
|
|
if ($total <= 0) {
|
|
return $this->unknownUsage();
|
|
}
|
|
|
|
$used = max(0, $total - $free);
|
|
|
|
return [
|
|
'total' => $this->formatBytes($total),
|
|
'used' => $this->formatBytes($used),
|
|
'free' => $this->formatBytes($free),
|
|
'percentage' => round(($used / $total) * 100, 2),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 바이트 단위를 읽기 쉬운 형태로 변환합니다.
|
|
*
|
|
* @param int $bytes 변환할 바이트 수
|
|
* @return string 형식화된 문자열
|
|
*/
|
|
private function formatBytes(int $bytes): string
|
|
{
|
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
|
|
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
|
$bytes /= 1024;
|
|
}
|
|
|
|
return round($bytes, 2).' '.$units[$i];
|
|
}
|
|
|
|
/**
|
|
* CPU 정보를 조회합니다.
|
|
*
|
|
* Windows 11/Server 2025 이상에서는 `wmic`이 제거되어 PowerShell(CIM)을 우선 사용하고,
|
|
* 레거시 환경(wmic 존재)을 위해 폴백도 유지합니다.
|
|
*
|
|
* @return string CPU 정보 문자열
|
|
*/
|
|
protected function getCpuInfo(): string
|
|
{
|
|
if (PHP_OS_FAMILY === 'Windows') {
|
|
$output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1');
|
|
if ($output) {
|
|
$name = trim($output);
|
|
if ($name !== '' && ! str_contains(strtolower($name), 'error')) {
|
|
return $name;
|
|
}
|
|
}
|
|
|
|
$output = @shell_exec('wmic cpu get name 2>&1');
|
|
if ($output) {
|
|
$lines = explode("\n", trim($output));
|
|
if (isset($lines[1]) && trim($lines[1]) !== '') {
|
|
return trim($lines[1]);
|
|
}
|
|
}
|
|
|
|
return __('common.unknown');
|
|
}
|
|
|
|
if (file_exists('/proc/cpuinfo')) {
|
|
$cpuInfo = file_get_contents('/proc/cpuinfo');
|
|
if (preg_match('/model name\s*:\s*(.+)/i', $cpuInfo, $matches)) {
|
|
return trim($matches[1]);
|
|
}
|
|
}
|
|
|
|
return __('common.unknown');
|
|
}
|
|
|
|
/**
|
|
* 데이터베이스 연결 구성 정보를 조회합니다.
|
|
*
|
|
* Read/Write 분리 구성 여부를 확인하고 민감 정보(비밀번호)를 제외한
|
|
* 연결 정보를 반환합니다.
|
|
*
|
|
* @return array 데이터베이스 구성 정보 배열
|
|
*/
|
|
public function getDatabaseConfig(): array
|
|
{
|
|
$mysqlConfig = config('database.connections.mysql', []);
|
|
|
|
// Write 설정 (기본 연결 또는 write 키)
|
|
$writeConfig = $this->extractConnectionConfig($mysqlConfig, 'write');
|
|
|
|
// Read 설정 (다중 replica 지원)
|
|
$readConfigs = $this->extractReadConfigs($mysqlConfig);
|
|
|
|
// Read와 Write가 동일한 경우 Write Only로 취급
|
|
$readConfigs = $this->filterDuplicateReadConfigs($readConfigs, $writeConfig);
|
|
|
|
return [
|
|
'has_read_write_split' => ! empty($readConfigs),
|
|
'write' => $writeConfig,
|
|
'read' => $readConfigs,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Read 설정 중 Write와 동일한 설정을 필터링합니다.
|
|
*
|
|
* Read와 Write의 host, port, database, username이 모두 동일하면
|
|
* 실질적으로 Read-Write 분리가 아니므로 해당 Read 설정을 제외합니다.
|
|
*
|
|
* @param array $readConfigs Read replica 설정 배열
|
|
* @param array $writeConfig Write 설정
|
|
* @return array 중복 제거된 Read 설정 배열
|
|
*/
|
|
private function filterDuplicateReadConfigs(array $readConfigs, array $writeConfig): array
|
|
{
|
|
return array_values(array_filter($readConfigs, function ($read) use ($writeConfig) {
|
|
// host, port, database, username이 모두 동일하면 중복으로 간주
|
|
return ! (
|
|
$read['host'] === $writeConfig['host'] &&
|
|
$read['port'] === $writeConfig['port'] &&
|
|
$read['database'] === $writeConfig['database'] &&
|
|
$read['username'] === $writeConfig['username']
|
|
);
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 연결 설정에서 표시할 정보를 추출합니다.
|
|
*
|
|
* @param array $config 전체 MySQL 설정
|
|
* @param string $type 연결 타입 (write, read)
|
|
* @return array 표시할 연결 정보
|
|
*/
|
|
private function extractConnectionConfig(array $config, string $type): array
|
|
{
|
|
// write/read 키가 있으면 해당 설정 사용, 없으면 기본 설정 사용
|
|
$connectionConfig = $config[$type] ?? [];
|
|
|
|
// 배열의 첫 번째 요소인 경우 (다중 연결)
|
|
if (isset($connectionConfig[0]) && is_array($connectionConfig[0])) {
|
|
$connectionConfig = $connectionConfig[0];
|
|
}
|
|
|
|
// host 값 추출 (배열인 경우 첫 번째 요소 사용)
|
|
$host = $connectionConfig['host'] ?? $config['host'] ?? 'localhost';
|
|
if (is_array($host)) {
|
|
$host = $host[0] ?? 'localhost';
|
|
}
|
|
|
|
// port 값 추출 (배열인 경우 첫 번째 요소 사용)
|
|
$port = $connectionConfig['port'] ?? $config['port'] ?? 3306;
|
|
if (is_array($port)) {
|
|
$port = $port[0] ?? 3306;
|
|
}
|
|
|
|
// database 값 추출 (배열인 경우 첫 번째 요소 사용)
|
|
$database = $connectionConfig['database'] ?? $config['database'] ?? '';
|
|
if (is_array($database)) {
|
|
$database = $database[0] ?? '';
|
|
}
|
|
|
|
// username 값 추출 (배열인 경우 첫 번째 요소 사용)
|
|
$username = $connectionConfig['username'] ?? $config['username'] ?? '';
|
|
if (is_array($username)) {
|
|
$username = $username[0] ?? '';
|
|
}
|
|
|
|
return [
|
|
'host' => $host,
|
|
'port' => (int) $port,
|
|
'database' => $database,
|
|
'username' => $username,
|
|
// 비밀번호는 절대 포함하지 않음
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Read replica 설정을 추출합니다.
|
|
*
|
|
* 다중 Read replica를 지원하며, 각 replica의 민감 정보를 제외하고 반환합니다.
|
|
*
|
|
* @param array $config 전체 MySQL 설정
|
|
* @return array Read replica 정보 배열 (없으면 빈 배열)
|
|
*/
|
|
private function extractReadConfigs(array $config): array
|
|
{
|
|
$readConfig = $config['read'] ?? null;
|
|
|
|
if (empty($readConfig)) {
|
|
return [];
|
|
}
|
|
|
|
// 단일 read 설정인 경우 (host만 있는 경우)
|
|
if (isset($readConfig['host']) && ! is_array($readConfig['host'])) {
|
|
return [
|
|
[
|
|
'host' => $readConfig['host'],
|
|
'port' => (int) ($readConfig['port'] ?? $config['port'] ?? 3306),
|
|
'database' => $readConfig['database'] ?? $config['database'] ?? '',
|
|
'username' => $readConfig['username'] ?? $config['username'] ?? '',
|
|
],
|
|
];
|
|
}
|
|
|
|
// 다중 read replica 설정인 경우 (배열)
|
|
if (isset($readConfig[0]) && is_array($readConfig[0])) {
|
|
return array_map(function ($replica) use ($config) {
|
|
return [
|
|
'host' => $replica['host'] ?? $config['host'] ?? 'localhost',
|
|
'port' => (int) ($replica['port'] ?? $config['port'] ?? 3306),
|
|
'database' => $replica['database'] ?? $config['database'] ?? '',
|
|
'username' => $replica['username'] ?? $config['username'] ?? '',
|
|
];
|
|
}, $readConfig);
|
|
}
|
|
|
|
// host가 배열인 경우 (Laravel의 sticky read 방식)
|
|
if (isset($readConfig['host']) && is_array($readConfig['host'])) {
|
|
return array_map(function ($host) use ($readConfig, $config) {
|
|
return [
|
|
'host' => $host,
|
|
'port' => (int) ($readConfig['port'] ?? $config['port'] ?? 3306),
|
|
'database' => $readConfig['database'] ?? $config['database'] ?? '',
|
|
'username' => $readConfig['username'] ?? $config['username'] ?? '',
|
|
];
|
|
}, $readConfig['host']);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* PHP 확장 모듈 상태를 조회합니다.
|
|
*
|
|
* @return array PHP 확장 모듈 상태 배열
|
|
*/
|
|
public function getPhpExtensions(): array
|
|
{
|
|
$required = ['openssl', 'pdo', 'mbstring', 'tokenizer', 'xml', 'curl', 'json', 'zip', 'fileinfo', 'bcmath'];
|
|
$optional = ['gd', 'imagick', 'redis', 'memcached', 'sodium', 'exif', 'intl', 'ldap', 'zlib'];
|
|
|
|
$extensions = [
|
|
'required' => [],
|
|
'optional' => [],
|
|
];
|
|
|
|
foreach ($required as $ext) {
|
|
$extensions['required'][$ext] = extension_loaded($ext);
|
|
}
|
|
|
|
foreach ($optional as $ext) {
|
|
$extensions['optional'][$ext] = extension_loaded($ext);
|
|
}
|
|
|
|
return $extensions;
|
|
}
|
|
}
|