Initial commit: 교육 프로젝트 배포

This commit is contained in:
송대일
2026-07-01 18:32:42 +09:00
commit be6dccd120
1483 changed files with 5082202 additions and 0 deletions
+419
View File
@@ -0,0 +1,419 @@
<?php
/**
* bbs/main_data.php
* ─────────────────────────────────────────────────────────────────
* 메인 페이지(skin/index.php) 용 데이터 조회 백엔드
*
* 이 파일은 직접 호출하지 않고 skin/index.php 에서 require_once 로 사용한다.
* 실행 후 아래 변수가 설정된다:
*
* $userName string 사용자 이름
* $userRank string 직위
* $myKeywords array 내 키워드 [ ['keyword_code'=>..., 'keyword_name'=>...], ... ] (max 3)
* $adminKeywords array 관리자 추천 키워드 (max 2)
* $allKeywords array 모달용 전체 키워드 목록
* $firstPageVideos array 메인 6개 영상 배열 (JS video 객체 형태)
* $totalMin int 총 학습시간(분)
* $videosJson string JSON 직렬화 영상 배열
* $myKwJson string JSON 직렬화 내 키워드명 배열
* ─────────────────────────────────────────────────────────────────
*/
require_once __DIR__ . '/db_conn.php';
require_once __DIR__ . '/api_log.php';
// ════════════════════════════════════════════════════════════════
// 현재 사용자 식별
// TODO: 실제 세션 로그인 구현 후 아래 두 줄을 세션으로 교체
// $memberId = $_SESSION['member_id'] ?? null;
// $sysCompCode = $_SESSION['sys_comp_code'] ?? null;
// ════════════════════════════════════════════════════════════════
$memberId = 'U001';
$sysCompCode = 'COMP01';
// ────────────────────────────────────────────────────────────────
// 카테고리/서브카테고리 코드 → 한글 변환 맵 (DB 조회 전 fallback 기본값)
// DB에서 edu_codes 조회 후 아래 값은 덮어쓰여짐
// ────────────────────────────────────────────────────────────────
$CATEGORY_MAP = [
'CA10001' => '마이클래스',
'CA10002' => '온보딩',
'CA10003' => '법정교육',
'CA10004' => '리더십',
'CA10005' => '인사이트',
'CA10006' => '비즈트렌드',
];
$SUBCATE_MAP = [];
/**
* contents 테이블 한 행을 JS video 객체 형태 배열로 변환
*
* @param array $row PDO fetchAll 행 데이터
* @param string $type 카드 타입 (main / comment / onboarding 등)
* @param string $picker Pick 배지에 표시할 이름 (없으면 '')
* @return array
*/
function mapContentRow(array $row, string $type = 'main', string $picker = '', array $catMap = [], array $subcateMap = []): array
{
// GROUP_CONCAT 결과 키워드 문자열 → 배열
$kwStr = $row['keywords'] ?? '';
$keywords = $kwStr !== '' ? explode(',', $kwStr) : [];
// 학습 진행률 (watch_tm / content_tm × 100, 0~100 클램프)
$watchTm = (float)($row['watch_tm'] ?? 0);
$contentTm = (float)($row['content_tm'] ?? 0);
$gauge = ($contentTm > 0) ? (int)round($watchTm / $contentTm * 100) : 0;
$gauge = min(100, max(0, $gauge));
// content_url: 풀URL(https://www.youtube.com/watch?v=ID) 또는 영상ID만 저장된 경우 모두 지원
$raw = trim($row['content_url'] ?? '');
if (preg_match('/(?:v=|youtu\.be\/)([A-Za-z0-9_-]{11})/', $raw, $m)) {
$videoId = $m[1]; // 풀 URL에서 ID 추출
} elseif (preg_match('/^[A-Za-z0-9_-]{11}$/', $raw)) {
$videoId = $raw; // 정확히 11자리 유효한 YouTube ID
} else {
$videoId = ''; // 유효하지 않은 값 (example02, 빈값 등)
}
// thumbnail_url 컬럼이 있으면 우선 사용, 없으면 YouTube API로 생성
$thumbFromDb = trim($row['thumbnail_url'] ?? '');
$url = $videoId !== '' ? "https://www.youtube.com/watch?v={$videoId}" : '';
$thumbnail = $thumbFromDb !== '' ? $thumbFromDb
: ($videoId !== '' ? "https://img.youtube.com/vi/{$videoId}/sddefault.jpg" : '');
// 카테고리 코드 → 한글
$categoryCode = $row['category_code'] ?? '';
$category = $catMap[$categoryCode] ?? $categoryCode;
// 서브카테고리 코드 → 한글
$groupCode = $row['category_group'] ?? '';
$subcate = $subcateMap[$groupCode] ?? $groupCode;
return [
'id' => $row['content_id'],
'url' => $url,
'thumbnail' => $thumbnail,
'category' => $category,
'category_code' => $categoryCode,
'subcate' => $subcate,
'bookmark' => (bool)($row['is_bookmarked'] ?? false),
'title' => $row['title'] ?? '',
'picker' => $picker,
'type' => $type,
'keywords' => $keywords,
'gauge' => $gauge,
'watch_tm' => (int)($row['watch_tm'] ?? 0),
'content_tm' => (int)($row['content_tm'] ?? 0),
];
}
// ════════════════════════════════════════════════════════════════
// DB 데이터 조회
// ════════════════════════════════════════════════════════════════
$dbSuccess = false;
try {
$pdo = db_conn();
$pdo->exec("SET NAMES 'utf8mb4'");
// ── 0. edu_codes에서 카테고리/서브카테고리 맵 동적 빌드 ──────
$codesRows = $pdo->query("
SELECT group_code, code, code_name
FROM edu_codes
WHERE is_active = 1
AND group_code IN ('CA100', 'CA200')
")->fetchAll();
foreach ($codesRows as $cr) {
$key = $cr['group_code'] . $cr['code']; // e.g. CA10001, CA200L01
if ($cr['group_code'] === 'CA100') {
$CATEGORY_MAP[$key] = $cr['code_name'];
} else {
$SUBCATE_MAP[$key] = $cr['code_name'];
}
}
// ── 1. 사용자 기본 정보 ─────────────────────────────────────
$stmtUser = $pdo->prepare("
SELECT name, rank_name
FROM edu_users
WHERE member_id = :mid AND sys_comp_code = :comp
LIMIT 1
");
$stmtUser->execute([':mid' => $memberId, ':comp' => $sysCompCode]);
$userRow = $stmtUser->fetch();
$userName = $userRow['name'] ?? '사용자';
$userRank = $userRow['rank_name'] ?? '';
// ── 2. 내 키워드 (최대 3개) ──────────────────────────────────
$stmtMyKw = $pdo->prepare("
SELECT keyword_code, keyword_name
FROM edu_user_keywords
WHERE member_id = :mid AND sys_comp_code = :comp
ORDER BY created_at DESC
LIMIT 3
");
$stmtMyKw->execute([':mid' => $memberId, ':comp' => $sysCompCode]);
$myKeywords = $stmtMyKw->fetchAll();
// ── 3. 관리자(법인) 추천 키워드 (최대 2개) ──────────────────
$stmtAdminKw = $pdo->prepare("
SELECT rk.keyword_code,
COALESCE(c.code_name, rk.keyword_code) AS keyword_name
FROM edu_recommend_keywords rk
LEFT JOIN edu_codes c
ON c.group_code = 'KW100'
AND c.code = rk.keyword_code
WHERE rk.sys_comp_code = :comp
AND rk.is_active = 1
ORDER BY rk.created_at DESC
LIMIT 2
");
$stmtAdminKw->execute([':comp' => $sysCompCode]);
$adminKeywords = $stmtAdminKw->fetchAll();
// ── 4. 모달용 전체 키워드 목록 ──────────────────────────────
$stmtAllKw = $pdo->query("
SELECT code AS keyword_code, code_name AS keyword_name
FROM edu_codes
WHERE group_code = 'KW100' AND is_active = 1
ORDER BY code_name
");
$allKeywords = $stmtAllKw ? $stmtAllKw->fetchAll() : [];
// DB에 코드 데이터 없으면 하드코딩 fallback
if (empty($allKeywords)) {
$fallbackKwNames = [
'온보딩','성장','코칭','인물','소통','협업','AI','IT테크',
'중간관리자','리더십','팔로우십','동기부여','인간관계','스킬업',
'피드백','커리어','경영','경제','마인드셋','웰니스','자기개발',
];
$allKeywords = array_map(
fn($k) => ['keyword_code' => $k, 'keyword_name' => $k],
$fallbackKwNames
);
}
// ── 5. 영상 슬롯 조합 ────────────────────────────────────────
// [0] Pick (좌상단 고정 - 다른 사람이 추천한 영상)
// [1] 관리자 키워드 영상 (좌중단)
// [2] 관리자 키워드 영상 (좌하단)
// [3] 내 키워드 영상 (우상단)
// [4] 내 키워드 영상 (우중단)
// [5] 내 키워드 영상 (우하단)
$myKwCodes = array_column($myKeywords, 'keyword_code');
$adminKwCodes = array_column($adminKeywords, 'keyword_code');
// 5-1. Pick 영상 (다른 사람이 제안, is_offer=1, 본인 제외)
$stmtPick = $pdo->prepare("
SELECT c.*,
NULL AS watch_tm,
NULL AS content_tm,
0 AS is_bookmarked,
GROUP_CONCAT(ck.keyword_code ORDER BY ck.keyword_code SEPARATOR ',') AS keywords,
u.name AS picker_name
FROM edu_contents c
LEFT JOIN edu_content_keywords ck ON ck.content_id = c.content_id
LEFT JOIN edu_content_offer co ON co.offer_id = c.offer_id
LEFT JOIN edu_users u ON u.member_id = co.member_id
AND u.sys_comp_code = co.sys_comp_code
WHERE c.is_offer = 1
AND c.is_active = 1
AND (co.member_id IS NULL OR co.member_id != :mid)
GROUP BY c.content_id
ORDER BY c.sort_order, RAND()
LIMIT 1
");
$stmtPick->execute([':mid' => $memberId]);
$pickRow = $stmtPick->fetch();
$pickVideo = $pickRow
? mapContentRow($pickRow, 'main', $pickRow['picker_name'] ?? '동료', $CATEGORY_MAP, $SUBCATE_MAP)
: null;
// 5-2. 관리자 키워드 관련 영상 (2개)
$adminVideos = [];
if (!empty($adminKwCodes)) {
$ph = implode(',', array_fill(0, count($adminKwCodes), '?'));
$stmtAdminV = $pdo->prepare("
SELECT c.*,
lh.watch_tm,
lh.content_tm,
CASE WHEN cw.content_id IS NOT NULL THEN 1 ELSE 0 END AS is_bookmarked,
GROUP_CONCAT(ck.keyword_code ORDER BY ck.keyword_code SEPARATOR ',') AS keywords
FROM edu_contents c
JOIN edu_content_keywords ck ON ck.content_id = c.content_id
AND ck.keyword_code IN ({$ph})
LEFT JOIN edu_learning_histories lh ON lh.content_id = c.content_id
AND lh.member_id = ?
AND lh.sys_comp_code = ?
LEFT JOIN edu_content_wishlist cw ON cw.content_id = c.content_id
AND cw.member_id = ?
AND cw.sys_comp_code = ?
AND cw.is_active = 1
WHERE c.is_active = 1
GROUP BY c.content_id
ORDER BY RAND()
LIMIT 2
");
$stmtAdminV->execute(array_merge(
$adminKwCodes,
[$memberId, $sysCompCode, $memberId, $sysCompCode]
));
foreach ($stmtAdminV->fetchAll() as $r) {
$adminVideos[] = mapContentRow($r, 'main', '', $CATEGORY_MAP, $SUBCATE_MAP);
}
}
// 5-3. 내 키워드 관련 영상 (3개)
$myVideos = [];
if (!empty($myKwCodes)) {
$ph2 = implode(',', array_fill(0, count($myKwCodes), '?'));
$stmtMyV = $pdo->prepare("
SELECT c.*,
lh.watch_tm,
lh.content_tm,
CASE WHEN cw.content_id IS NOT NULL THEN 1 ELSE 0 END AS is_bookmarked,
GROUP_CONCAT(ck.keyword_code ORDER BY ck.keyword_code SEPARATOR ',') AS keywords
FROM edu_contents c
JOIN edu_content_keywords ck ON ck.content_id = c.content_id
AND ck.keyword_code IN ({$ph2})
LEFT JOIN edu_learning_histories lh ON lh.content_id = c.content_id
AND lh.member_id = ?
AND lh.sys_comp_code = ?
LEFT JOIN edu_content_wishlist cw ON cw.content_id = c.content_id
AND cw.member_id = ?
AND cw.sys_comp_code = ?
AND cw.is_active = 1
WHERE c.is_active = 1
GROUP BY c.content_id
ORDER BY RAND()
LIMIT 3
");
$stmtMyV->execute(array_merge(
$myKwCodes,
[$memberId, $sysCompCode, $memberId, $sysCompCode]
));
foreach ($stmtMyV->fetchAll() as $r) {
$myVideos[] = mapContentRow($r, 'main', '', $CATEGORY_MAP, $SUBCATE_MAP);
}
}
// 5-4. 6 슬롯 최종 조합
// ① 키워드 매칭으로 채워진 영상 목록
$slots = [
$pickVideo,
$adminVideos[0] ?? null,
$adminVideos[1] ?? null,
$myVideos[0] ?? null,
$myVideos[1] ?? null,
$myVideos[2] ?? null,
];
$usedIds = array_values(array_filter(array_map(fn($s) => $s ? $s['id'] : null, $slots)));
$slotsLeft = 6 - count(array_filter($slots, fn($s) => $s !== null));
// ② 부족한 슬롯 → edu_contents 에서 직접 조회 (키워드·is_offer 무관, 랜덤)
// 키워드를 선택하지 않아도 항상 DB 영상이 표시되도록 함
$extraVideos = [];
if ($slotsLeft > 0) {
$excClause = !empty($usedIds)
? 'AND c.content_id NOT IN (' . implode(',', array_fill(0, count($usedIds), '?')) . ')'
: '';
$stmtExtra = $pdo->prepare("
SELECT c.*,
lh.watch_tm,
lh.content_tm,
0 AS is_bookmarked,
GROUP_CONCAT(ck.keyword_code ORDER BY ck.keyword_code SEPARATOR ',') AS keywords
FROM edu_contents c
LEFT JOIN edu_learning_histories lh ON lh.content_id = c.content_id
AND lh.member_id = ?
AND lh.sys_comp_code = ?
LEFT JOIN edu_content_keywords ck ON ck.content_id = c.content_id
WHERE c.is_active = 1
{$excClause}
GROUP BY c.content_id
ORDER BY c.sort_order, RAND()
LIMIT {$slotsLeft}
");
$bindParams = [$memberId, $sysCompCode];
if (!empty($usedIds)) {
$bindParams = array_merge($bindParams, $usedIds);
}
$stmtExtra->execute($bindParams);
foreach ($stmtExtra->fetchAll() as $r) {
$extraVideos[] = mapContentRow($r, 'main', '', $CATEGORY_MAP, $SUBCATE_MAP);
}
}
// ③ 슬롯 순서대로 채우기: 키워드 영상 → DB 보충 → FALLBACK (최후 수단)
$firstPageVideos = [];
$extraIdx = 0;
$fbIdx = 0;
foreach ($slots as $slot) {
if ($slot !== null) {
$firstPageVideos[] = $slot;
} elseif (isset($extraVideos[$extraIdx])) {
$firstPageVideos[] = $extraVideos[$extraIdx++];
}
// DB에 영상이 없으면 해당 슬롯 생략 (하드코딩 fallback 없음)
}
// ── 6. 총 학습시간(게이지용) ─────────────────────────────────
$stmtTotal = $pdo->prepare("
SELECT COALESCE(SUM(all_tm), 0)
FROM edu_learning_histories
WHERE member_id = :mid AND sys_comp_code = :comp
");
$stmtTotal->execute([':mid' => $memberId, ':comp' => $sysCompCode]);
$totalMin = (int)$stmtTotal->fetchColumn();
$dbSuccess = true;
} catch (Exception $e) {
// DB 오류 발생 시 fallback 기본값 사용
$errMsg = $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine();
error_log('[bbs/main_data.php] DB Error: ' . $errMsg);
api_log('main_data', 'DB_ERROR', ['error' => $errMsg]);
$userName = '사용자';
$userRank = '';
$myKeywords = [
['keyword_code' => 'KW004', 'keyword_name' => '인물'],
['keyword_code' => 'KW005', 'keyword_name' => '소통'],
['keyword_code' => 'KW006', 'keyword_name' => '협업'],
];
$adminKeywords = [
['keyword_code' => 'KW019', 'keyword_name' => '마인드셋'],
['keyword_code' => 'KW020', 'keyword_name' => '웰니스'],
];
$allKeywords = array_map(
fn($k) => ['keyword_code' => $k, 'keyword_name' => $k],
['온보딩','성장','코칭','인물','소통','협업','AI','IT테크',
'중간관리자','리더십','팔로우십','동기부여','인간관계','스킬업',
'피드백','커리어','경영','경제','마인드셋','웰니스','자기개발']
);
$firstPageVideos = [];
$totalMin = 0;
}
// ── DB 실패 시에만 기본값 보장 (DB 성공 시 빈 키워드는 그대로 유지) ──
if (!$dbSuccess) {
$myKeywords = [
['keyword_code' => 'KW004', 'keyword_name' => '인물'],
['keyword_code' => 'KW005', 'keyword_name' => '소통'],
['keyword_code' => 'KW006', 'keyword_name' => '협업'],
];
$adminKeywords = [
['keyword_code' => 'KW019', 'keyword_name' => '마인드셋'],
['keyword_code' => 'KW020', 'keyword_name' => '웰니스'],
];
}
// ════════════════════════════════════════════════════════════════
// 뷰(skin)에서 사용할 JSON 직렬화 변수
// ════════════════════════════════════════════════════════════════
$videosJson = json_encode(
$firstPageVideos,
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP
);
$myKwJson = json_encode(
array_column($myKeywords, 'keyword_name'),
JSON_UNESCAPED_UNICODE
);