운영 기능 및 GA4 대시보드 반영

This commit is contained in:
박성필
2026-08-18 10:53:58 +09:00
parent 612e87847c
commit b492abd3b4
24 changed files with 1521 additions and 77 deletions

View File

@@ -10,9 +10,11 @@ use App\Repositories\InquiryRepository;
use App\Repositories\AdminRepository;
use App\Auth\AdminAuth;
use App\Repositories\SiteSettingRepository;
use App\Services\AnalyticsService;
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$basePath = (string) config('app.base_path', '');
$mediaExperiment = $basePath !== '';
if ($basePath !== '') {
if ($path !== $basePath && !str_starts_with($path, $basePath . '/')) {
http_response_code(404);
@@ -68,7 +70,12 @@ if ($path === '/blog' && $method === 'GET') {
}
if (preg_match('#^/blog/([a-z0-9-]+)$#', $path, $matches) === 1 && $method === 'GET') {
$post = $postRepository?->findPublishedBySlug($matches[1]);
$requestedSlug = $matches[1];
$slugRedirects = config('post_slug_redirects', []);
if (is_array($slugRedirects) && isset($slugRedirects[$requestedSlug])) {
redirect('/blog/' . $slugRedirects[$requestedSlug], 301);
}
$post = $postRepository?->findPublishedBySlug($requestedSlug);
if ($post === null) {
http_response_code(404);
render('errors/404', ['title' => '게시글을 찾을 수 없습니다']);
@@ -346,20 +353,48 @@ if (str_starts_with($path, '/admin')) {
}
if ($path === '/admin' && $method === 'GET') {
render('admin/dashboard', ['title'=>'대시보드','counts'=>$adminRepository->dashboardCounts()], 'layouts/admin');
$analytics = (new AnalyticsService())->dashboard();
render('admin/dashboard', ['title'=>'대시보드','counts'=>$adminRepository->dashboardCounts(),'analytics'=>$analytics], 'layouts/admin');
exit;
}
if ($path === '/admin/posts' && $method === 'GET') {
render('admin/posts/index', ['title'=>'글 관리','posts'=>$adminRepository->posts()], 'layouts/admin');
exit;
}
if (preg_match('#^/admin/posts/(\d+)/preview$#', $path, $adminPreviewMatch) === 1 && $method === 'GET') {
$post = $adminRepository->findPost((int) $adminPreviewMatch[1]);
if (!$post) {
http_response_code(404);
render('errors/404', ['title'=>'게시글을 찾을 수 없습니다'], 'layouts/admin');
exit;
}
render('blog/show', [
'title'=>'미리보기 · ' . $post['title'],
'description'=>$post['excerpt'] ?: '관리자 게시글 미리보기',
'post'=>$post,
'preview'=>true,
'mediaExperiment'=>$mediaExperiment,
'noindex'=>true,
'canonicalUrl'=>rtrim((string) config('app.base_url'), '/') . '/blog/' . $post['slug'],
]);
exit;
}
if ($path === '/admin/posts/new' && $method === 'GET') {
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>[]], 'layouts/admin');
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>[],'mediaExperiment'=>$mediaExperiment], 'layouts/admin');
exit;
}
if ($path === '/admin/posts/new' && $method === 'POST') {
verify_csrf();
$postData = collect_post_input();
if ($mediaExperiment) {
$result = save_media_post_request($adminRepository, $postData, null, null);
if ($result['errors']) {
http_response_code(422);
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>$result['errors'],'mediaExperiment'=>true], 'layouts/admin');
exit;
}
redirect('/admin/posts/' . $result['id'] . '/preview');
}
$errors = validate_post_input($postData, $adminRepository);
[$image, $imageError] = prepare_post_image($_FILES['image'] ?? null);
if ($imageError !== null) $errors[] = $imageError;
@@ -390,12 +425,22 @@ if (str_starts_with($path, '/admin')) {
exit;
}
if ($method === 'GET') {
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>[]], 'layouts/admin');
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>[],'mediaExperiment'=>$mediaExperiment], 'layouts/admin');
exit;
}
if ($method === 'POST') {
verify_csrf();
$postData = collect_post_input();
if ($mediaExperiment) {
$result = save_media_post_request($adminRepository, $postData, $post, $postId);
if ($result['errors']) {
$post = $adminRepository->findPost($postId) ?? $post;
http_response_code(422);
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>$result['errors'],'mediaExperiment'=>true], 'layouts/admin');
exit;
}
redirect('/admin/posts/' . $postId . '/preview');
}
$errors = validate_post_input($postData, $adminRepository, $postId);
[$image, $imageError] = prepare_post_image($_FILES['image'] ?? null);
if ($imageError !== null) $errors[] = $imageError;
@@ -515,8 +560,6 @@ function validate_post_input(array $data, AdminRepository $repository, ?int $exc
{
$errors = [];
if ($data['title']==='' || mb_strlen($data['title'])>200) $errors[]='제목을 200자 이내로 입력해 주세요.';
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $data['slug'])) $errors[]='slug는 영문 소문자, 숫자, 하이픈으로 입력해 주세요.';
elseif ($repository->slugExists($data['slug'], $exceptId)) $errors[]='이미 사용 중인 slug입니다.';
if (!in_array($data['category'], ['네트워크','키폰시스템','CCTV','시공사례'], true)) $errors[]='카테고리를 확인해 주세요.';
if (mb_strlen($data['excerpt'])>500) $errors[]='목록 요약을 500자 이내로 입력해 주세요.';
if ($data['body']==='') $errors[]='본문을 입력해 주세요.';
@@ -526,14 +569,140 @@ function validate_post_input(array $data, AdminRepository $repository, ?int $exc
return $errors;
}
function prepare_post_image(?array $file): array
function save_media_post_request(AdminRepository $repository, array $postData, ?array $post, ?int $postId): array
{
$deleteIds = array_values(array_unique(array_filter(array_map('intval', (array) ($_POST['delete_images'] ?? [])))));
$existingGallery = $post['gallery_images'] ?? [];
$existingInline = $post['inline_images'] ?? [];
$existingById = [];
foreach ([...$existingGallery, ...$existingInline] as $image) {
$existingById[(int) $image['id']] = $image;
}
$deleteIds = array_values(array_filter($deleteIds, static fn (int $id): bool => isset($existingById[$id])));
foreach ($existingInline as $image) {
if (in_array((int) $image['id'], $deleteIds, true)) {
$postData['body'] = preg_replace(
'/(?:\R{2,})?\[\[image:' . preg_quote((string) $image['token'], '/') . '\]\](?:\R{2,})?/',
"\n\n",
$postData['body']
) ?? $postData['body'];
}
}
$postData['body'] = trim($postData['body']);
$_POST['body'] = $postData['body'];
$errors = validate_post_input($postData, $repository, $postId);
[$galleryUploads, $galleryErrors] = prepare_post_images($_FILES['gallery_images'] ?? null, '대표 이미지');
[$inlineUploads, $inlineErrors] = prepare_post_images($_FILES['inline_images'] ?? null, '본문 이미지');
$errors = [...$errors, ...$galleryErrors, ...$inlineErrors];
$newGalleryAlts = array_map(static fn ($value): string => trim((string) $value), (array) ($_POST['new_gallery_alts'] ?? []));
$newInlineAlts = array_map(static fn ($value): string => trim((string) $value), (array) ($_POST['new_inline_alts'] ?? []));
$submittedInlineTokens = array_map(static fn ($value): string => trim((string) $value), (array) ($_POST['new_inline_tokens'] ?? []));
$newInlineTokens = [];
foreach ($submittedInlineTokens as $token) {
if (preg_match('/^[1-9]\d{0,2}$/D', $token) !== 1) {
$errors[] = '본문 이미지 위치 정보를 다시 확인해 주세요.';
continue;
}
$newInlineTokens[] = (int) $token;
}
if (count($newGalleryAlts) !== count($galleryUploads)) $errors[] = '선택한 대표 이미지 정보를 다시 확인해 주세요.';
if (count($newInlineAlts) !== count($inlineUploads)) $errors[] = '선택한 본문 이미지 정보를 다시 확인해 주세요.';
if (count($newInlineTokens) !== count($inlineUploads)) $errors[] = '선택한 본문 이미지의 위치 정보를 다시 확인해 주세요.';
foreach ($newGalleryAlts as $alt) if ($alt === '' || mb_strlen($alt) > 255) $errors[] = '새 대표 이미지의 대체 텍스트를 255자 이내로 입력해 주세요.';
foreach ($newInlineAlts as $alt) if ($alt === '' || mb_strlen($alt) > 255) $errors[] = '새 본문 이미지의 대체 텍스트를 255자 이내로 입력해 주세요.';
$imageAlts = [];
foreach ((array) ($_POST['media_alt'] ?? []) as $imageId => $alt) {
$imageId = (int) $imageId;
if (!isset($existingById[$imageId]) || in_array($imageId, $deleteIds, true)) continue;
$alt = trim((string) $alt);
if ($alt === '' || mb_strlen($alt) > 255) $errors[] = '등록된 이미지의 대체 텍스트를 255자 이내로 입력해 주세요.';
$imageAlts[$imageId] = $alt;
}
$remainingGallery = count(array_filter($existingGallery, static fn (array $image): bool => !in_array((int) $image['id'], $deleteIds, true)));
$remainingInlineImages = array_values(array_filter($existingInline, static fn (array $image): bool => !in_array((int) $image['id'], $deleteIds, true)));
$remainingInline = count($remainingInlineImages);
$totalGallery = $remainingGallery + count($galleryUploads);
$totalInline = $remainingInline + count($inlineUploads);
if ($totalGallery > 6) $errors[] = '대표 이미지는 최대 6장까지 등록할 수 있습니다.';
if ($totalInline > 10) $errors[] = '본문 이미지는 최대 10장까지 등록할 수 있습니다.';
if (($totalGallery > 1 || $totalInline > 0) && $postData['status'] !== 'draft') {
$errors[] = '다중·본문 이미지 기능은 테스트 중이므로 초안으로만 저장할 수 있습니다.';
}
$activeTokens = array_map(static fn (array $image): int => (int) $image['token'], $remainingInlineImages);
foreach ($newInlineTokens as $token) {
if (in_array($token, $activeTokens, true)) $errors[] = '본문 이미지 위치 정보가 중복되었습니다.';
$activeTokens[] = $token;
}
if (count($activeTokens) !== count(array_unique($activeTokens))) $errors[] = '본문 이미지 위치 정보가 중복되었습니다.';
preg_match_all('/\[\[image:(\d+)\]\]/', $postData['body'], $bodyTokenMatches);
$bodyTokens = array_map('intval', $bodyTokenMatches[1] ?? []);
foreach ($activeTokens as $token) {
if (count(array_keys($bodyTokens, $token, true)) !== 1) $errors[] = '각 본문 이미지는 글 안에 한 번씩 배치해 주세요.';
}
foreach ($bodyTokens as $token) {
if (!in_array($token, $activeTokens, true)) $errors[] = '연결되지 않은 본문 이미지 위치가 있습니다.';
}
if ($errors !== []) return ['id' => $postId, 'errors' => array_values(array_unique($errors))];
$storedPaths = [];
$galleryImages = [];
$inlineImages = [];
try {
foreach ($galleryUploads as $index => $image) {
$path = store_post_image($image);
$storedPaths[] = $path;
$galleryImages[] = ['path' => $path, 'alt' => $newGalleryAlts[$index]];
}
foreach ($inlineUploads as $index => $image) {
$path = store_post_image($image);
$storedPaths[] = $path;
$inlineImages[] = ['path' => $path, 'alt' => $newInlineAlts[$index], 'token' => $newInlineTokens[$index]];
}
$result = $repository->savePostWithMedia($postData, $postId, $galleryImages, $inlineImages, $deleteIds, $imageAlts);
foreach ($result['old_paths'] as $oldPath) remove_stored_image((string) $oldPath);
return ['id' => $result['id'], 'errors' => []];
} catch (Throwable) {
foreach ($storedPaths as $storedPath) remove_stored_image($storedPath);
return ['id' => $postId, 'errors' => ['저장하지 못했습니다. 잠시 후 다시 시도해 주세요.']];
}
}
function prepare_post_images(?array $files, string $label): array
{
if ($files === null || !isset($files['name'], $files['error'], $files['size'], $files['tmp_name'])) return [[], []];
$names = is_array($files['name']) ? $files['name'] : [$files['name']];
$errors = is_array($files['error']) ? $files['error'] : [$files['error']];
$sizes = is_array($files['size']) ? $files['size'] : [$files['size']];
$temporaryNames = is_array($files['tmp_name']) ? $files['tmp_name'] : [$files['tmp_name']];
$prepared = [];
$messages = [];
foreach ($names as $index => $name) {
if (($errors[$index] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue;
[$image, $error] = prepare_post_image([
'name' => $name,
'error' => $errors[$index] ?? UPLOAD_ERR_NO_FILE,
'size' => $sizes[$index] ?? 0,
'tmp_name' => $temporaryNames[$index] ?? '',
], $label);
if ($error !== null) $messages[] = $error;
if ($image !== null) $prepared[] = $image;
}
return [$prepared, $messages];
}
function prepare_post_image(?array $file, string $label = '대표 이미지'): array
{
if ($file === null || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return [null, null];
if (!isset($file['error'], $file['size'], $file['tmp_name']) || is_array($file['error']) || is_array($file['size']) || is_array($file['tmp_name'])) {
return [null, '이미지 업로드 요청이 올바르지 않습니다.'];
}
if ((int) $file['error'] !== UPLOAD_ERR_OK) return [null, '이미지를 업로드하지 못했습니다. 파일 크기를 확인해 주세요.'];
if ((int) $file['size'] <= 0 || (int) $file['size'] > 8 * 1024 * 1024) return [null, '대표 이미지는 8MB 이하만 등록할 수 있습니다.'];
if ((int) $file['size'] <= 0 || (int) $file['size'] > 8 * 1024 * 1024) return [null, $label . '는 파일당 8MB 이하만 등록할 수 있습니다.'];
$tmpName = (string) $file['tmp_name'];
if (!is_uploaded_file($tmpName)) return [null, '업로드된 이미지 파일을 확인할 수 없습니다.'];