= nl2br(e($paragraph)) ?>
diff --git a/app/Repositories/AdminRepository.php b/app/Repositories/AdminRepository.php
index f07823d..1d30e31 100644
--- a/app/Repositories/AdminRepository.php
+++ b/app/Repositories/AdminRepository.php
@@ -64,13 +64,40 @@ final class AdminRepository
{
$statement = $this->db->prepare(
'SELECT posts.*,
- (SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path,
- (SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt
+ (SELECT file_path FROM post_images WHERE post_id = posts.id AND caption NOT LIKE \'@gtsit:inline:%\' ORDER BY sort_order, id LIMIT 1) AS image_path,
+ (SELECT alt_text FROM post_images WHERE post_id = posts.id AND caption NOT LIKE \'@gtsit:inline:%\' ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts WHERE posts.id = :id AND posts.deleted_at IS NULL LIMIT 1'
);
$statement->execute(['id' => $id]);
$post = $statement->fetch();
- return $post ?: null;
+ if (!$post) {
+ return null;
+ }
+
+ $media = $this->postMedia($id);
+ $post['gallery_images'] = $media['gallery'];
+ $post['inline_images'] = $media['inline'];
+ return $post;
+ }
+
+ public function postMedia(int $postId): array
+ {
+ $statement = $this->db->prepare(
+ 'SELECT id, file_path, alt_text, caption, sort_order
+ FROM post_images WHERE post_id = :post_id ORDER BY sort_order, id'
+ );
+ $statement->execute(['post_id' => $postId]);
+ $gallery = [];
+ $inline = [];
+ foreach ($statement->fetchAll() as $image) {
+ if (preg_match('/^@gtsit:inline:(\d+)$/', (string) $image['caption'], $matches) === 1) {
+ $image['token'] = (int) $matches[1];
+ $inline[] = $image;
+ continue;
+ }
+ $gallery[] = $image;
+ }
+ return ['gallery' => $gallery, 'inline' => $inline];
}
public function savePost(array $data, ?int $id = null): int
@@ -80,16 +107,20 @@ final class AdminRepository
: null;
if ($id === null) {
+ $temporarySlug = 'pending-' . bin2hex(random_bytes(12));
$statement = $this->db->prepare(
'INSERT INTO posts (title, slug, category, excerpt, body, author, status, published_at)
VALUES (:title, :slug, :category, :excerpt, :body, :author, :status, :published_at)'
);
$statement->execute([
- 'title'=>$data['title'],'slug'=>$data['slug'],'category'=>$data['category'],
+ 'title'=>$data['title'],'slug'=>$temporarySlug,'category'=>$data['category'],
'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'],
'status'=>$data['status'],'published_at'=>$publishedAt,
]);
- return (int) $this->db->lastInsertId();
+ $postId = (int) $this->db->lastInsertId();
+ $updateSlug = $this->db->prepare('UPDATE posts SET slug = :slug WHERE id = :id');
+ $updateSlug->execute(['slug' => 'post-' . $postId, 'id' => $postId]);
+ return $postId;
}
$statement = $this->db->prepare(
@@ -97,7 +128,7 @@ final class AdminRepository
body=:body, author=:author, status=:status, published_at=:published_at WHERE id=:id AND deleted_at IS NULL'
);
$statement->execute([
- 'title'=>$data['title'],'slug'=>$data['slug'],'category'=>$data['category'],
+ 'title'=>$data['title'],'slug'=>'post-' . $id,'category'=>$data['category'],
'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'],
'status'=>$data['status'],'published_at'=>$publishedAt,'id'=>$id,
]);
@@ -138,17 +169,84 @@ final class AdminRepository
}
}
- public function slugExists(string $slug, ?int $exceptId = null): bool
- {
- $sql = 'SELECT COUNT(*) FROM posts WHERE slug = :slug AND deleted_at IS NULL';
- $params = ['slug' => $slug];
- if ($exceptId !== null) {
- $sql .= ' AND id <> :id';
- $params['id'] = $exceptId;
+ public function savePostWithMedia(
+ array $data,
+ ?int $id,
+ array $galleryImages,
+ array $inlineImages,
+ array $deleteImageIds,
+ array $imageAlts
+ ): array {
+ $oldPaths = [];
+ $this->db->beginTransaction();
+ try {
+ $postId = $this->savePost($data, $id);
+ $existing = $this->db->prepare(
+ 'SELECT id, file_path, caption FROM post_images WHERE post_id = :post_id FOR UPDATE'
+ );
+ $existing->execute(['post_id' => $postId]);
+ $existingImages = $existing->fetchAll();
+ $existingIds = array_map('intval', array_column($existingImages, 'id'));
+
+ foreach ($imageAlts as $imageId => $altText) {
+ $imageId = (int) $imageId;
+ if (!in_array($imageId, $existingIds, true)) continue;
+ $update = $this->db->prepare(
+ 'UPDATE post_images SET alt_text = :alt_text WHERE id = :id AND post_id = :post_id'
+ );
+ $update->execute(['alt_text' => $altText, 'id' => $imageId, 'post_id' => $postId]);
+ }
+
+ $deleteIds = array_values(array_intersect($existingIds, array_map('intval', $deleteImageIds)));
+ if ($deleteIds !== []) {
+ foreach ($existingImages as $existingImage) {
+ if (in_array((int) $existingImage['id'], $deleteIds, true)) {
+ $oldPaths[] = (string) $existingImage['file_path'];
+ }
+ }
+ $placeholders = implode(',', array_fill(0, count($deleteIds), '?'));
+ $delete = $this->db->prepare("DELETE FROM post_images WHERE post_id = ? AND id IN ({$placeholders})");
+ $delete->execute([$postId, ...$deleteIds]);
+ }
+
+ $galleryOrder = 0;
+ foreach ($existingImages as $existingImage) {
+ if (!in_array((int) $existingImage['id'], $deleteIds, true)
+ && !str_starts_with((string) $existingImage['caption'], '@gtsit:inline:')) {
+ $galleryOrder++;
+ }
+ }
+ $insert = $this->db->prepare(
+ 'INSERT INTO post_images (post_id, file_path, alt_text, caption, sort_order)
+ VALUES (:post_id, :file_path, :alt_text, :caption, :sort_order)'
+ );
+ foreach ($galleryImages as $image) {
+ $insert->execute([
+ 'post_id' => $postId,
+ 'file_path' => $image['path'],
+ 'alt_text' => $image['alt'],
+ 'caption' => '@gtsit:gallery',
+ 'sort_order' => $galleryOrder++,
+ ]);
+ }
+ foreach ($inlineImages as $image) {
+ $insert->execute([
+ 'post_id' => $postId,
+ 'file_path' => $image['path'],
+ 'alt_text' => $image['alt'],
+ 'caption' => '@gtsit:inline:' . $image['token'],
+ 'sort_order' => 1000 + (int) $image['token'],
+ ]);
+ }
+
+ $this->db->commit();
+ return ['id' => $postId, 'old_paths' => $oldPaths];
+ } catch (\Throwable $exception) {
+ if ($this->db->inTransaction()) {
+ $this->db->rollBack();
+ }
+ throw $exception;
}
- $statement = $this->db->prepare($sql);
- $statement->execute($params);
- return (int) $statement->fetchColumn() > 0;
}
public function deletePost(int $id): void
diff --git a/app/Repositories/PostRepository.php b/app/Repositories/PostRepository.php
index 634bd45..21481ae 100644
--- a/app/Repositories/PostRepository.php
+++ b/app/Repositories/PostRepository.php
@@ -20,8 +20,8 @@ final class PostRepository
{
$statement = $this->db->prepare(
"SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.published_at,
- (SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path,
- (SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt
+ (SELECT file_path FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_path,
+ (SELECT alt_text FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts
WHERE posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW()
ORDER BY published_at DESC, id DESC
@@ -48,8 +48,8 @@ final class PostRepository
$offset = max(0, ($page - 1) * $perPage);
$query = $this->db->prepare(
"SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.published_at,
- (SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path,
- (SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt
+ (SELECT file_path FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_path,
+ (SELECT alt_text FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts WHERE {$where}
ORDER BY published_at DESC, id DESC
LIMIT :limit OFFSET :offset"
@@ -68,14 +68,34 @@ final class PostRepository
{
$statement = $this->db->prepare(
"SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.body, posts.author, posts.published_at,
- (SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path,
- (SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt
+ (SELECT file_path FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_path,
+ (SELECT alt_text FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts
WHERE posts.slug = :slug AND posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW()
LIMIT 1"
);
$statement->execute(['slug' => $slug]);
$post = $statement->fetch();
- return $post ?: null;
+ return $post ? $this->withMedia($post) : null;
+ }
+
+ public function withMedia(array $post): array
+ {
+ $statement = $this->db->prepare(
+ 'SELECT id, file_path, alt_text, caption, sort_order
+ FROM post_images WHERE post_id = :post_id ORDER BY sort_order, id'
+ );
+ $statement->execute(['post_id' => $post['id']]);
+ $post['gallery_images'] = [];
+ $post['inline_images'] = [];
+ foreach ($statement->fetchAll() as $image) {
+ if (preg_match('/^@gtsit:inline:(\d+)$/', (string) $image['caption'], $matches) === 1) {
+ $image['token'] = (int) $matches[1];
+ $post['inline_images'][(int) $matches[1]] = $image;
+ continue;
+ }
+ $post['gallery_images'][] = $image;
+ }
+ return $post;
}
}
diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php
new file mode 100644
index 0000000..31e7149
--- /dev/null
+++ b/app/Services/AnalyticsService.php
@@ -0,0 +1,253 @@
+propertyId = (string) config('app.analytics.property_id', '');
+ $this->credentialsPath = (string) config('app.analytics.credentials_path', '');
+ $this->cachePath = (string) config('app.analytics.cache_path', '');
+ $this->cacheTtl = max(60, (int) config('app.analytics.cache_ttl', 900));
+ }
+
+ public function dashboard(): array
+ {
+ $cache = $this->readCache();
+ if ($cache !== null && time() - (int) ($cache['cached_at'] ?? 0) < $this->cacheTtl) {
+ return $cache + ['available' => true, 'stale' => false];
+ }
+
+ try {
+ $credentials = $this->credentials();
+ $token = $this->accessToken($credentials);
+ $data = [
+ 'available' => true,
+ 'stale' => false,
+ 'cached_at' => time(),
+ 'realtime' => $this->realtime($token),
+ 'summary' => $this->summary($token),
+ 'all_time_users' => $this->allTimeUsers($token),
+ 'daily' => $this->daily($token),
+ 'pages' => $this->pages($token),
+ 'channels' => $this->channels($token),
+ ];
+ $this->writeCache($data);
+ return $data;
+ } catch (Throwable) {
+ if ($cache !== null) {
+ $cache['available'] = true;
+ $cache['stale'] = true;
+ return $cache;
+ }
+ return [
+ 'available' => false,
+ 'stale' => false,
+ 'message' => '방문 통계를 불러오지 못했습니다. 잠시 후 다시 확인해 주세요.',
+ ];
+ }
+ }
+
+ private function credentials(): array
+ {
+ if ($this->propertyId === '' || !is_file($this->credentialsPath) || !is_readable($this->credentialsPath)) {
+ throw new RuntimeException('Analytics configuration is unavailable.');
+ }
+ $credentials = json_decode((string) file_get_contents($this->credentialsPath), true);
+ if (!is_array($credentials) || empty($credentials['client_email']) || empty($credentials['private_key'])) {
+ throw new RuntimeException('Analytics credentials are invalid.');
+ }
+ return $credentials;
+ }
+
+ private function accessToken(array $credentials): string
+ {
+ $now = time();
+ $header = $this->base64Url(json_encode(['alg' => 'RS256', 'typ' => 'JWT'], JSON_THROW_ON_ERROR));
+ $claims = $this->base64Url(json_encode([
+ 'iss' => $credentials['client_email'],
+ 'scope' => 'https://www.googleapis.com/auth/analytics.readonly',
+ 'aud' => 'https://oauth2.googleapis.com/token',
+ 'iat' => $now,
+ 'exp' => $now + 3600,
+ ], JSON_THROW_ON_ERROR));
+ $unsigned = $header . '.' . $claims;
+ $signature = '';
+ if (!openssl_sign($unsigned, $signature, (string) $credentials['private_key'], OPENSSL_ALGO_SHA256)) {
+ throw new RuntimeException('Analytics token signing failed.');
+ }
+ $jwt = $unsigned . '.' . $this->base64Url($signature);
+ $response = $this->request('https://oauth2.googleapis.com/token', [
+ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ 'assertion' => $jwt,
+ ], true);
+ $token = (string) ($response['access_token'] ?? '');
+ if ($token === '') {
+ throw new RuntimeException('Analytics token was not issued.');
+ }
+ return $token;
+ }
+
+ private function realtime(string $token): int
+ {
+ $report = $this->report($token, 'runRealtimeReport', [
+ 'metrics' => [['name' => 'activeUsers']],
+ ]);
+ return (int) ($report['rows'][0]['metricValues'][0]['value'] ?? 0);
+ }
+
+ private function summary(string $token): array
+ {
+ $report = $this->report($token, 'runReport', [
+ 'dateRanges' => [['startDate' => '30daysAgo', 'endDate' => 'today']],
+ 'metrics' => array_map(static fn(string $name): array => ['name' => $name], [
+ 'activeUsers', 'newUsers', 'screenPageViews', 'sessions',
+ ]),
+ ]);
+ $values = $report['rows'][0]['metricValues'] ?? [];
+ return [
+ 'active_users' => (int) ($values[0]['value'] ?? 0),
+ 'new_users' => (int) ($values[1]['value'] ?? 0),
+ 'page_views' => (int) ($values[2]['value'] ?? 0),
+ 'sessions' => (int) ($values[3]['value'] ?? 0),
+ ];
+ }
+
+ private function allTimeUsers(string $token): int
+ {
+ $report = $this->report($token, 'runReport', [
+ 'dateRanges' => [['startDate' => '2026-08-12', 'endDate' => 'today']],
+ 'metrics' => [['name' => 'totalUsers']],
+ ]);
+ return (int) ($report['rows'][0]['metricValues'][0]['value'] ?? 0);
+ }
+
+ private function daily(string $token): array
+ {
+ $report = $this->report($token, 'runReport', [
+ 'dateRanges' => [['startDate' => '13daysAgo', 'endDate' => 'today']],
+ 'dimensions' => [['name' => 'date']],
+ 'metrics' => [['name' => 'activeUsers'], ['name' => 'screenPageViews']],
+ 'orderBys' => [['dimension' => ['dimensionName' => 'date']]],
+ ]);
+ return array_map(static function (array $row): array {
+ $date = (string) ($row['dimensionValues'][0]['value'] ?? '');
+ return [
+ 'date' => strlen($date) === 8 ? substr($date, 4, 2) . '.' . substr($date, 6, 2) : $date,
+ 'users' => (int) ($row['metricValues'][0]['value'] ?? 0),
+ 'views' => (int) ($row['metricValues'][1]['value'] ?? 0),
+ ];
+ }, $report['rows'] ?? []);
+ }
+
+ private function pages(string $token): array
+ {
+ $report = $this->report($token, 'runReport', [
+ 'dateRanges' => [['startDate' => '30daysAgo', 'endDate' => 'today']],
+ 'dimensions' => [['name' => 'pageTitle'], ['name' => 'pagePath']],
+ 'metrics' => [['name' => 'screenPageViews']],
+ 'orderBys' => [['metric' => ['metricName' => 'screenPageViews'], 'desc' => true]],
+ 'limit' => 5,
+ ]);
+ return array_map(static fn(array $row): array => [
+ 'title' => (string) ($row['dimensionValues'][0]['value'] ?? '(제목 없음)'),
+ 'path' => (string) ($row['dimensionValues'][1]['value'] ?? '/'),
+ 'views' => (int) ($row['metricValues'][0]['value'] ?? 0),
+ ], $report['rows'] ?? []);
+ }
+
+ private function channels(string $token): array
+ {
+ $report = $this->report($token, 'runReport', [
+ 'dateRanges' => [['startDate' => '30daysAgo', 'endDate' => 'today']],
+ 'dimensions' => [['name' => 'sessionDefaultChannelGroup']],
+ 'metrics' => [['name' => 'sessions'], ['name' => 'activeUsers']],
+ 'orderBys' => [['metric' => ['metricName' => 'sessions'], 'desc' => true]],
+ 'limit' => 5,
+ ]);
+ return array_map(static fn(array $row): array => [
+ 'channel' => (string) ($row['dimensionValues'][0]['value'] ?? 'Unassigned'),
+ 'sessions' => (int) ($row['metricValues'][0]['value'] ?? 0),
+ 'users' => (int) ($row['metricValues'][1]['value'] ?? 0),
+ ], $report['rows'] ?? []);
+ }
+
+ private function report(string $token, string $method, array $body): array
+ {
+ return $this->request(
+ 'https://analyticsdata.googleapis.com/v1beta/properties/' . rawurlencode($this->propertyId) . ':' . $method,
+ $body,
+ false,
+ $token
+ );
+ }
+
+ private function request(string $url, array $body, bool $form = false, string $token = ''): array
+ {
+ $handle = curl_init($url);
+ if ($handle === false) {
+ throw new RuntimeException('HTTP client initialization failed.');
+ }
+ $headers = ['Accept: application/json'];
+ $payload = $form ? http_build_query($body) : json_encode($body, JSON_THROW_ON_ERROR);
+ $headers[] = $form ? 'Content-Type: application/x-www-form-urlencoded' : 'Content-Type: application/json';
+ if ($token !== '') {
+ $headers[] = 'Authorization: Bearer ' . $token;
+ }
+ curl_setopt_array($handle, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_TIMEOUT => 12,
+ ]);
+ $raw = curl_exec($handle);
+ $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
+ $error = curl_error($handle);
+ curl_close($handle);
+ if (!is_string($raw) || $status < 200 || $status >= 300) {
+ throw new RuntimeException('Analytics API request failed: ' . ($error !== '' ? $error : (string) $status));
+ }
+ $decoded = json_decode($raw, true);
+ if (!is_array($decoded)) {
+ throw new RuntimeException('Analytics API response is invalid.');
+ }
+ return $decoded;
+ }
+
+ private function base64Url(string $value): string
+ {
+ return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
+ }
+
+ private function readCache(): ?array
+ {
+ if ($this->cachePath === '' || !is_file($this->cachePath) || !is_readable($this->cachePath)) {
+ return null;
+ }
+ $cache = json_decode((string) file_get_contents($this->cachePath), true);
+ return is_array($cache) ? $cache : null;
+ }
+
+ private function writeCache(array $data): void
+ {
+ $directory = dirname($this->cachePath);
+ if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
+ return;
+ }
+ file_put_contents($this->cachePath, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), LOCK_EX);
+ @chmod($this->cachePath, 0600);
+ }
+}
diff --git a/app/Views/admin/dashboard.php b/app/Views/admin/dashboard.php
index 4799b24..798cbd7 100644
--- a/app/Views/admin/dashboard.php
+++ b/app/Views/admin/dashboard.php
@@ -1 +1,38 @@
- 사이트 콘텐츠와 문의 현황입니다. 사이트 콘텐츠, 문의, 방문 현황입니다. 운영 사이트의 최근 30일 통계입니다. 관리자와 테스트 페이지는 제외됩니다. = e($analytics['message'] ?? '잠시 후 다시 확인해 주세요.') ?> 아직 집계된 방문 데이터가 없습니다. 아직 집계된 페이지가 없습니다. 아직 집계된 유입 데이터가 없습니다. 초안으로 저장하거나 공개 상태로 게시합니다.대시보드
대시보드
방문 현황
14일 페이지 열람 추이
열람 수많이 본 페이지
열람 수방문한 경로
방문 횟수 / 방문자= $post ? '글 수정' : '새 글 작성' ?>
블로그 글을 작성하고 공개 상태를 관리합니다.
| 제목 | 분류 | 상태 | 공개일 | 관리 |
|---|---|---|---|---|
| = e($post['title']) ?>/blog/= e($post['slug']) ?> | = e($post['category']) ?> | = $post['status'] === 'published' ? '공개' : '초안' ?> | = $post['published_at'] ? e(date('Y.m.d', strtotime($post['published_at']))) : '-' ?> | 수정 |
새 글을 작성해 콘텐츠를 추가해 주세요.
블로그 글을 작성하고 공개 상태를 관리합니다.
| 제목 | 분류 | 상태 | 공개일 | 관리 |
|---|---|---|---|---|
| = e($post['title']) ?>/blog/= e($post['slug']) ?> | = e($post['category']) ?> | = $post['status'] === 'published' ? '공개' : '초안' ?> | = $post['published_at'] ? e(date('Y.m.d', strtotime($post['published_at']))) : '-' ?> | 미리보기수정 |
새 글을 작성해 콘텐츠를 추가해 주세요.
= e($post['author']) ?> 기술팀
= nl2br(e($paragraph)) ?>
= e($post['author']) ?> 기술팀
= nl2br(e($paragraph)) ?>
+ += e($address) ?>
diff --git a/app/Views/layouts/admin.php b/app/Views/layouts/admin.php index 0305882..67d83f8 100644 --- a/app/Views/layouts/admin.php +++ b/app/Views/layouts/admin.php @@ -6,9 +6,10 @@대표번호 = e($phone) ?>
경기지점 = e($phoneSub) ?>
FAX = e($fax) ?>
이메일 = e($email) ?>
= e($legalName) ?>
사업자등록번호 = e($bizno) ?>
= e($address) ?>
| 구분 | 처리 목적 | 처리 항목 | 법적 근거 | 보유기간 |
|---|---|---|---|---|
| 온라인 상담 문의 | 상담 접수, 문의자 확인, 답변 제공 및 처리 이력 관리 | 필수: 이름 또는 회사명, 연락처, 문의 유형, 제목, 내용, 조회 PIN의 일방향 암호화값 선택: 이메일 자동 생성: 접수번호, 접수·동의·답변·처리 시각 | 정보주체의 동의 「개인정보 보호법」 제15조 제1항 제1호 | 문의 처리 종료 후 1년 |
| 온라인 상담 문의 | 상담 접수, 문의자 확인, 답변 제공 및 처리 이력 관리 | 필수: 이름 또는 회사명, 연락처, 문의 유형, 제목, 내용, 조회 PIN의 일방향 암호화값 선택: 이메일 자동 생성: 접수번호, 접수·동의·답변·처리 시각 | 정보주체의 동의 「개인정보 보호법」 제15조 제1항 제1호 | 문의 처리 종료 후 1년 |
| 웹사이트 이용 통계 | 방문 현황, 이용 경로, 콘텐츠 성과 분석 및 사이트 개선 | 쿠키 식별자, 방문·세션 정보, 페이지 URL·제목, 유입 경로, 브라우저·기기 정보, 대략적인 지역, 상호작용 시각 | 통계 쿠키와 국외 이전에 대한 정보주체의 선택 동의 | Google Analytics 사용자·이벤트 데이터 2개월 집계 보고서는 서비스 정책에 따른 기간 |
관계 법령에 따라 개인정보를 별도로 보존해야 하는 경우에는 해당 법령에서 정한 기간 동안 분리하여 보관합니다.
@@ -43,43 +47,57 @@ $email = site_setting('email', config('app.contact.email'));회사는 원활한 사이트 운영을 위해 다음 업무를 위탁하고 있으며, 위탁계약 등을 통해 개인정보가 안전하게 관리되도록 필요한 사항을 규정하고 있습니다.
-| 수탁업체 | 위탁업무 |
|---|---|
| 카페24(주) | 웹·데이터베이스 호스팅, 데이터 저장 및 백업을 위한 인프라 제공 |
| 수탁업체 | 위탁업무 |
|---|---|
| 카페24(주) | 웹·데이터베이스 호스팅, 데이터 저장 및 백업을 위한 인프라 제공 |
| Google LLC | 동의한 이용자의 Google Analytics 방문 통계 처리 |
수탁업체 또는 위탁업무가 변경되면 본 처리방침을 통해 공개합니다.
회사는 이용자가 통계 쿠키를 허용한 경우에만 다음과 같이 이용 정보를 국외로 이전합니다. 동의하지 않거나 이후 동의를 철회해도 웹사이트의 일반 기능 이용에는 제한이 없습니다.
+| 이전받는 자 | 이전 국가 | 이전 항목·목적 | 이전 시기·방법 | 보유·이용기간 |
|---|---|---|---|---|
| Google LLC 문의처 | 미국 및 Google 데이터센터가 위치한 국가 | 쿠키 식별자, 방문·세션 정보, 페이지 URL·제목, 유입 경로, 브라우저·기기 정보, 대략적인 지역, 상호작용 시각 방문 통계 분석 및 사이트 개선 | 동의 후 사이트 이용 시 암호화된 네트워크를 통해 이전 | 사용자·이벤트 데이터 2개월. 관계 법령이나 Google 서비스 정책에 따른 예외가 있는 경우 해당 기간 |
이용자는 최초 안내에서 “거부”를 선택하거나, 페이지 하단의 “통계 쿠키 설정”에서 동의를 철회할 수 있습니다.
+회사는 보유기간이 지나거나 처리 목적을 달성해 개인정보가 불필요하게 된 경우 지체 없이 파기합니다. 온라인 문의는 관리자가 처리 상태를 “종료”로 변경한 시점부터 1년간 보관한 뒤 데이터베이스에서 복구할 수 없도록 삭제합니다. 관계 법령에 따라 보존해야 하는 정보는 다른 개인정보와 분리하여 보관한 후 기간이 끝나면 파기합니다.
정보주체는 회사에 개인정보 열람, 정정·삭제, 처리정지 및 동의 철회를 요구할 수 있습니다. 아래 담당부서에 전화 또는 이메일로 요청하면 본인 확인 후 관련 법령에서 정한 절차에 따라 처리합니다. 법정대리인이나 위임받은 사람을 통해서도 권리를 행사할 수 있으며, 이 경우 위임장 등 정당한 대리권을 확인할 수 있는 자료를 요청할 수 있습니다.
회사는 개인정보의 분실·도난·유출·위조·변조 또는 훼손을 방지하기 위해 다음 조치를 시행합니다.
개인정보 침해에 관한 상담이나 피해 구제가 필요한 경우 다음 기관에 문의할 수 있습니다.
이 개인정보처리방침은 2026년 8월 5일부터 적용됩니다. 내용이 변경되는 경우 시행 전에 사이트를 통해 안내합니다.
-공고일자: 2026년 8월 5일
시행일자: 2026년 8월 5일
이 개인정보처리방침은 2026년 8월 12일부터 적용됩니다. 내용이 변경되는 경우 시행 전에 사이트를 통해 안내합니다.
+공고일자: 2026년 8월 12일
시행일자: 2026년 8월 12일