as $block) { $rows = array(); foreach ($block['items'] as $it) { $rows[] = array( 'rank' => (int)$it->rank_num, 'title' => (string)$it->title, 'genre' => (string)$it->genre_title, 'score' => (string)$it->score_display, 'delta' => (int)$it->rank_delta, ); } $out['categories'][] = array( 'category' => $block['category']->title, 'axis' => $block['category']->axis, 'items' => $rows, ); } foreach ($this->getMovers($region, 'up', 10) as $it) { $out['risers'][] = array( 'title' => (string)$it->title, 'category' => (string)$it->category_title, 'delta' => (int)$it->rank_delta, ); } foreach ($this->getNewcomers($region, 10) as $it) { $out['newcomers'][] = array( 'title' => (string)$it->title, 'category' => (string)$it->category_title, 'rank' => (int)$it->rank_num, ); } // 트렌드 담당자가 참모에게 올리는 보고. // 담당자는 바깥 세상을, 참모는 사이트 안쪽을 본다. // 둘이 각자 결론만 내면 "밖에서 뜨는데 우리는 못 받고 있다"를 아무도 못 짚는다. $report = $this->getState('manager_report', ''); if ($report !== '') { $out['manager_report'] = $report; $out['manager_report_at'] = $this->humanTime($this->getState('manager_report_at', '')); } $pending = array(); foreach ($this->getProposals('pending', 12) as $p) { if (!in_array($p->kind, array('feature', 'improve', 'monetize'), true)) continue; $pending[] = array( 'kind' => $p->kind_label, 'title' => (string)$p->title, 'reason' => (string)$p->reason, ); } if ($pending) $out['manager_proposals'] = $pending; $out['engagement'] = $this->getEngagementByCategory(14); $out['harness'] = $this->getHarnessStatus(); // 운영자가 트렌드 쪽에 남긴 메모도 참모에게 함께 올린다. // 참모 역시 이걸 지시가 아니라 참고로 다뤄야 한다. $notes = array(); foreach ($this->getNotes(8) as $n) { $notes[] = array( 'at' => substr((string)$n->regdate, 0, 8), 'scope' => (string)$n->scope, 'body' => mb_substr((string)$n->body, 0, 400, 'UTF-8'), ); } if ($notes) $out['operator_notes'] = $notes; } catch (\Throwable $e) { $out['available'] = false; $out['reason'] = '트렌드 조회 오류: ' . $e->getMessage(); } \Rhymix\Framework\Cache::set($key, $out, 900); return $out; } /* ================================================================ * 상태 KV · 실행기록 · AI 제안 * ================================================================ */ /** * @param string $name * @param string $default * @return string */ public function getState($name, $default = '') { $row = $this->row("SELECT value FROM trend_state WHERE name = ?", array((string)$name)); return $row ? (string)$row->value : $default; } /** * @param string $name * @param string $value * @return void */ public function setState($name, $value) { try { \Rhymix\Framework\DB::getInstance()->query( "INSERT INTO trend_state (name, value, updated) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = ?, updated = ?", array((string)$name, (string)$value, date('YmdHis'), (string)$value, date('YmdHis')) ); } catch (\Throwable $e) {} } /** * @param int $source_srl * @param int $limit * @return array */ public function getRunsBySource($source_srl, $limit = 10) { $limit = max(1, min(50, (int)$limit)); return $this->rows( "SELECT * FROM trend_run WHERE source_srl = ? ORDER BY run_srl DESC LIMIT {$limit}", array((int)$source_srl) ); } /** * AI 판단 기록 남기기. * * @return void */ public function logProposal($kind, $title, $reason, array $payload, $status = 'pending') { try { \Rhymix\Framework\DB::getInstance()->query( "INSERT INTO trend_proposal (kind, title, reason, payload_json, status, applied_at, regdate) VALUES (?, ?, ?, ?, ?, ?, ?)", array( (string)$kind, mb_substr((string)$title, 0, 200, 'UTF-8'), mb_substr((string)$reason, 0, 2000, 'UTF-8'), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), (string)$status, $status === 'applied' ? date('YmdHis') : null, date('YmdHis'), ) ); } catch (\Throwable $e) {} } /** * @param string $status '' 이면 전체 * @param int $limit * @return array */ public function getProposals($status = '', $limit = 40) { $limit = max(1, min(200, (int)$limit)); if ($status !== '') { $rows = $this->rows( "SELECT * FROM trend_proposal WHERE status = ? ORDER BY proposal_srl DESC LIMIT {$limit}", array((string)$status) ); } else { $rows = $this->rows("SELECT * FROM trend_proposal ORDER BY proposal_srl DESC LIMIT {$limit}"); } $labels = array( 'category' => '분야 추가', 'genre' => '장르 추가', 'source' => '수집원 추가', 'interval' => '주기 조정', 'disable' => '수집원 중지', 'note' => '메모', 'feature' => '기능 제안', 'improve' => '개선 제안', 'monetize' => '사업화 제안', ); foreach ($rows as $r) { $r->kind_label = $labels[$r->kind] ?? $r->kind; $r->when = $this->humanTime((string)$r->regdate); $payload = json_decode((string)$r->payload_json, true); $r->payload = is_array($payload) ? $payload : array(); $r->payload_pretty = $r->payload ? json_encode($r->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''; $r->is_pending = ($r->status === 'pending'); } return $rows; } /** * @param int $srl * @return object|null */ public function getProposal($srl) { return $this->row("SELECT * FROM trend_proposal WHERE proposal_srl = ?", array((int)$srl)); } /* ================================================================ * 영상 피드 — 반응 · 댓글 * * 영상 자체는 우리 서버에 없다. 남는 건 "누가 무엇을 보고 어떻게 반응했는가" 뿐이고 * 그게 우리만 가질 수 있는 데이터다. * ================================================================ */ /** * 재생 가능한 영상 항목. * * @param array $args region, category_srl, genre_srl, offset, limit * @return array */ public function getVideoFeed(array $args) { $region = (string)($args['region'] ?? 'KR'); $offset = max(0, (int)($args['offset'] ?? 0)); $limit = max(1, min(30, (int)($args['limit'] ?? 10))); $cat_srl = (int)($args['category_srl'] ?? 0); $genre_srl = (int)($args['genre_srl'] ?? 0); $key = 'trend:videofeed:v3:' . $region . ':' . $cat_srl . ':' . $genre_srl . ':' . $offset . ':' . $limit; $cached = \Rhymix\Framework\Cache::get($key); if (is_array($cached)) return $cached; // A video feed must move with the source. Items that have not been // observed for three weeks are no longer current feed candidates. $active_cutoff = date('YmdHis', strtotime('-21 days')); $fresh_cutoff = date('YmdHis', strtotime('-7 days')); $where = array("status = 'Y'", "video_id <> ''", 'region = ?', 'collected_at >= ?'); $params = array($region, $active_cutoff); if ($cat_srl > 0) { $where[] = 'category_srl = ?'; $params[] = $cat_srl; } if ($genre_srl > 0) { $where[] = 'genre_srl = ?'; $params[] = $genre_srl; } $order_params = $params; $order_params[] = $fresh_cutoff; $sql = "SELECT * FROM trend_item WHERE " . implode(' AND ', $where) // 수집원에서 계산한 rank_num은 쇼츠의 '최근 발행 + 현재 반응' 순위다. // 새 항목을 먼저 보여 주되, 같은 최신 묶음에서는 이 순위를 존중한다. . " ORDER BY CASE WHEN first_seen_at >= ? THEN 0 ELSE 1 END ASC," . " rank_num ASC, score DESC, first_seen_at DESC, collected_at DESC LIMIT {$offset}, {$limit}"; $res = $this->decorateVideos($this->decorate($this->rows($sql, $order_params))); \Rhymix\Framework\Cache::set($key, $res, 300); return $res; } /** * @return int */ public function countVideoFeed($region, $category_srl = 0) { $key = 'trend:videofeed_cnt:v2:' . $region . ':' . (int)$category_srl; $cached = \Rhymix\Framework\Cache::get($key); if (is_numeric($cached)) return (int)$cached; $where = array("status = 'Y'", "video_id <> ''", 'region = ?', 'collected_at >= ?'); $params = array((string)$region, date('YmdHis', strtotime('-21 days'))); if ($category_srl) { $where[] = 'category_srl = ?'; $params[] = (int)$category_srl; } $row = $this->row("SELECT COUNT(*) AS cnt FROM trend_item WHERE " . implode(' AND ', $where), $params); $cnt = $row ? (int)$row->cnt : 0; \Rhymix\Framework\Cache::set($key, $cnt, 300); return $cnt; } /** * 피드 댓글 버튼은 '화면에 보여 주는 원본 댓글 최대 5개'와 * 'KERTASMU에서 직접 작성된 댓글'만 합산한다. * 원본 영상의 전체 댓글 수는 영상 플랫폼 통계일 뿐 이 화면의 댓글 수가 아니다. * * @return array [item_srl => total] */ public function getVideoCommentTotals(array $items) { if (!$items) return array(); require_once RX_BASEDIR . 'modules/trend/lib/Video.php'; require_once RX_BASEDIR . 'modules/trend/lib/SourceComments.php'; $item_srls = array(); $video_ids = array(); $video_by_item = array(); foreach ($items as $it) { $item_srl = (int)($it->item_srl ?? 0); if ($item_srl <= 0) continue; $item_srls[] = $item_srl; $provider = strtolower((string)($it->video_provider ?? '')); $video_id = trim((string)($it->video_id ?? '')); if (($provider === '' || $video_id === '') && !empty($it->target_url)) { $parsed = TrendVideo::parse((string)$it->target_url); if ($provider === '') $provider = strtolower((string)($parsed['provider'] ?? '')); if ($video_id === '') $video_id = trim((string)($parsed['id'] ?? '')); $it->video_provider = $provider; $it->video_id = $video_id; } if ($provider === 'youtube' && $video_id !== '') { $video_ids[] = $video_id; $video_by_item[$item_srl] = $video_id; } } $site_counts = $this->getCommentCountsForItems($item_srls); $source_stats = TrendSourceComments::fetchYoutubeStatisticsBatch($video_ids); $totals = array(); foreach ($item_srls as $item_srl) { $site = (int)($site_counts[$item_srl] ?? 0); $video_id = (string)($video_by_item[$item_srl] ?? ''); // 원본 댓글은 카드에서 최대 다섯 줄만 빌려 온다. // 전체 플랫폼 댓글 수를 쓰면 실제 펼쳐지는 댓글 수와 달라진다. $source = ($video_id !== '') ? min(5, max(0, (int)($source_stats[$video_id]['comments'] ?? 0))) : 0; $totals[$item_srl] = $site + $source; } return $totals; } /** * 임베드 주소·내 반응을 붙인다. * * @return array */ public function decorateVideos($items) { if (!$items) return array(); require_once RX_BASEDIR . 'modules/trend/lib/Video.php'; $logged = Context::get('logged_info'); $member_srl = ($logged && !empty($logged->member_srl)) ? (int)$logged->member_srl : 0; $srls = array(); foreach ($items as $it) $srls[] = (int)$it->item_srl; $mine = $member_srl ? $this->getMyReactions($member_srl, $srls) : array(); foreach ($items as $it) { $vprovider = (string)($it->video_provider ?? ''); $vid = (string)($it->video_id ?? ''); $it->embed_url = TrendVideo::embedUrl($vprovider, $vid); if (empty($it->has_thumb)) { $fallback = TrendVideo::thumbUrl($vprovider, $vid); if ($fallback !== '') { $it->thumbnail_url = $fallback; $it->has_thumb = true; } } $it->my_reaction = (string)($mine[(int)$it->item_srl] ?? ''); } return $items; } /** * @return array [item_srl => reaction] */ public function getMyReactions($member_srl, array $item_srls) { $ids = array_values(array_unique(array_filter(array_map('intval', $item_srls)))); if (!$ids || !$member_srl) return array(); $in = implode(',', array_fill(0, count($ids), '?')); $params = $ids; $params[] = (int)$member_srl; $out = array(); foreach ($this->rows( "SELECT item_srl, reaction FROM trend_reaction WHERE item_srl IN ({$in}) AND member_srl = ?", $params ) as $r) { $out[(int)$r->item_srl] = (string)$r->reaction; } return $out; } /** * 항목별 반응 집계. * * @return array [reaction => count] */ public function getReactionCounts($item_srl) { $out = array(); foreach ($this->rows( "SELECT reaction, COUNT(*) AS cnt FROM trend_reaction WHERE item_srl = ? GROUP BY reaction", array((int)$item_srl) ) as $r) { $out[(string)$r->reaction] = (int)$r->cnt; } return $out; } /** * Feed rendering must not execute one aggregate query per card. * * @return array [item_srl => [reaction => count]] */ public function getReactionCountsForItems(array $item_srls) { $ids = array_values(array_unique(array_filter(array_map('intval', $item_srls)))); if (!$ids) return array(); $in = implode(',', array_fill(0, count($ids), '?')); $out = array(); foreach ($this->rows( "SELECT item_srl, reaction, COUNT(*) AS cnt FROM trend_reaction WHERE item_srl IN ({$in}) GROUP BY item_srl, reaction", $ids ) as $r) { $srl = (int)$r->item_srl; if (!isset($out[$srl])) $out[$srl] = array(); $out[$srl][(string)$r->reaction] = (int)$r->cnt; } return $out; } /** * KERTASMU 안에서 작성된 댓글만 센다. * 원본 영상 댓글(origin=source)은 최대 5개만 화면에 임시로 섞어 보여 주므로, * 이 집계에 포함하면 외부 전체 댓글 수와 중복되어 버튼 숫자가 부풀려진다. * * @return array [item_srl => KERTASMU comment count] */ public function getCommentCountsForItems(array $item_srls) { $ids = array_values(array_unique(array_filter(array_map('intval', $item_srls)))); if (!$ids) return array(); $in = implode(',', array_fill(0, count($ids), '?')); $out = array(); foreach ($this->rows( "SELECT item_srl, COUNT(*) AS cnt FROM trend_comment WHERE status = 'Y' AND origin <> 'source' AND item_srl IN ({$in}) GROUP BY item_srl", $ids ) as $r) { $out[(int)$r->item_srl] = (int)$r->cnt; } return $out; } /** * @return array */ public function getComments($item_srl, $limit = 50) { $limit = max(1, min(200, (int)$limit)); $rows = $this->rows( "SELECT comment_srl, member_srl, nick_name, content, like_count, origin, src_key, src_likes, ipaddress, regdate FROM trend_comment WHERE item_srl = ? AND status = 'Y' ORDER BY comment_srl DESC LIMIT {$limit}", array((int)$item_srl) ); if (!$rows) return array(); $logged = Context::get('logged_info'); $me = (int)($logged->member_srl ?? 0); $is_admin = ($logged && ($logged->is_admin ?? '') === 'Y'); $ip = substr((string)(\RX_CLIENT_IP ?: ''), 0, 60); $liked = $this->myCommentLikes($rows, $me, $ip); foreach ($rows as $r) { $is_src = ((string)$r->origin === 'source'); $r->when = $this->humanTime((string)$r->regdate); $r->is_src = $is_src; // 빌려온 줄의 화면 숫자는 원본 좋아요 + 우리 좋아요다. $r->likes = (int)$r->like_count + ($is_src ? (int)$r->src_likes : 0); $r->liked = isset($liked[(int)$r->comment_srl]); $r->sort_ts = (int)strtotime((string)$r->regdate); $r->mine = ($me > 0 && (int)$r->member_srl === $me); // 지울 수 있는 건 내 글뿐이다. 비회원 글은 같은 IP 에서만. // 빌려온 줄은 우리 글이 아니므로 관리자만 내릴 수 있다. $r->can_delete = $is_src ? $is_admin : ($is_admin || ($me > 0 && (int)$r->member_srl === $me) || ($me === 0 && (int)$r->member_srl === 0 && $ip !== '' && (string)$r->ipaddress === $ip)); // IP 는 화면으로 나가면 안 된다 unset($r->ipaddress); } return $rows; } /** * 화면에 뿌릴 댓글 한 줄기. * * 우리 댓글과, 원본에서 빌려온 줄을 하나로 합쳐 순서를 정한다. * * 맨 위 — 좋아요가 붙은 것 상위 3개 (고정) * 그 아래 — 내 댓글 먼저, 그다음 최신순 * * 좋아요 많은 게 위에 있으면 읽을 이유가 생기고, * 내 댓글이 바로 보이면 쓴 보람이 생긴다. 둘 다 있어야 다음 줄이 써진다. * * @param object $item * @param int $limit * @param int $borrow 원본에서 빌려올 최대 개수 * @return array */ public function getCommentStream($item, $limit = 50, $borrow = 3) { $item_srl = (int)($item->item_srl ?? 0); $rows = $this->getComments($item_srl, $limit); // 이미 새겨진 줄(좋아요·삭제로 표에 들어온 것)은 캐시본 대신 DB 를 쓴다. // 지운 줄도 여기서 걸러야 다시 살아나지 않는다. $known = array(); foreach ($this->rows( "SELECT src_key FROM trend_comment WHERE item_srl = ? AND origin = 'source' AND src_key <> ''", array($item_srl) ) as $r) { $known[(string)$r->src_key] = true; } $stream = array(); foreach ($rows as $r) { $stream[] = array( 'srl' => (int)$r->comment_srl, 'src_key' => (string)$r->src_key, 'author' => (string)$r->nick_name, 'content' => (string)$r->content, 'when' => (string)$r->when, 'likes' => (int)$r->likes, 'liked' => (bool)$r->liked, 'is_src' => (bool)$r->is_src, 'mine' => (bool)$r->mine, 'can_delete' => (bool)$r->can_delete, 'sort_ts' => (int)$r->sort_ts, ); } if ($borrow > 0 && !empty($item->video_id)) { require_once RX_BASEDIR . 'modules/trend/lib/SourceComments.php'; $sc = TrendSourceComments::fetch($item, $borrow + count($known)); if ($sc['ok']) { $taken = 0; foreach ($sc['comments'] as $c) { if ($taken >= $borrow) break; if (isset($known[$c['key']])) continue; // 이미 표에 있거나 내려간 줄 $stream[] = array( 'srl' => 0, // 아직 새기지 않았다 'src_key' => (string)$c['key'], 'author' => (string)$c['author'], 'content' => (string)$c['text'], 'when' => (string)$c['when'], 'likes' => (int)$c['likes'], 'liked' => false, 'is_src' => true, 'mine' => false, 'can_delete' => $this->isSiteAdmin(), 'sort_ts' => (int)$c['ts'], ); $taken++; } } } return $this->orderCommentStream($stream); } /** * @return array */ private function orderCommentStream(array $stream) { if (count($stream) < 2) { foreach ($stream as &$one) $one['pinned'] = false; unset($one); return $stream; } // 1) 좋아요가 붙은 것 상위 3개를 위로 고정 $by_like = $stream; usort($by_like, function ($a, $b) { if ($a['likes'] !== $b['likes']) return $b['likes'] <=> $a['likes']; return $b['sort_ts'] <=> $a['sort_ts']; }); $pinned = array(); $pinned_at = array(); foreach (array_slice($by_like, 0, 3) as $p) { if ($p['likes'] <= 0) break; $p['pinned'] = true; $pinned[] = $p; $pinned_at[$p['src_key'] . '#' . $p['srl']] = true; } // 2) 나머지는 내 댓글 먼저, 그다음 최신순 $rest = array(); foreach ($stream as $s) { if (isset($pinned_at[$s['src_key'] . '#' . $s['srl']])) continue; $s['pinned'] = false; $rest[] = $s; } usort($rest, function ($a, $b) { if ($a['mine'] !== $b['mine']) return $a['mine'] ? -1 : 1; return $b['sort_ts'] <=> $a['sort_ts']; }); return array_merge($pinned, $rest); } /** * @return bool */ private function isSiteAdmin() { $logged = Context::get('logged_info'); return (bool)($logged && ($logged->is_admin ?? '') === 'Y'); } /** * 이 목록 중 내가 좋아요를 누른 댓글. * * @return array comment_srl => true */ private function myCommentLikes($rows, $member_srl, $ip) { $srls = array(); foreach ($rows as $r) $srls[] = (int)$r->comment_srl; if (!$srls) return array(); if ($member_srl <= 0 && $ip === '') return array(); $in = implode(',', array_map('intval', $srls)); if ($member_srl > 0) { $sql = "SELECT comment_srl FROM trend_comment_vote WHERE comment_srl IN ({$in}) AND member_srl = ?"; $params = array((int)$member_srl); } else { $sql = "SELECT comment_srl FROM trend_comment_vote WHERE comment_srl IN ({$in}) AND member_srl = 0 AND ipaddress = ?"; $params = array($ip); } $out = array(); foreach ($this->rows($sql, $params) as $r) $out[(int)$r->comment_srl] = true; return $out; } /** * 반응·댓글 수를 항목에 다시 새긴다 (목록 정렬·AI 근거용). * * @return void */ public function syncCounts($item_srl) { try { $db = \Rhymix\Framework\DB::getInstance(); $db->query( "UPDATE trend_item SET reaction_count = (SELECT COUNT(*) FROM trend_reaction WHERE item_srl = ?), comment_count = (SELECT COUNT(*) FROM trend_comment WHERE item_srl = ? AND status = 'Y' AND origin = 'user') WHERE item_srl = ?", array((int)$item_srl, (int)$item_srl, (int)$item_srl) ); } catch (\Throwable $e) {} } /* ================================================================ * 안 되는 출처 원장 * * 같은 사이트를 매일 제안하고 매일 실패하는 것만큼 낭비가 없다. * 영구 차단이 아니라 유예다 — 사이트가 정책이나 구조를 바꾸는 일이 실제로 있다. * ================================================================ */ /** * @param string $url * @return string 호스트 */ public function hostOf($url) { $host = parse_url(trim((string)$url), PHP_URL_HOST); return $host ? strtolower($host) : ''; } /** * 지금 이 호스트를 다시 시도해도 되는가. * * @param string $host * @return bool */ public function isBlocked($host) { $host = strtolower(trim((string)$host)); if ($host === '') return false; $row = $this->row("SELECT retry_after FROM trend_blocklist WHERE host = ?", array($host)); if (!$row) return false; if (!$row->retry_after) return true; return ((string)$row->retry_after > date('YmdHis')); } /** * 실패 기록. 반복될수록 유예 기간을 늘린다. * * @return void */ public function markBlocked($url, $reason_code, $reason) { $host = $this->hostOf($url); if ($host === '') return; // robots 로 막힌 곳은 오래 쉬고, 파싱 실패는 짧게 쉰다 — // 전자는 상대의 의사이고 후자는 우리가 고칠 여지가 있다. $days = ($reason_code === 'robots') ? 90 : (($reason_code === 'js_required') ? 30 : 7); try { \Rhymix\Framework\DB::getInstance()->query( "INSERT INTO trend_blocklist (host, sample_url, reason_code, reason, fail_count, retry_after, last_tried, regdate) VALUES (?, ?, ?, ?, 1, ?, ?, ?) ON DUPLICATE KEY UPDATE fail_count = fail_count + 1, reason_code = ?, reason = ?, retry_after = ?, last_tried = ?", array( $host, mb_substr((string)$url, 0, 500, 'UTF-8'), (string)$reason_code, mb_substr((string)$reason, 0, 300, 'UTF-8'), date('YmdHis', strtotime("+{$days} days")), date('YmdHis'), date('YmdHis'), (string)$reason_code, mb_substr((string)$reason, 0, 300, 'UTF-8'), date('YmdHis', strtotime("+{$days} days")), date('YmdHis'), ) ); } catch (\Throwable $e) {} } /** * @return void */ public function clearBlocked($host) { try { \Rhymix\Framework\DB::getInstance()->query( "DELETE FROM trend_blocklist WHERE host = ?", array(strtolower(trim((string)$host))) ); } catch (\Throwable $e) {} } /** * @return array */ public function getBlocklist($limit = 50) { $limit = max(1, min(200, (int)$limit)); $rows = $this->rows("SELECT * FROM trend_blocklist ORDER BY last_tried DESC LIMIT {$limit}"); $labels = array( 'robots' => 'robots.txt 차단', 'http' => '접속/응답 실패', 'parse' => '구조 해석 실패', 'js_required' => '자바스크립트 필요', 'dead' => '기타', ); foreach ($rows as $r) { $r->reason_label = $labels[$r->reason_code] ?? $r->reason_code; $r->when = $this->humanTime((string)$r->last_tried); $r->retry_text = $r->retry_after ? $this->humanTime((string)$r->retry_after) : '-'; $r->can_retry = !$r->retry_after || ((string)$r->retry_after <= date('YmdHis')); } return $rows; } /* ================================================================ * 운영자 메모 — 지표에 안 잡히는 현장 감각 * ================================================================ */ /** * @param int $limit * @param bool $active_only * @return array */ public function getNotes($limit = 20, $active_only = true) { $limit = max(1, min(100, (int)$limit)); $sql = "SELECT * FROM trend_note"; if ($active_only) $sql .= " WHERE is_active = 'Y'"; $sql .= " ORDER BY note_srl DESC LIMIT {$limit}"; $rows = $this->rows($sql); $cats = array(); foreach ($this->getCategoryList(false) as $c) $cats[(int)$c->category_srl] = $c->title; foreach ($rows as $r) { $r->when = $this->humanTime((string)$r->regdate); $r->scope = ''; if ((int)$r->category_srl > 0) $r->scope = $cats[(int)$r->category_srl] ?? ''; if ((string)$r->region !== '') { $r->scope = $r->scope !== '' ? ($r->scope . ' / ' . $r->region) : (string)$r->region; } if ($r->scope === '') $r->scope = '전체'; } return $rows; } /** * @return void */ public function addNote($member_srl, $writer, $body, $category_srl = 0, $region = '') { try { \Rhymix\Framework\DB::getInstance()->query( "INSERT INTO trend_note (member_srl, writer, body, category_srl, region, is_active, regdate) VALUES (?, ?, ?, ?, ?, 'Y', ?)", array( (int)$member_srl, mb_substr((string)$writer, 0, 80, 'UTF-8'), mb_substr((string)$body, 0, 4000, 'UTF-8'), (int)$category_srl, strtoupper(trim((string)$region)), date('YmdHis'), ) ); } catch (\Throwable $e) {} } /** * @return void */ public function deleteNote($note_srl) { try { \Rhymix\Framework\DB::getInstance()->query( "DELETE FROM trend_note WHERE note_srl = ?", array((int)$note_srl) ); } catch (\Throwable $e) {} } /** * 분야별 노출·클릭. AI 담당자가 "여긴 관심이 없다"를 판단하는 근거. * * @param int $days 최근 며칠 안에 수집된 항목만 * @return array */ public function getEngagementByCategory($days = 14) { $since = date('YmdHis', strtotime('-' . max(1, (int)$days) . ' days')); $rows = $this->rows( "SELECT category_srl, region, COUNT(*) AS item_cnt, SUM(view_count) AS views, SUM(click_count) AS clicks FROM trend_item WHERE status = 'Y' AND collected_at >= ? GROUP BY category_srl, region", array($since) ); $cats = array(); foreach ($this->getCategoryList(false) as $c) $cats[(int)$c->category_srl] = $c->title; $out = array(); foreach ($rows as $r) { $views = (int)$r->views; $clicks = (int)$r->clicks; $out[] = array( 'category' => $cats[(int)$r->category_srl] ?? ('#' . $r->category_srl), 'region' => (string)$r->region, 'items' => (int)$r->item_cnt, 'views' => $views, 'clicks' => $clicks, 'ctr' => $views > 0 ? round($clicks / $views * 100, 1) : 0, ); } return $out; } /** * 노출·클릭 카운터. 실패해도 화면을 막지 않는다. * * @param int $item_srl * @param string $kind 'view' | 'click' * @return void */ public function bumpEngagement($item_srl, $kind = 'view') { $column = ($kind === 'click') ? 'click_count' : 'view_count'; try { \Rhymix\Framework\DB::getInstance()->query( "UPDATE trend_item SET {$column} = {$column} + 1 WHERE item_srl = ?", array((int)$item_srl) ); } catch (\Throwable $e) {} } /* ================================================================ * AI 브리핑 (크레딧 소비 결과물) * ================================================================ */ /** * 최근 N시간 안에 같은 조건으로 받은 브리핑. * * @return object|null body 필드에 파싱된 배열이 들어간다 */ public function getRecentBriefing($member_srl, $category_srl, $region, $hours = 6) { $since = date('YmdHis', time() - max(1, (int)$hours) * 3600); $row = $this->row( "SELECT * FROM trend_briefing WHERE member_srl = ? AND category_srl = ? AND region = ? AND regdate >= ? ORDER BY briefing_srl DESC LIMIT 1", array((int)$member_srl, (int)$category_srl, (string)$region, $since) ); if (!$row) return null; $body = json_decode((string)$row->body_json, true); $row->body = is_array($body) ? $body : array(); return $row; } /** * @return void */ public function saveBriefing($member_srl, $category_srl, $region, $title, array $body, $cost) { try { \Rhymix\Framework\DB::getInstance()->query( "INSERT INTO trend_briefing (member_srl, category_srl, region, title, body_json, cost, regdate) VALUES (?, ?, ?, ?, ?, ?, ?)", array( (int)$member_srl, (int)$category_srl, (string)$region, mb_substr((string)$title, 0, 200, 'UTF-8'), json_encode($body, JSON_UNESCAPED_UNICODE), (int)$cost, date('YmdHis'), ) ); } catch (\Throwable $e) {} } /** * 내가 받은 브리핑 목록. * * @return array */ public function getMyBriefings($member_srl, $limit = 20) { $limit = max(1, min(100, (int)$limit)); $rows = $this->rows( "SELECT briefing_srl, category_srl, region, title, cost, regdate FROM trend_briefing WHERE member_srl = ? ORDER BY briefing_srl DESC LIMIT {$limit}", array((int)$member_srl) ); foreach ($rows as $r) { $r->when = $this->humanTime((string)$r->regdate); } return $rows; } /* ================================================================ * 내부 helper * ================================================================ */ /** * @return object|null */ private function row($sql, array $params = array()) { try { $db = \Rhymix\Framework\DB::getInstance(); $row = $db->fetch($db->query($sql, $params)); if (is_array($row)) return reset($row) ?: null; return $row ?: null; } catch (\Throwable $e) { return null; } } /** * @return array */ private function rows($sql, array $params = array()) { try { $db = \Rhymix\Framework\DB::getInstance(); $rows = $db->fetch($db->query($sql, $params), 0, 'array'); return is_array($rows) ? $rows : array(); } catch (\Throwable $e) { return array(); } } }