'安卓', 'ios' => 'iOS', 'harmony' => '鸿蒙', 'macos' => 'macOS', 'win' => 'Windows', 'web' => 'Web', 'other' => '其他', ]; private static $feedMap = [ 'poetry' => ['table' => 'poetry', 'name' => '古诗词', 'icon' => '📜', 'search' => ['name','content','author'], 'return' => ['id','name as title','author','content','views'], 'order' => 'views'], 'wisdom' => ['table' => 'wisdom', 'name' => '名言金句', 'icon' => '💡', 'search' => ['name','content'], 'return' => ['id','name as author','content','views'], 'order' => 'views'], 'story' => ['table' => 'story', 'name' => '故事', 'icon' => '📚', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'order' => 'views'], 'hitokoto' => ['table' => 'hitokoto', 'name' => '一言', 'icon' => '💬', 'search' => ['hitokoto','from_source'], 'return' => ['id','hitokoto as content','from_source','from_who as author','type_name','views'], 'order' => 'id'], 'riddle' => ['table' => 'riddle', 'name' => '谜语', 'icon' => '🧩', 'search' => ['riddle','interpret'], 'return' => ['id','riddle as title','interpret as answer','miidii as hint','views'], 'order' => 'views'], 'efs' => ['table' => 'efs', 'name' => '歇后语', 'icon' => '🎭', 'search' => ['facet','undertone'], 'return' => ['id','facet as title','undertone as content','views'], 'order' => 'views'], 'brainteaser' => ['table' => 'brainteaser', 'name' => '脑筋急转弯', 'icon' => '🧠', 'search' => ['topic','answer'], 'return' => ['id','topic as title','answer','views'], 'order' => 'views'], 'saying' => ['table' => 'saying', 'name' => '俗语', 'icon' => '🗣️', 'search' => ['saying','content'], 'return' => ['id','saying as title','content','views'], 'order' => 'views'], 'lyric' => ['table' => 'lyric', 'name' => '歌词', 'icon' => '🎵', 'search' => ['title','singer','content'],'return' => ['id','title','singer as author','content','views'], 'order' => 'views'], 'why' => ['table' => 'why', 'name' => '十万个为什么','icon' => '❓', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'order' => 'views'], 'composition' => ['table' => 'composition', 'name' => '作文', 'icon' => '✍️', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'order' => 'views'], 'couplet' => ['table' => 'couplet', 'name' => '对联', 'icon' => '🏮', 'search' => ['hp','sl'], 'return' => ['id','hp as upper','sl as lower','xl as horizontal','views'], 'order' => 'views'], 'cs' => ['table' => 'cs', 'name' => '常识', 'icon' => '📖', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'order' => 'views'], 'drug' => ['table' => 'drug', 'name' => '药品', 'icon' => '💊', 'search' => ['name','goods_name'], 'return' => ['id','name','goods_name','syz as indication','views'], 'order' => 'views'], 'herbal' => ['table' => 'herbal', 'name' => '中草药', 'icon' => '🌿', 'search' => ['name','name_alias'], 'return' => ['id','name','name_alias','effect','views'], 'order' => 'views'], 'food' => ['table' => 'food', 'name' => '食物', 'icon' => '🍽️', 'search' => ['sw'], 'return' => ['id','sw as name','yh as effect','views'], 'order' => 'views'], 'wine' => ['table' => 'wine', 'name' => '酒方', 'icon' => '🍷', 'search' => ['name','effect'], 'return' => ['id','name as title','effect as content','views'], 'order' => 'id'], 'article' => ['table' => 'article', 'name' => '文章', 'icon' => '📰', 'search' => ['title','summary'], 'return' => ['id','title','summary','views','likes','user_id'], 'order' => 'createtime'], 'chengyu' => ['table' => 'cy', 'name' => '成语', 'icon' => '🔤', 'search' => ['cy','cyjs','cyzy'], 'return' => ['id','cy as title','cypy as pinyin','cyjs as content','views'], 'order' => 'views'], 'hanzi' => ['table' => 'hanzi', 'name' => '汉字', 'icon' => '🈯', 'search' => ['zi','pinyin','bushou'], 'return' => ['id','zi as title','pinyin','wubi','bushou','bihua','pv as views'], 'order' => 'pv'], 'cidian' => ['table' => 'zc', 'name' => '词典', 'icon' => '📚', 'search' => ['zc','zcjs'], 'return' => ['id','zc as title','zcpy as pinyin','zcjs as content','views'], 'order' => 'views'], 'prescription'=> ['table' => 'prescription','name' => '偏方', 'icon' => '🧪', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'order' => 'views'], 'tisana' => ['table' => 'tisana', 'name' => '药茶', 'icon' => '🍵', 'search' => ['name','effect'], 'return' => ['id','name as title','effect as content','recipe','views'], 'order' => 'views'], 'joke' => ['table' => 'joke', 'name' => '笑话', 'icon' => '😄', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'order' => 'views'], 'zgjm' => ['table' => 'zgjm', 'name' => '周公解梦', 'icon' => '🌙', 'search' => ['name','content'], 'return' => ['id','name as title','content','views'], 'order' => 'views'], 'lunyu' => ['table' => 'lunyu', 'name' => '论语', 'icon' => '📖', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'hdnj' => ['table' => 'hdnj', 'name' => '黄帝内经', 'icon' => '⚕️', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'jgj' => ['table' => 'jgj', 'name' => '金刚经', 'icon' => '📿', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'mz' => ['table' => 'mz', 'name' => '孟子', 'icon' => '📜', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'zz' => ['table' => 'zz', 'name' => '庄子', 'icon' => '🦋', 'search' => ['original','translation'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'zuozhuan' => ['table' => 'zuozhuan', 'name' => '左传', 'icon' => '竹', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'sj' => ['table' => 'sj', 'name' => '史记', 'icon' => '🏛️', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'sgz' => ['table' => 'sgz', 'name' => '三国志', 'icon' => '⚔️', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'sbbf' => ['table' => 'sbbf', 'name' => '孙膑兵法', 'icon' => '🗡️', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'warring' => ['table' => 'warring', 'name' => '兵法', 'icon' => '🛡️', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'illness' => ['table' => 'illness', 'name' => '疾病', 'icon' => '🩺', 'search' => ['jb','zz','yqys'], 'return' => ['id','jb as title','zz as content','yqys','zlff','views'], 'order' => 'views'], 'word' => ['table' => 'word', 'name' => '英语单词', 'icon' => '🔤', 'search' => ['word','jbjs'], 'return' => ['id','word as title','british','american','jbjs as content','views'], 'order' => 'views'], 'abbr' => ['table' => 'abbr', 'name' => '缩写', 'icon' => '📝', 'search' => ['abbr','full','meaning'], 'return' => ['id','abbr as title','full','meaning as content','views'], 'order' => 'views'], 'surname' => ['table' => 'surname', 'name' => '姓氏', 'icon' => '👤', 'search' => ['surname','content'], 'return' => ['id','surname as title','content','views'], 'order' => 'views'], 'jieqi' => ['table' => 'jieqi', 'name' => '节气', 'icon' => '🌤️', 'search' => ['name','content'], 'return' => ['id','name as title','content','views'], 'order' => 'views'], 'nation' => ['table' => 'nation', 'name' => '国家', 'icon' => '🌍', 'search' => ['title','content'], 'return' => ['id','title','capital','content','views'], 'order' => 'views'], 'wlyh' => ['table' => 'wlyh', 'name' => '网络用语', 'icon' => '💬', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'order' => 'views'], 'jiufang' => ['table' => 'jiufang', 'name' => '酒方', 'icon' => '🍶', 'search' => ['name','source'], 'return' => ['id','name as title','source','method as content','views'], 'order' => 'views'], 'bot' => ['table' => 'bot', 'name' => '星座', 'icon' => '⭐', 'search' => ['title','chinese'], 'return' => ['id','title','chinese','content','views'], 'order' => 'views'], ]; public function _initialize() { if (isset($_SERVER['HTTP_ORIGIN'])) { header('Access-Control-Expose-Headers: __token__'); } check_cors_request(); parent::_initialize(); } /** * @name 安装/同步全部分类权重配置 * @desc 自动检测并插入缺失的分类配置到feed_weight_config表,确保44种数据源全部可管理 * @param string token 验证token(可选,简单安全校验) */ public function install() { // 简单安全校验:需要传入token参数或管理员登录 $token = input('get.token', '', 'trim'); $adminToken = 'xianyan_feed_sync_2026'; if ($token !== $adminToken) { $userId = $this->_getUserId(); if (!$userId) { $this->error('无权限执行此操作,请传入正确的token或管理员登录'); } } $existingTypes = []; try { $existingTypes = Db::name('feed_weight_config')->column('feed_type'); } catch (\Exception $e) { // 表可能不存在,需要创建 $this->_ensureTableExists(); $existingTypes = []; } $addedCount = 0; $updatedCount = 0; $now = time(); // 事务包裹:确保INSERT/UPDATE操作的原子性,防止部分写入导致数据不一致 Db::startTrans(); try { foreach (self::$feedMap as $type => $config) { $defaultWeight = $this->_getDefaultWeight($type); if (!in_array($type, $existingTypes)) { Db::name('feed_weight_config')->insert([ 'feed_type' => $type, 'feed_name' => $config['name'], 'feed_icon' => $config['icon'], 'search_fields' => json_encode($config['search']), 'weight' => $defaultWeight, 'display_weight' => max(10, intval($defaultWeight * 0.8)), 'push_limit' => 0, 'push_count' => 0, 'push_date' => null, 'is_enabled' => 1, 'platform_enabled' => $this->_defaultPlatformEnabled(), 'create_time' => $now, 'update_time' => $now, ]); $addedCount++; } else { // 更新已有记录的名称和图标(保持权重等配置不变) Db::name('feed_weight_config') ->where('feed_type', $type) ->update([ 'feed_name' => $config['name'], 'feed_icon' => $config['icon'], 'search_fields' => json_encode($config['search']), 'update_time' => $now, ]); $updatedCount++; } } Db::commit(); } catch (\Exception $e) { Db::rollback(); $this->error('同步失败:' . $e->getMessage()); } // 清除缓存 $this->_clearWeightCache(); $result = [ 'added' => $addedCount, 'updated' => $updatedCount, 'existing' => count($existingTypes), 'total_types' => count(self::$feedMap), 'all_categories' => array_keys(self::$feedMap), ]; $this->success("同步完成:新增{$addedCount}种,更新{$updatedCount}种,共" . count(self::$feedMap) . '种分类', $result); } /** * @name 确保feed_weight_config表存在 * @desc 如果表不存在则自动创建 */ private function _ensureTableExists() { $sql = "CREATE TABLE IF NOT EXISTS `fa_feed_weight_config` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `feed_type` varchar(50) NOT NULL DEFAULT '' COMMENT '内容类型key', `feed_name` varchar(50) NOT NULL DEFAULT '' COMMENT '显示名称', `feed_icon` varchar(10) NOT NULL DEFAULT '' COMMENT '图标', `search_fields` varchar(255) NOT NULL DEFAULT '' COMMENT '搜索字段JSON', `weight` int(10) NOT NULL DEFAULT 50 COMMENT '推荐权重0-100', `display_weight` int(10) NOT NULL DEFAULT 50 COMMENT '展示权重0-100', `push_limit` int(10) NOT NULL DEFAULT 0 COMMENT '推送上限0=不限', `push_count` int(10) NOT NULL DEFAULT 0 COMMENT '今日推送计数', `push_date` date DEFAULT NULL COMMENT '推送日期', `is_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '启用状态', `platform_enabled` varchar(255) NOT NULL DEFAULT '' COMMENT '平台开关JSON', `create_time` int(10) unsigned DEFAULT NULL COMMENT '创建时间', `update_time` int(10) unsigned DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `feed_type` (`feed_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='信息流推荐权重配置'"; try { Db::execute($sql); } catch (\Exception $e) {} } /** * @name 获取分类默认权重 * @desc 根据分类类型返回合理的默认推荐权重 */ private function _getDefaultWeight($type) { $weightMap = [ 'hitokoto' => 70, 'poetry' => 60, 'article' => 60, 'wisdom' => 55, 'lyric' => 55, 'story' => 50, 'chengyu' => 45, 'joke' => 45, 'riddle' => 40, 'brainteaser' => 40, 'lunyu' => 40, 'why' => 35, 'efs' => 35, 'hanzi' => 35, 'wlyh' => 35, 'cs' => 30, 'composition' => 30, 'mz' => 30, 'zz' => 30, 'sj' => 30, 'saying' => 30, 'cidian' => 30, 'bot' => 30, 'couplet' => 25, 'zgjm' => 25, 'hdnj' => 25, 'jgj' => 25, 'zuozhuan' => 25, 'sgz' => 25, 'jieqi' => 25, 'sbbf' => 20, 'warring' => 20, 'food' => 20, 'prescription'=> 20, 'illness' => 20, 'word' => 20, 'nation' => 20, 'drug' => 15, 'herbal' => 15, 'tisana' => 15, 'abbr' => 15, 'surname' => 15, 'wine' => 10, 'jiufang' => 10, ]; return isset($weightMap[$type]) ? $weightMap[$type] : 30; } /** * @name 信息流列表 * @desc 聚合多类型内容为统一信息流,支持频道筛选、分页、轻量模式 * @param string channel 频道类型(all/poetry/wisdom/story等) * @param string sort 排序(newest/hottest) * @param int page 页码 * @param int limit 每页数量 * @param int last_id 游标分页: 上一页最后ID * @param int lite 轻量模式(1=只返回summary不返回content,快速下滑优化) * @param string platform 平台标识(android/ios/harmony/macos/win/web/other),按平台过滤启用分类 */ public function list() { $channel = input('get.channel', 'all', 'trim'); $sort = input('get.sort', 'newest', 'trim'); $page = max(1, input('get.page', 1, 'intval')); $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $lastId = input('get.last_id', 0, 'intval'); $lite = input('get.lite', 0, 'intval'); $seenIds = input('get.seen_ids', '', 'trim'); $platform = $this->_getPlatform(); $seenMap = []; if (!empty($seenIds)) { $pairs = explode(',', $seenIds); foreach ($pairs as $pair) { $parts = explode('_', $pair, 2); if (count($parts) === 2) { $seenMap[$parts[0]][] = intval($parts[1]); } } } $cacheKey = "feed_list_{$channel}_{$sort}_{$page}_{$limit}" . ($lite ? "_lite" : ""); if ($lastId > 0) { $cacheKey .= "_cursor_{$lastId}"; } if (!empty($platform)) { $cacheKey .= "_plat_{$platform}"; } $hasSeenIds = !empty($seenMap); if ($hasSeenIds) { ksort($seenMap); $cacheKey .= "_seen_" . md5(json_encode($seenMap)); } $cached = Cache::get($cacheKey); if ($cached && !$hasSeenIds) { // v2.6: 缓存命中后重新附加当前用户的 is_liked/is_favorited 状态 // 缓存中的 per-user 字段可能来自其他用户,不可信,必须按当前用户重查 if (isset($cached['list']) && !empty($cached['list'])) { $this->_batchAttachInteractionCounts($cached['list'], $this->_getUserId()); } $this->success('成功(cache)', $cached); return; } $items = []; $total = 0; if ($channel === 'all') { $weightConfig = $this->_getWeightConfig(); $enabledTypes = []; $totalWeight = 0; foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { // 按平台过滤:如果指定了平台,检查该分类是否在该平台启用 if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { continue; } $enabledTypes[$type] = $wc; $totalWeight += $wc['weight']; } } if (empty($enabledTypes)) { // 指定了平台但无启用分类 → 返回空列表(尊重后台配置) if (!empty($platform)) { $total = 0; $result = [ 'list' => [], 'total' => 0, 'page' => $page, 'limit' => $limit, 'channel' => $channel, 'sort' => $sort, 'lite' => $lite ? true : false, 'platform' => $platform, ]; $this->success('成功', $result); return; } // 未指定平台时使用默认主分类(向后兼容) $mainTypes = ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article', 'efs', 'saying']; $perType = max(1, intval($limit / count($mainTypes)) + 1); foreach ($mainTypes as $type) { try { $typeItems = $this->_fetchFeedItems($type, $sort, 1, $perType, $lastId); foreach ($typeItems as $item) { $items[] = $item; } } catch (\Exception $e) {} } } else { foreach ($enabledTypes as $type => $wc) { $typeLimit = max(1, intval($limit * ($wc['weight'] / $totalWeight)) + 1); if ($wc['push_limit'] > 0) { $this->_resetPushCount($type, $wc); $remaining = max(0, $wc['push_limit'] - $wc['push_count']); if ($remaining <= 0) continue; $typeLimit = min($typeLimit, $remaining); } try { $typeItems = $this->_fetchFeedItems($type, $sort, 1, $typeLimit, $lastId); foreach ($typeItems as $item) { $item['_weight'] = $wc['weight']; $item['_display_weight'] = $wc['display_weight']; $items[] = $item; } } catch (\Exception $e) {} } } usort($items, function ($a, $b) use ($sort) { $dwA = $a['_display_weight'] ?? 50; $dwB = $b['_display_weight'] ?? 50; if ($dwA !== $dwB) { return $dwB - $dwA; } if ($sort === 'hottest') { return ($b['views'] ?? 0) - ($a['views'] ?? 0); } return ($b['id'] ?? 0) - ($a['id'] ?? 0); }); foreach ($items as &$item) { unset($item['_weight'], $item['_display_weight']); } unset($item); $total = count($items); $items = array_slice($items, ($page - 1) * $limit, $limit); } else { if (!isset(self::$feedMap[$channel])) { $allowed = implode('/', array_keys(self::$feedMap)); $this->error('不支持的频道: ' . $channel . ',可选: ' . $allowed); } // 指定频道时也检查平台过滤 if (!empty($platform)) { $weightConfig = $this->_getWeightConfig(); $isEnabled = true; if (isset($weightConfig[$channel])) { $isEnabled = !empty($weightConfig[$channel]['is_enabled']); } if (!$isEnabled || !$this->_isPlatformEnabled($channel, $platform, $weightConfig)) { $result = [ 'list' => [], 'total' => 0, 'page' => $page, 'limit' => $limit, 'channel' => $channel, 'sort' => $sort, 'lite' => $lite ? true : false, 'platform' => $platform, ]; $this->success('成功', $result); return; } } $items = $this->_fetchFeedItems($channel, $sort, $page, $limit, $lastId); $total = $this->_countFeedItems($channel); } if (!empty($seenMap)) { $items = array_filter($items, function ($item) use ($seenMap) { $type = $item['feed_type'] ?? ''; $id = $item['id'] ?? 0; if (isset($seenMap[$type]) && in_array($id, $seenMap[$type])) { return false; } return true; }); $items = array_values($items); if (count($items) < $limit && $channel === 'all') { $need = $limit - count($items); $existIds = []; foreach ($items as $it) { $existIds[$it['feed_type'] ?? ''][] = $it['id']; } $extraItems = $this->_fetchExcludingSeen($seenMap, $existIds, $sort, $need); $items = array_merge($items, $extraItems); } } if ($lite) { foreach ($items as &$item) { unset($item['content']); } unset($item); } // 批量附加互动计数(like_count/favorite_count/comment_count/share_count) // 修复: 原仅 /api/feed/detail 返回这些字段,列表接口缺失导致客户端按钮显示 0 // v2.6: 同时回填当前用户的 is_liked/is_favorited 状态,解决已收藏句子重复出现时状态丢失 $this->_batchAttachInteractionCounts($items, $this->_getUserId()); $result = [ 'list' => $items, 'total' => $total, 'page' => $page, 'limit' => $limit, 'channel' => $channel, 'sort' => $sort, 'lite' => $lite ? true : false, ]; if (!$hasSeenIds) { // v2.6: 缓存前剥离 per-user 字段(is_liked/is_favorited) // 这些字段是用户私有的,缓存后其他用户命中会串状态 // 缓存命中时由 _batchAttachInteractionCounts 重新按当前用户查询 $cacheResult = $result; if (isset($cacheResult['list'])) { foreach ($cacheResult['list'] as &$cacheItem) { $cacheItem['is_liked'] = false; $cacheItem['is_favorited'] = false; } unset($cacheItem); } Cache::set($cacheKey, $cacheResult, 60); } $this->success('成功', $result); } /** * @name 信息流详情 * @desc 获取单条内容的详细信息,浏览量+1,记录浏览历史 * @param string type 内容类型 * @param int id 内容ID */ public function detail() { $type = input('get.type', '', 'trim'); $id = input('get.id', 0, 'intval'); if (empty($type) || !$id) { $this->error('参数错误,需要type和id'); } if (!isset(self::$feedMap[$type])) { $this->error('不支持的内容类型'); } $config = self::$feedMap[$type]; $row = Db::name($config['table'])->where('id', $id)->find(); if (!$row) { $this->error('内容不存在'); } Db::name($config['table'])->where('id', $id)->setInc('views'); $item = $this->_formatItem($type, $row); try { $likeCount = Db::name('feed_interaction') ->where('feed_type', $type)->where('feed_id', $id)->where('action', 'like')->count(); $favCount = Db::name('feed_interaction') ->where('feed_type', $type)->where('feed_id', $id)->where('action', 'favorite')->count(); $commentCount = Db::name('feed_interaction') ->where('feed_type', $type)->where('feed_id', $id)->where('action', 'comment')->count(); $shareCount = Db::name('feed_interaction') ->where('feed_type', $type)->where('feed_id', $id)->where('action', 'share')->count(); $item['like_count'] = $likeCount; $item['favorite_count'] = $favCount; $item['comment_count'] = $commentCount; $item['share_count'] = $shareCount; } catch (\Exception $e) { $item['like_count'] = 0; $item['favorite_count'] = 0; $item['comment_count'] = 0; $item['share_count'] = 0; } $userId = $this->_getUserId(); if ($userId) { $interacted = Db::name('feed_interaction') ->where('user_id', $userId) ->where('feed_type', $type) ->where('feed_id', $id) ->column('action'); $item['my_actions'] = $interacted ?: []; $item['is_liked'] = in_array('like', $interacted ?: []); $item['is_favorited'] = in_array('favorite', $interacted ?: []); try { $viewExists = Db::name('feed_interaction') ->where('user_id', $userId) ->where('feed_type', $type) ->where('feed_id', $id) ->where('action', 'view') ->find(); if (!$viewExists) { Db::name('feed_interaction')->insert([ 'user_id' => $userId, 'feed_type' => $type, 'feed_id' => $id, 'action' => 'view', 'createtime' => time(), ]); } } catch (\Exception $e) {} } else { $item['my_actions'] = []; $item['is_liked'] = false; $item['is_favorited'] = false; } $this->success('成功', $item); } /** * @name 互动操作 * @desc 对信息流内容进行点赞/收藏/分享/评论/稍后读/评分等操作 * @param string action 操作类型(like/unlike/favorite/unfavorite/share/dislike/comment/readlater/rating) * @param string feed_type 内容类型 * @param int feed_id 内容ID * @param string extra 扩展数据(JSON: comment内容/rating分数等) */ public function action() { $userId = $this->auth->id; if (!$userId) $this->error('请先登录'); $rawPost = $this->request->post(false); $action = isset($rawPost['action']) ? trim($rawPost['action']) : ''; $feedType = isset($rawPost['feed_type']) ? trim($rawPost['feed_type']) : ''; $feedId = isset($rawPost['feed_id']) ? intval($rawPost['feed_id']) : 0; $extraRaw = isset($rawPost['extra']) ? trim($rawPost['extra']) : ''; $validActions = ['like', 'unlike', 'favorite', 'unfavorite', 'share', 'dislike', 'comment', 'readlater', 'unreadlater', 'rating', 'block', 'unblock', 'report', 'comment_like', 'comment_unlike', 'readtime']; if (!in_array($action, $validActions)) { $this->error('无效操作,可选: ' . implode('/', $validActions)); } if (empty($feedType) || !$feedId) { $this->error('参数错误'); } if (!isset(self::$feedMap[$feedType])) { $this->error('不支持的内容类型'); } $extraData = null; if (!empty($extraRaw)) { $extraData = json_decode($extraRaw, true); if (json_last_error() !== JSON_ERROR_NONE) { $extraData = ['text' => $extraRaw]; } } if ($action === 'comment') { if (empty($extraData) || empty($extraData['content'])) { $this->error('评论内容不能为空'); } $content = mb_substr(trim($extraData['content']), 0, 500); Db::name('feed_interaction')->insert([ 'user_id' => $userId, 'feed_type' => $feedType, 'feed_id' => $feedId, 'action' => 'comment', 'extra' => json_encode(['content' => $content], JSON_UNESCAPED_UNICODE), 'createtime' => time(), ]); $this->_updateProfile($userId, $feedType, 'comment'); $this->_clearFeedCache($feedType, $feedId); $this->success('评论成功', ['action' => 'comment', 'feed_type' => $feedType, 'feed_id' => $feedId]); return; } if ($action === 'rating') { $score = isset($extraData['score']) ? intval($extraData['score']) : 0; if ($score < 1 || $score > 5) { $this->error('评分范围1-5'); } $exists = Db::name('feed_interaction') ->where('user_id', $userId) ->where('feed_type', $feedType) ->where('feed_id', $feedId) ->where('action', 'rating') ->find(); if ($exists) { Db::name('feed_interaction') ->where('id', $exists['id']) ->update(['extra' => json_encode(['score' => $score])]); } else { Db::name('feed_interaction')->insert([ 'user_id' => $userId, 'feed_type' => $feedType, 'feed_id' => $feedId, 'action' => 'rating', 'extra' => json_encode(['score' => $score]), 'createtime' => time(), ]); } $this->_updateProfile($userId, $feedType, 'rating'); $this->_clearFeedCache($feedType, $feedId); $this->success('评分成功', ['action' => 'rating', 'feed_type' => $feedType, 'feed_id' => $feedId, 'score' => $score]); return; } if ($action === 'readlater') { $exists = Db::name('feed_interaction') ->where('user_id', $userId) ->where('feed_type', $feedType) ->where('feed_id', $feedId) ->where('action', 'readlater') ->find(); if ($exists) { $this->error('已在稍后读列表'); } Db::name('feed_interaction')->insert([ 'user_id' => $userId, 'feed_type' => $feedType, 'feed_id' => $feedId, 'action' => 'readlater', 'createtime' => time(), ]); $this->_clearFeedCache($feedType, $feedId); $this->success('已加入稍后读', ['action' => 'readlater', 'feed_type' => $feedType, 'feed_id' => $feedId]); return; } if ($action === 'unreadlater') { Db::name('feed_interaction') ->where('user_id', $userId) ->where('feed_type', $feedType) ->where('feed_id', $feedId) ->where('action', 'readlater') ->delete(); $this->_clearFeedCache($feedType, $feedId); $this->success('已移出稍后读', ['action' => 'unreadlater', 'feed_type' => $feedType, 'feed_id' => $feedId]); return; } $exists = Db::name('feed_interaction') ->where('user_id', $userId) ->where('feed_type', $feedType) ->where('feed_id', $feedId) ->where('action', $action) ->find(); if ($action === 'like' || $action === 'favorite' || $action === 'share' || $action === 'dislike') { if ($exists) { $this->error('已操作过'); } Db::name('feed_interaction')->insert([ 'user_id' => $userId, 'feed_type' => $feedType, 'feed_id' => $feedId, 'action' => $action, 'createtime' => time(), ]); if ($action === 'like') { Db::name(self::$feedMap[$feedType]['table'])->where('id', $feedId)->setInc('views'); } $this->_updateProfile($userId, $feedType, $action); $this->_clearFeedCache($feedType, $feedId); } elseif ($action === 'unlike' || $action === 'unfavorite') { $reverseAction = str_replace('un', '', $action); Db::name('feed_interaction') ->where('user_id', $userId) ->where('feed_type', $feedType) ->where('feed_id', $feedId) ->where('action', $reverseAction) ->delete(); $this->_clearFeedCache($feedType, $feedId); } elseif ($action === 'block') { if ($exists) { $this->error('已屏蔽'); } Db::name('feed_interaction')->insert([ 'user_id' => $userId, 'feed_type' => $feedType, 'feed_id' => $feedId, 'action' => 'block', 'createtime' => time(), ]); $this->_updateProfile($userId, $feedType, 'dislike'); $this->_clearFeedCache($feedType, $feedId); } elseif ($action === 'unblock') { Db::name('feed_interaction') ->where('user_id', $userId) ->where('feed_type', $feedType) ->where('feed_id', $feedId) ->where('action', 'block') ->delete(); $this->_clearFeedCache($feedType, $feedId); } elseif ($action === 'report') { $reason = ''; if (!empty($extraData) && !empty($extraData['reason'])) { $reason = mb_substr(trim($extraData['reason']), 0, 200); } Db::name('feed_interaction')->insert([ 'user_id' => $userId, 'feed_type' => $feedType, 'feed_id' => $feedId, 'action' => 'report', 'extra' => json_encode(['reason' => $reason], JSON_UNESCAPED_UNICODE), 'createtime' => time(), ]); $this->_clearFeedCache($feedType, $feedId); } elseif ($action === 'comment_like') { $commentId = !empty($extraData['comment_id']) ? intval($extraData['comment_id']) : 0; if (!$commentId) { $this->error('需要comment_id'); } $commentExists = Db::name('feed_interaction') ->where('user_id', $userId) ->where('feed_type', $feedType) ->where('feed_id', $feedId) ->where('action', 'comment_like') ->where('extra', 'like', '%"comment_id":' . $commentId . '%') ->find(); if ($commentExists) { $this->error('已点赞该评论'); } Db::name('feed_interaction')->insert([ 'user_id' => $userId, 'feed_type' => $feedType, 'feed_id' => $feedId, 'action' => 'comment_like', 'extra' => json_encode(['comment_id' => $commentId]), 'createtime' => time(), ]); } elseif ($action === 'comment_unlike') { $commentId = !empty($extraData['comment_id']) ? intval($extraData['comment_id']) : 0; if (!$commentId) { $this->error('需要comment_id'); } Db::name('feed_interaction') ->where('user_id', $userId) ->where('feed_type', $feedType) ->where('feed_id', $feedId) ->where('action', 'comment_like') ->where('extra', 'like', '%"comment_id":' . $commentId . '%') ->delete(); } elseif ($action === 'readtime') { $duration = isset($extraData['duration']) ? intval($extraData['duration']) : 0; if ($duration < 1) { $this->error('阅读时长需大于0秒'); } Db::name('feed_interaction')->insert([ 'user_id' => $userId, 'feed_type' => $feedType, 'feed_id' => $feedId, 'action' => 'readtime', 'extra' => json_encode(['duration' => $duration]), 'createtime' => time(), ]); $this->_updateProfile($userId, $feedType, 'like'); } $this->success('操作成功', ['action' => $action, 'feed_type' => $feedType, 'feed_id' => $feedId]); } /** * @name 频道列表 * @desc 获取所有可用的信息流频道 */ /** * @name 关联推荐 * @desc 根据指定内容推荐同类型相关内容,用于详情页底部"相关推荐" * @param string type 内容类型 * @param int id 内容ID * @param int limit 返回数量 */ public function relatedRecommend() { $type = input('get.type', '', 'trim'); $id = input('get.id', 0, 'intval'); $limit = min(20, max(1, input('get.limit', 5, 'intval'))); if (empty($type) || !$id) { $this->error('参数错误,需要type和id'); } if (!isset(self::$feedMap[$type])) { $this->error('不支持的内容类型'); } $config = self::$feedMap[$type]; $source = Db::name($config['table'])->where('id', $id)->find(); if (!$source) { $this->error('内容不存在'); } $items = []; $searchFields = $config['search']; $keywords = []; foreach ($searchFields as $field) { $val = $source[$field] ?? ''; if (!empty($val) && mb_strlen($val) >= 2) { $keywords[] = mb_substr($val, 0, 20); } } if (!empty($keywords)) { $query = Db::name($config['table'])->where('id', '<>', $id); $this->_applyStatus($query, $config); $query->where(function ($q) use ($searchFields, $keywords) { foreach ($searchFields as $field) { foreach ($keywords as $kw) { $q->whereOr($field, 'like', '%' . $kw . '%'); } } }); $rows = $query->field($config['return']) ->order($config['order'] . ' desc') ->limit($limit) ->select(); foreach ($rows as $row) { $items[] = $this->_formatItem($type, $row); } } if (count($items) < $limit) { $need = $limit - count($items); $existIds = array_column($items, 'id'); $existIds[] = $id; $query = Db::name($config['table']) ->where('id', 'not in', $existIds); $this->_applyStatus($query, $config); $extraRows = $query->field($config['return']) ->order($config['order'] . ' desc') ->limit($need) ->select(); foreach ($extraRows as $row) { $items[] = $this->_formatItem($type, $row); } } $this->success('成功', [ 'source_type' => $type, 'source_id' => $id, 'total' => count($items), 'list' => $items, ]); } public function channels() { // 获取平台参数,用于按平台过滤频道 $platform = $this->_getPlatform(); $weightConfig = $this->_getWeightConfig(); $channels = []; $hasEnabledChannels = false; foreach (self::$feedMap as $key => $config) { // 检查权重配置中的启用状态 $isEnabled = true; // 未配置的类型默认启用 if (isset($weightConfig[$key])) { $isEnabled = !empty($weightConfig[$key]['is_enabled']); } // 只返回后台启用的分类 if (!$isEnabled) { continue; } // 按平台过滤:如果指定了平台,检查该分类是否在该平台启用 if (!empty($platform) && !$this->_isPlatformEnabled($key, $platform, $weightConfig)) { continue; } $hasEnabledChannels = true; $count = $this->_countFeedItems($key); $channels[] = [ 'key' => $key, 'name' => $config['name'], 'icon' => $config['icon'], 'count' => $count, 'is_enabled' => true, ]; } // 仅当有启用频道时才添加"推荐"频道 if ($hasEnabledChannels) { $totalCount = 0; foreach ($channels as $ch) { $totalCount += $ch['count']; } array_unshift($channels, ['key' => 'all', 'name' => '推荐', 'icon' => '🔥', 'count' => $totalCount, 'is_enabled' => true]); } $this->success('成功', ['channels' => $channels, 'platform' => $platform ?: 'all']); } /** * @name 热门内容 * @desc 获取各频道热门内容排行 * @param string channel 频道类型(all或具体类型) * @param int limit 返回数量 */ public function trending() { $channel = input('get.channel', 'all', 'trim'); $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $platform = $this->_getPlatform(); $cacheKey = "feed_trending_{$channel}_{$limit}"; if (!empty($platform)) { $cacheKey .= "_{$platform}"; } $cached = Cache::get($cacheKey); if ($cached) { $this->success('成功(cache)', $cached); return; } $items = []; if ($channel === 'all') { $weightConfig = $this->_getWeightConfig(); $enabledTypes = []; foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { continue; } $enabledTypes[] = $type; } } // 指定了平台但无启用分类 → 返回空列表 if (!empty($platform) && empty($enabledTypes)) { $result = ['list' => [], 'channel' => $channel, 'limit' => $limit, 'platform' => $platform]; $this->success('成功', $result); return; } $types = !empty($enabledTypes) ? $enabledTypes : ['poetry', 'wisdom', 'story', 'hitokoto', 'article', 'riddle', 'brainteaser', 'lyric']; $topPerType = max(1, intval($limit / min(5, count($types)))); foreach ($types as $type) { $typeItems = $this->_fetchFeedItems($type, 'hottest', 1, $topPerType); foreach ($typeItems as $item) { $items[] = $item; } } usort($items, function ($a, $b) { return ($b['views'] ?? 0) - ($a['views'] ?? 0); }); $items = array_slice($items, 0, $limit); } else { if (!isset(self::$feedMap[$channel])) { $this->error('不支持的频道'); } // 指定频道时也检查平台过滤 if (!empty($platform)) { $weightConfig = $this->_getWeightConfig(); $isEnabled = true; if (isset($weightConfig[$channel])) { $isEnabled = !empty($weightConfig[$channel]['is_enabled']); } if (!$isEnabled || !$this->_isPlatformEnabled($channel, $platform, $weightConfig)) { $result = ['list' => [], 'channel' => $channel, 'limit' => $limit, 'platform' => $platform]; $this->success('成功', $result); return; } } $items = $this->_fetchFeedItems($channel, 'hottest', 1, $limit); } $result = ['list' => $items, 'channel' => $channel, 'limit' => $limit]; Cache::set($cacheKey, $result, 120); $this->success('成功', $result); } /** * @name 个性化推荐 * @desc 基于用户兴趣画像推荐内容,无需登录也可使用(返回每日精选) * @param int limit 返回数量 * @param string platform 平台标识,按平台过滤启用分类 */ public function recommend() { $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $userId = $this->_getUserId(); $seenIds = input('get.seen_ids', '', 'trim'); $platform = $this->_getPlatform(); $seenMap = []; if (!empty($seenIds)) { $pairs = explode(',', $seenIds); foreach ($pairs as $pair) { $parts = explode('_', $pair, 2); if (count($parts) === 2) { $seenMap[$parts[0]][] = intval($parts[1]); } } } $cacheKey = "feed_recommend_" . ($userId ?: 'guest') . "_{$limit}"; if (!empty($platform)) { $cacheKey .= "_plat_{$platform}"; } $hasSeenIds = !empty($seenMap); if ($hasSeenIds) { ksort($seenMap); $cacheKey .= "_seen_" . md5(json_encode($seenMap)); } $cached = Cache::get($cacheKey); if ($cached && !$hasSeenIds) { $this->success('成功(cache)', $cached); return; } $items = []; $weightConfig = $this->_getWeightConfig(); if ($userId) { $profile = Db::name('feed_profile')->where('user_id', $userId)->find(); if ($profile && !empty($profile['preferred_types'])) { $preferredTypes = json_decode($profile['preferred_types'], true); if (is_array($preferredTypes)) { $combinedWeights = []; foreach ($weightConfig as $type => $wc) { if (!$wc['is_enabled'] || !isset(self::$feedMap[$type])) continue; // 按平台过滤 if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { continue; } $userW = isset($preferredTypes[$type]) ? $preferredTypes[$type] : 0; $adminW = $wc['weight']; $combinedWeights[$type] = $adminW * 0.4 + $userW * 0.6; } arsort($combinedWeights); $totalW = array_sum($combinedWeights); if ($totalW > 0) { foreach ($combinedWeights as $type => $cw) { $typeLimit = max(1, intval($limit * ($cw / $totalW)) + 1); if (isset($weightConfig[$type]) && $weightConfig[$type]['push_limit'] > 0) { $this->_resetPushCount($type, $weightConfig[$type]); $remaining = max(0, $weightConfig[$type]['push_limit'] - $weightConfig[$type]['push_count']); if ($remaining <= 0) continue; $typeLimit = min($typeLimit, $remaining); } $typeItems = $this->_fetchFeedItems($type, 'hottest', 1, $typeLimit); foreach ($typeItems as $item) { $item['_score'] = $cw; $items[] = $item; } } usort($items, function ($a, $b) { return ($b['_score'] ?? 0) - ($a['_score'] ?? 0); }); foreach ($items as &$item) { unset($item['_score']); } unset($item); $items = array_slice($items, 0, $limit); } } } } if (empty($items)) { $enabledTypes = []; $totalWeight = 0; foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { // 按平台过滤 if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { continue; } $enabledTypes[$type] = $wc; $totalWeight += $wc['weight']; } } if (!empty($enabledTypes) && $totalWeight > 0) { $dailySeed = intval(date('Ymd')); foreach ($enabledTypes as $type => $wc) { $typeLimit = max(1, intval($limit * ($wc['weight'] / $totalWeight)) + 1); if ($wc['push_limit'] > 0) { $this->_resetPushCount($type, $wc); $remaining = max(0, $wc['push_limit'] - $wc['push_count']); if ($remaining <= 0) continue; $typeLimit = min($typeLimit, $remaining); } $count = $this->_countFeedItems($type); if ($count == 0) continue; $offset = $dailySeed % max(1, $count); $config = self::$feedMap[$type]; $rows = Db::name($config['table']) ->where($this->_getStatusCondition($type)) ->field($config['return']) ->limit($offset, $typeLimit) ->order($config['order'] . ' desc') ->select(); foreach ($rows as $row) { $item = $this->_formatItem($type, $row); $item['_score'] = $wc['weight']; $items[] = $item; } } usort($items, function ($a, $b) { $scoreDiff = ($b['_score'] ?? 0) - ($a['_score'] ?? 0); if (abs($scoreDiff) > 5) return $scoreDiff; return ($b['views'] ?? 0) - ($a['views'] ?? 0); }); foreach ($items as &$item) { unset($item['_score']); } unset($item); $items = array_slice($items, 0, $limit); } elseif (!empty($platform)) { // 指定了平台但无启用分类 → 返回空列表 $items = []; } else { $dailySeed = intval(date('Ymd')); $mainTypes = ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article']; shuffle($mainTypes); $perType = max(1, intval($limit / count($mainTypes))); foreach ($mainTypes as $type) { $count = $this->_countFeedItems($type); if ($count == 0) continue; $offset = $dailySeed % max(1, $count); $config = self::$feedMap[$type]; $rows = Db::name($config['table']) ->where($this->_getStatusCondition($type)) ->field($config['return']) ->limit($offset, $perType) ->order($config['order'] . ' desc') ->select(); foreach ($rows as $row) { $items[] = $this->_formatItem($type, $row); } } usort($items, function ($a, $b) { return ($b['views'] ?? 0) - ($a['views'] ?? 0); }); $items = array_slice($items, 0, $limit); } } if (!empty($seenMap)) { $items = array_filter($items, function ($item) use ($seenMap) { $type = $item['feed_type'] ?? ''; $id = $item['id'] ?? 0; if (isset($seenMap[$type]) && in_array($id, $seenMap[$type])) { return false; } return true; }); $items = array_values($items); if (count($items) < $limit) { $need = $limit - count($items); $existIds = []; foreach ($items as $it) { $existIds[$it['feed_type'] ?? ''][] = $it['id']; } $extraItems = $this->_fetchExcludingSeen($seenMap, $existIds, 'hottest', $need); $items = array_merge($items, $extraItems); } $items = array_slice($items, 0, $limit); } $result = ['list' => $items, 'limit' => $limit, 'personalized' => $userId ? true : false]; if (!$hasSeenIds) { Cache::set($cacheKey, $result, 90); } $this->success('成功', $result); } /** * @name 随机内容 * @desc 获取随机信息流内容,每次请求返回不同内容,适合快速下滑刷新 * @param string channel 频道类型(all或具体类型) * @param int limit 返回数量(1-30) * @param int seed 随机种子(可选,相同种子返回相同内容,用于翻页一致性) * @param string platform 平台标识,按平台过滤启用分类 */ public function random() { $channel = input('get.channel', 'all', 'trim'); $limit = min(30, max(1, input('get.limit', 10, 'intval'))); $seed = input('get.seed', '', 'trim'); $platform = $this->_getPlatform(); if ($seed === '') { $seed = uniqid('rnd_', true); } $cacheKey = "feed_random_{$channel}_{$limit}_" . md5($seed); if (!empty($platform)) { $cacheKey .= "_{$platform}"; } $cached = Cache::get($cacheKey); if ($cached) { $this->success('成功(cache)', $cached); return; } $items = []; if ($channel === 'all') { $weightConfig = $this->_getWeightConfig(); $enabledTypes = []; foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { continue; } $enabledTypes[] = $type; } } // 指定了平台但无启用分类 → 返回空列表 if (!empty($platform) && empty($enabledTypes)) { $result = ['list' => [], 'channel' => $channel, 'limit' => $limit, 'seed' => $seed, 'platform' => $platform]; $this->success('成功', $result); return; } $types = !empty($enabledTypes) ? $enabledTypes : array_keys(self::$feedMap); $seedIdx = crc32($seed); $selectedTypes = []; for ($i = 0; $i < min(6, count($types)); $i++) { $idx = abs($seedIdx + $i * 7) % count($types); $selectedTypes[] = $types[$idx]; } $perType = max(1, intval($limit / count($selectedTypes)) + 1); foreach ($selectedTypes as $type) { $typeItems = $this->_fetchRandomItems($type, $perType, $seed); foreach ($typeItems as $item) { $items[] = $item; } } shuffle($items); $items = array_slice($items, 0, $limit); } else { if (!isset(self::$feedMap[$channel])) { $this->error('不支持的频道'); } // 指定频道时也检查平台过滤 if (!empty($platform)) { $weightConfig = $this->_getWeightConfig(); $isEnabled = true; if (isset($weightConfig[$channel])) { $isEnabled = !empty($weightConfig[$channel]['is_enabled']); } if (!$isEnabled || !$this->_isPlatformEnabled($channel, $platform, $weightConfig)) { $result = ['list' => [], 'channel' => $channel, 'limit' => $limit, 'seed' => $seed, 'platform' => $platform]; $this->success('成功', $result); return; } } $items = $this->_fetchRandomItems($channel, $limit, $seed); } $result = [ 'list' => $items, 'channel' => $channel, 'limit' => $limit, 'seed' => $seed, ]; Cache::set($cacheKey, $result, 30); $this->success('成功', $result); } /** * @name 信息流搜索 * @desc 跨频道搜索内容,支持关键词匹配 * @param string keyword 搜索关键词 * @param string channel 频道类型(all或具体类型) * @param int page 页码 * @param int limit 每页数量 */ public function search() { $rawGet = $this->request->get(false); $keyword = isset($rawGet['keyword']) ? trim($rawGet['keyword']) : ''; $channel = isset($rawGet['channel']) ? trim($rawGet['channel']) : 'all'; $page = max(1, isset($rawGet['page']) ? intval($rawGet['page']) : 1); $limit = min(50, max(1, isset($rawGet['limit']) ? intval($rawGet['limit']) : 20)); if (empty($keyword) || mb_strlen($keyword) < 1) { $this->error('请输入搜索关键词'); } if (mb_strlen($keyword) > 50) { $this->error('关键词最长50个字符'); } $cacheKey = "feed_search_" . md5($keyword . $channel . $page . $limit); $cached = Cache::get($cacheKey); if ($cached) { $this->success('成功(cache)', $cached); return; } $items = []; $types = ($channel === 'all') ? array_keys(self::$feedMap) : [$channel]; if ($channel === 'all') { $types = ['poetry', 'wisdom', 'story', 'hitokoto', 'lyric', 'article', 'riddle', 'brainteaser', 'saying', 'efs']; } $searchedCount = 0; $maxSearchTypes = 8; foreach ($types as $type) { if ($searchedCount >= $maxSearchTypes) break; if (!isset(self::$feedMap[$type])) continue; $config = self::$feedMap[$type]; $searchFields = $config['search'] ?? []; if (empty($searchFields)) continue; try { $query = Db::name($config['table'])->where($this->_getStatusCondition($type)); $query->where(function ($q) use ($searchFields, $keyword) { foreach ($searchFields as $field) { $q->whereOr($field, 'like', '%' . $keyword . '%'); } }); $rows = $query->field($config['return']) ->order('views desc') ->page($page, $limit) ->select(); foreach ($rows as $row) { $items[] = $this->_formatItem($type, $row); } } catch (\Exception $e) { continue; } $searchedCount++; } usort($items, function ($a, $b) { return ($b['views'] ?? 0) - ($a['views'] ?? 0); }); $total = count($items); $items = array_slice($items, 0, $limit); $result = [ 'list' => $items, 'total' => $total, 'keyword' => $keyword, 'channel' => $channel, 'page' => $page, 'limit' => $limit, ]; Cache::set($cacheKey, $result, 120); $this->success('成功', $result); } /** * @name 收藏列表 * @desc 获取当前用户的收藏内容列表 * @param int page 页码 * @param int limit 每页数量 */ public function favorites() { $userId = $this->auth->id; if (!$userId) $this->error('请先登录'); $page = max(1, input('get.page', 1, 'intval')); $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $interactions = Db::name('feed_interaction') ->where('user_id', $userId) ->where('action', 'favorite') ->order('createtime desc') ->page($page, $limit) ->select(); $total = Db::name('feed_interaction') ->where('user_id', $userId) ->where('action', 'favorite') ->count(); $items = $this->_batchLoadFeedItems($interactions, 'favorited_at'); $this->success('成功', [ 'list' => $items, 'total' => $total, 'page' => $page, 'limit' => $limit, ]); } /** * @name 点赞列表 * @desc 获取当前用户的点赞内容列表 * @param int page 页码 * @param int limit 每页数量 */ public function likes() { $userId = $this->auth->id; if (!$userId) $this->error('请先登录'); $page = max(1, input('get.page', 1, 'intval')); $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $interactions = Db::name('feed_interaction') ->where('user_id', $userId) ->where('action', 'like') ->order('createtime desc') ->page($page, $limit) ->select(); $total = Db::name('feed_interaction') ->where('user_id', $userId) ->where('action', 'like') ->count(); $items = $this->_batchLoadFeedItems($interactions, 'liked_at'); $this->success('成功', [ 'list' => $items, 'total' => $total, 'page' => $page, 'limit' => $limit, ]); } /** * @name 浏览历史 * @desc 获取当前用户的浏览历史记录 * @param int page 页码 * @param int limit 每页数量 */ public function history() { $userId = $this->auth->id; if (!$userId) $this->error('请先登录'); $page = max(1, input('get.page', 1, 'intval')); $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $interactions = Db::name('feed_interaction') ->where('user_id', $userId) ->where('action', 'view') ->order('createtime desc') ->page($page, $limit) ->select(); $total = Db::name('feed_interaction') ->where('user_id', $userId) ->where('action', 'view') ->count(); $items = $this->_batchLoadFeedItems($interactions, 'viewed_at'); $this->success('成功', [ 'list' => $items, 'total' => $total, 'page' => $page, 'limit' => $limit, ]); } /** * @name 稍后读列表 * @desc 获取用户稍后读列表 */ public function readlater() { $userId = $this->auth->id; if (!$userId) $this->error('请先登录'); $page = max(1, input('get.page', 1, 'intval')); $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $interactions = Db::name('feed_interaction') ->where('user_id', $userId) ->where('action', 'readlater') ->order('createtime desc') ->page($page, $limit) ->select(); $total = Db::name('feed_interaction') ->where('user_id', $userId) ->where('action', 'readlater') ->count(); $items = $this->_batchLoadFeedItems($interactions, 'added_at'); $this->success('成功', [ 'list' => $items, 'total' => $total, 'page' => $page, 'limit' => $limit, ]); } /** * @name 评论列表 * @desc 获取指定内容的评论列表,无需登录 * @param string feed_type 内容类型 * @param int feed_id 内容ID */ public function comments() { $rawGet = $this->request->get(false); $feedType = isset($rawGet['feed_type']) ? trim($rawGet['feed_type']) : ''; $feedId = isset($rawGet['feed_id']) ? intval($rawGet['feed_id']) : 0; $page = max(1, isset($rawGet['page']) ? intval($rawGet['page']) : 1); $limit = min(50, max(1, isset($rawGet['limit']) ? intval($rawGet['limit']) : 20)); if (empty($feedType) || !$feedId) { $this->error('参数错误,需要feed_type和feed_id'); } $query = Db::name('feed_interaction') ->where('feed_type', $feedType) ->where('feed_id', $feedId) ->where('action', 'comment') ->order('createtime desc'); $total = $query->count(); $comments = $query->page($page, $limit)->select(); $list = []; foreach ($comments as $c) { $userInfo = Db::name('user')->where('id', $c['user_id'])->field('id,username,nickname,avatar')->find(); $extraData = json_decode($c['extra'] ?? '{}', true); $likeCount = Db::name('feed_interaction') ->where('feed_type', $feedType) ->where('feed_id', $feedId) ->where('action', 'comment_like') ->where('extra', 'like', '%"comment_id":' . $c['id'] . '%') ->count(); $commentItem = [ 'id' => $c['id'], 'user_id' => $c['user_id'], 'username' => $userInfo ? ($userInfo['nickname'] ?: $userInfo['username']) : '匿名', 'avatar' => $userInfo ? ($userInfo['avatar'] ?: '') : '', 'content' => isset($extraData['content']) ? $extraData['content'] : '', 'like_count' => $likeCount, 'createtime' => $c['createtime'], ]; $list[] = $commentItem; } $this->success('成功', [ 'list' => $list, 'total' => $total, 'feed_type' => $feedType, 'feed_id' => $feedId, 'page' => $page, 'limit' => $limit, ]); } /** * @name 下拉刷新检查 * @desc 检查指定频道是否有新内容,用于客户端下拉刷新判断 * @param string channel 频道类型 * @param int since_id 客户端已知的最新ID */ public function refresh() { $channel = input('get.channel', 'all', 'trim'); $sinceId = input('get.since_id', 0, 'intval'); $platform = $this->_getPlatform(); if ($sinceId <= 0) { $this->error('需要since_id参数'); } $newCount = 0; $latestId = $sinceId; if ($channel === 'all') { $weightConfig = $this->_getWeightConfig(); $enabledTypes = []; foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { continue; } $enabledTypes[] = $type; } } // 指定了平台但无启用分类 → 返回无新内容 if (!empty($platform) && empty($enabledTypes)) { $this->success('成功', [ 'has_new' => false, 'new_count' => 0, 'latest_id' => $sinceId, 'channel' => $channel, 'platform' => $platform, ]); return; } $types = !empty($enabledTypes) ? $enabledTypes : ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article']; foreach ($types as $type) { if (!isset(self::$feedMap[$type])) continue; $config = self::$feedMap[$type]; try { $maxId = Db::name($config['table'])->max('id'); if ($maxId > $latestId) { $latestId = $maxId; } $cnt = Db::name($config['table'])->where('id', '>', $sinceId)->count(); $newCount += $cnt; } catch (\Exception $e) {} } } else { if (!isset(self::$feedMap[$channel])) { $this->error('不支持的频道'); } // 指定频道时也检查平台过滤 if (!empty($platform)) { $weightConfig = $this->_getWeightConfig(); $isEnabled = true; if (isset($weightConfig[$channel])) { $isEnabled = !empty($weightConfig[$channel]['is_enabled']); } if (!$isEnabled || !$this->_isPlatformEnabled($channel, $platform, $weightConfig)) { $this->success('成功', [ 'has_new' => false, 'new_count' => 0, 'latest_id' => $sinceId, 'channel' => $channel, 'platform' => $platform, ]); return; } } $config = self::$feedMap[$channel]; try { $maxId = Db::name($config['table'])->max('id'); if ($maxId > $latestId) { $latestId = $maxId; } $newCount = Db::name($config['table'])->where('id', '>', $sinceId)->count(); } catch (\Exception $e) {} } $this->success('成功', [ 'has_new' => $newCount > 0, 'new_count' => $newCount, 'latest_id' => $latestId, 'channel' => $channel, ]); } /** * @name 刷新获取新内容 * @desc 专用刷新接口,接收已看ID列表,保证返回内容不重复。不缓存。 * @param string channel 频道类型(all/poetry/wisdom等) * @param string sort 排序(newest/hottest) * @param int limit 请求数量(默认20,最大50) * @param string seen_ids 已看ID列表,格式: type_id,type_id (如 poetry_1,poetry_2,wisdom_5) * @param string seen_hashes 已看内容hash列表(MD5前8位,逗号分隔),用于内容去重 * @param string platform 平台标识,按平台过滤启用分类 */ public function refresh_content() { $channel = input('get.channel', 'all', 'trim'); $sort = input('get.sort', 'newest', 'trim'); $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $seenIds = input('get.seen_ids', '', 'trim'); $seenHashes = input('get.seen_hashes', '', 'trim'); $platform = $this->_getPlatform(); $seenMap = []; if (!empty($seenIds)) { $pairs = explode(',', $seenIds); foreach ($pairs as $pair) { $parts = explode('_', $pair, 2); if (count($parts) === 2) { $seenMap[$parts[0]][] = intval($parts[1]); } } } $hashSet = []; if (!empty($seenHashes)) { $hashSet = array_filter(array_map('trim', explode(',', $seenHashes))); } $items = []; if ($channel === 'all') { // 按平台过滤获取启用分类 $enabledTypes = null; if (!empty($platform)) { $weightConfig = $this->_getWeightConfig(); $enabledTypes = []; foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { if (!$this->_isPlatformEnabled($type, $platform, $weightConfig)) { continue; } $enabledTypes[] = $type; } } // 指定了平台但无启用分类 → 返回空列表 if (empty($enabledTypes)) { $this->success('成功', ['list' => [], 'total' => 0, 'channel' => $channel, 'sort' => $sort, 'platform' => $platform]); return; } } $items = $this->_fetchExcludingSeen($seenMap, [], $sort, $limit); // 按平台过滤结果 if (!empty($platform) && !empty($enabledTypes) && !empty($items)) { $items = array_filter($items, function ($item) use ($enabledTypes) { return in_array($item['feed_type'] ?? '', $enabledTypes); }); $items = array_values($items); } if (empty($items)) { $mainTypes = !empty($enabledTypes) ? $enabledTypes : ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article', 'efs', 'saying']; $perType = max(1, intval($limit / count($mainTypes)) + 1); foreach ($mainTypes as $type) { try { $excludeIds = isset($seenMap[$type]) ? $seenMap[$type] : []; $config = self::$feedMap[$type]; $query = Db::name($config['table'])->where($this->_getStatusCondition($type)); if (!empty($excludeIds)) { $query->where('id', 'not in', $excludeIds); } $orderField = $sort === 'hottest' ? 'views' : $config['order']; $rows = $query->field($config['return']) ->order($orderField . ' desc') ->limit($perType) ->select(); foreach ($rows as $row) { $items[] = $this->_formatItem($type, $row); } } catch (\Exception $e) {} } usort($items, function ($a, $b) use ($sort) { if ($sort === 'hottest') { return ($b['views'] ?? 0) - ($a['views'] ?? 0); } return ($b['id'] ?? 0) - ($a['id'] ?? 0); }); $items = array_slice($items, 0, $limit); } } else { if (!isset(self::$feedMap[$channel])) { $this->error('不支持的频道: ' . $channel); } // 指定频道时也检查平台过滤 if (!empty($platform)) { $weightConfig = $this->_getWeightConfig(); $isEnabled = true; if (isset($weightConfig[$channel])) { $isEnabled = !empty($weightConfig[$channel]['is_enabled']); } if (!$isEnabled || !$this->_isPlatformEnabled($channel, $platform, $weightConfig)) { $this->success('成功', ['list' => [], 'total' => 0, 'channel' => $channel, 'sort' => $sort, 'platform' => $platform]); return; } } $excludeIds = isset($seenMap[$channel]) ? $seenMap[$channel] : []; $config = self::$feedMap[$channel]; try { $query = Db::name($config['table'])->where($this->_getStatusCondition($channel)); if (!empty($excludeIds)) { $query->where('id', 'not in', $excludeIds); } $orderField = $sort === 'hottest' ? 'views' : $config['order']; $rows = $query->field($config['return']) ->order($orderField . ' desc') ->limit($limit) ->select(); foreach ($rows as $row) { $items[] = $this->_formatItem($channel, $row); } } catch (\Exception $e) { $this->error('获取内容失败'); } } if (!empty($hashSet)) { $items = array_filter($items, function ($item) use ($hashSet) { $text = trim($item['content'] ?? '' . $item['title'] ?? ''); if (empty($text)) return true; $hash = substr(md5($text), 0, 8); return !in_array($hash, $hashSet); }); $items = array_values($items); } $result = [ 'list' => $items, 'total' => count($items), 'channel' => $channel, 'sort' => $sort, 'limit' => $limit, ]; $this->success('成功', $result); } /** * @name 用户偏好设置 * @desc 获取/保存用户Feed偏好设置(频道开关/混合规则/去重/排序) * @param string action 操作: get(获取)/save(保存) * @param string data JSON格式偏好数据(save时必传) */ public function preferences() { $action = input('action', 'get', 'trim'); if ($action === 'get') { $userId = $this->_getUserId(); if (!$userId) { $this->success('未登录', [ 'disabled_channels' => [], 'mix_mode' => 'random', 'mix_channels' => [], 'mix_ratios' => new \stdClass(), 'group_size' => 3, 'deduplicate' => true, 'sort' => 'newest', 'home_card_mode' => 'random', 'home_card_channels' => [], 'per_page' => 20, ]); return; } try { $pref = Db::name('feed_preferences')->where('user_id', $userId)->find(); if ($pref) { $data = json_decode($pref['preferences'], true); if (!is_array($data)) $data = []; $this->success('成功', $data); } else { $this->success('无偏好记录', [ 'disabled_channels' => [], 'mix_mode' => 'random', 'mix_channels' => [], 'mix_ratios' => new \stdClass(), 'group_size' => 3, 'deduplicate' => true, 'sort' => 'newest', 'home_card_mode' => 'random', 'home_card_channels' => [], 'per_page' => 20, ]); } } catch (\Exception $e) { $this->error('获取偏好失败'); } return; } if ($action === 'save') { $userId = $this->_getUserId(); if (!$userId) { $this->error('请先登录', null, 401); } $rawData = input('data', '', 'trim'); if (empty($rawData)) { $this->error('偏好数据不能为空'); } $data = json_decode($rawData, true); if (!is_array($data)) { $this->error('偏好数据格式错误'); } $allowedKeys = ['disabled_channels', 'mix_mode', 'mix_channels', 'mix_ratios', 'group_size', 'deduplicate', 'sort', 'home_card_mode', 'home_card_channels', 'per_page']; $filtered = []; foreach ($data as $k => $v) { if (in_array($k, $allowedKeys)) { $filtered[$k] = $v; } } try { $exists = Db::name('feed_preferences')->where('user_id', $userId)->find(); if ($exists) { Db::name('feed_preferences')->where('user_id', $userId)->update([ 'preferences' => json_encode($filtered), 'updatetime' => time(), ]); } else { Db::name('feed_preferences')->insert([ 'user_id' => $userId, 'preferences' => json_encode($filtered), 'createtime' => time(), 'updatetime' => time(), ]); } $this->success('保存成功', $filtered); } catch (\Exception $e) { $this->error('保存偏好失败'); } return; } $this->error('不支持的操作'); } /** * @name 信息流统计 * @desc 获取信息流各频道内容数量和互动统计,无需登录 */ public function stats() { $cacheKey = 'feed_stats'; $cached = Cache::get($cacheKey); if ($cached) { $this->success('成功(cache)', $cached); return; } // 获取权重配置,用于过滤启用状态 $weightConfig = $this->_getWeightConfig(); $channelStats = []; $totalContent = 0; $totalViews = 0; $enabledCount = 0; foreach (self::$feedMap as $key => $config) { // 检查权重配置中的启用状态 $isEnabled = true; if (isset($weightConfig[$key])) { $isEnabled = !empty($weightConfig[$key]['is_enabled']); } // 只返回后台启用的分类统计 if (!$isEnabled) { continue; } $enabledCount++; $count = $this->_countFeedItems($key); $views = 0; try { $views = intval(Db::name($config['table'])->where($this->_getStatusCondition($key))->sum('views')); } catch (\Exception $e) {} $channelStats[] = [ 'key' => $key, 'name' => $config['name'], 'icon' => $config['icon'], 'count' => $count, 'views' => $views, ]; $totalContent += $count; $totalViews += $views; } $interactionStats = []; try { $interactionStats = Db::name('feed_interaction') ->group('action') ->field('action, count(*) as cnt') ->select(); } catch (\Exception $e) { $interactionStats = []; } $result = [ 'total_content' => $totalContent, 'total_views' => $totalViews, 'channel_count' => $enabledCount, 'channels' => $channelStats, 'interactions' => $interactionStats, 'updated_at' => date('Y-m-d H:i:s'), ]; Cache::set($cacheKey, $result, 300); $this->success('成功', $result); } /** * @name 获取随机内容(高效) * @desc 使用ID范围随机取内容,避免ORDER BY RAND()性能问题 */ private function _fetchRandomItems($type, $limit, $seed) { if (!isset(self::$feedMap[$type])) return []; $config = self::$feedMap[$type]; $table = $config['table']; try { $maxId = Db::name($table)->max('id'); $minId = Db::name($table)->min('id'); if (!$maxId || !$minId) return []; $range = $maxId - $minId; if ($range <= 0) { $rows = Db::name($table)->where($this->_getStatusCondition($type)) ->field($config['return'])->limit($limit)->select(); } else { $seedNum = abs(crc32($seed)); $randomIds = []; $attempts = 0; $maxAttempts = $limit * 3; while (count($randomIds) < $limit && $attempts < $maxAttempts) { $randomId = $minId + ($seedNum + $attempts * 7919) % ($range + 1); if (!in_array($randomId, $randomIds)) { $randomIds[] = $randomId; } $attempts++; } if (empty($randomIds)) { $rows = Db::name($table)->where($this->_getStatusCondition($type)) ->field($config['return'])->orderRaw('RAND()')->limit($limit)->select(); } else { $rows = Db::name($table) ->where($this->_getStatusCondition($type)) ->where('id', 'in', $randomIds) ->field($config['return']) ->select(); if (count($rows) < $limit) { $existingIds = array_column($rows, 'id'); $extraRows = Db::name($table) ->where($this->_getStatusCondition($type)) ->where('id', 'not in', $existingIds) ->field($config['return']) ->orderRaw('RAND()') ->limit($limit - count($rows)) ->select(); $rows = array_merge($rows, $extraRows); } } } } catch (\Exception $e) { return []; } $items = []; foreach ($rows as $row) { $items[] = $this->_formatItem($type, $row); } return $items; } private function _fetchFeedItems($type, $sort, $page, $limit, $lastId = 0) { if (!isset(self::$feedMap[$type])) return []; $config = self::$feedMap[$type]; $query = Db::name($config['table'])->where($this->_getStatusCondition($type)); if ($lastId > 0 && $sort === 'newest') { $query->where('id', '<', $lastId); } $orderField = $config['order']; $orderDir = 'desc'; if ($sort === 'hottest') { $orderField = 'views'; } elseif ($sort === 'newest') { $orderField = 'id'; } try { $rows = $query->field($config['return']) ->order($orderField . ' ' . $orderDir) ->page($page, $limit) ->select(); } catch (\Exception $e) { try { $rows = Db::name($config['table']) ->where($this->_getStatusCondition($type)) ->field($config['return']) ->order('id desc') ->page($page, $limit) ->select(); } catch (\Exception $e2) { return []; } } $items = []; foreach ($rows as $row) { $items[] = $this->_formatItem($type, $row); } return $items; } private function _countFeedItems($type) { if (!isset(self::$feedMap[$type])) return 0; $cacheKey = "feed_count_{$type}"; $cached = Cache::get($cacheKey); if ($cached !== false) return $cached; try { $count = Db::name(self::$feedMap[$type]['table'])->where($this->_getStatusCondition($type))->count(); } catch (\Exception $e) { $count = 0; } Cache::set($cacheKey, $count, 600); return $count; } private function _applyStatus(&$query, $config) { $type = array_search($config, self::$feedMap, true); if ($type === false) { foreach (self::$feedMap as $k => $v) { if ($v === $config) { $type = $k; break; } } } $cond = $this->_getStatusCondition($type ?: ''); if (!empty($cond)) { $query->where($cond); } } private function _getStatusCondition($type) { if ($type === 'hitokoto') { return ['switch' => 1]; } if ($type === 'article') { return ['status' => 'normal']; } if ($type === 'wine') { return ['switch' => 1]; } return []; } /** * @name 加载单条Feed内容 * @desc 根据feed_type和feed_id从对应表加载内容 */ private function _loadFeedItem($feedType, $feedId) { if (!isset(self::$feedMap[$feedType])) return null; $config = self::$feedMap[$feedType]; try { $row = Db::name($config['table'])->where('id', $feedId)->find(); if (!$row) return null; return $this->_formatItem($feedType, $row); } catch (\Exception $e) { return null; } } private function _formatItem($type, $row) { $config = self::$feedMap[$type]; $item = [ 'feed_type' => $type, 'feed_name' => $config['name'], 'feed_icon' => $config['icon'], 'id' => $row['id'] ?? 0, 'title' => '', 'author' => '', 'content' => '', 'summary' => '', 'views' => intval($row['views'] ?? 0), 'createtime' => isset($row['createtime']) ? intval($row['createtime']) : 0, 'updatetime' => isset($row['updatetime']) ? intval($row['updatetime']) : 0, 'extra' => new \stdClass(), ]; switch ($type) { case 'poetry': $item['title'] = $row['title'] ?? ($row['name'] ?? ''); $item['author'] = $row['author'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr($row['content'] ?? '', 0, 100); $item['extra'] = [ 'dynasty' => $row['dynasty'] ?? '', 'tag' => $row['tag'] ?? '', 'ywjzsy' => $row['ywjzsy'] ?? '', 'ywckzly' => $row['ywckzly'] ?? '', 'czbj' => $row['czbj'] ?? '', 'czckzl' => $row['czckzl'] ?? '', 'jsy' => $row['jsy'] ?? '', 'jsckzly' => $row['jsckzly'] ?? '', 'sxy' => $row['sxy'] ?? '', 'jx' => $row['jx'] ?? '', 'sxckzly' => $row['sxckzly'] ?? '', 'pj' => $row['pj'] ?? '', 'sxe' => $row['sxe'] ?? '', 'sxckzle' => $row['sxckzle'] ?? '', 'ywjzse' => $row['ywjzse'] ?? '', 'ywckzle' => $row['ywckzle'] ?? '', 'jse' => $row['jse'] ?? '', 'jsckzle' => $row['jsckzle'] ?? '', 'jj' => $row['jj'] ?? '', 'wyzs' => $row['wyzs'] ?? '', 'yj' => $row['yj'] ?? '', 'xzsf' => $row['xzsf'] ?? '', 'xzckzl' => $row['xzckzl'] ?? '', 'dj' => $row['dj'] ?? '', ]; break; case 'wisdom': $item['author'] = $row['author'] ?? ($row['name'] ?? ''); $item['content'] = $row['content'] ?? ''; $item['title'] = mb_substr($row['content'] ?? '', 0, 30) . '...'; $item['summary'] = mb_substr($row['content'] ?? '', 0, 100); break; case 'story': $item['title'] = $row['title'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr($row['content'] ?? '', 0, 100); break; case 'hitokoto': $item['content'] = $row['content'] ?? ($row['hitokoto'] ?? ''); $item['author'] = $row['author'] ?? ($row['from_who'] ?? ''); $item['title'] = mb_substr($item['content'], 0, 30); $item['summary'] = mb_substr($item['content'], 0, 100); $item['extra'] = [ 'from_source' => $row['from_source'] ?? '', 'type_name' => $row['type_name'] ?? '', 'hitokoto' => $row['hitokoto'] ?? '', 'from_who' => $row['from_who'] ?? '', 'uuid' => $row['uuid'] ?? '', ]; break; case 'riddle': $item['title'] = $row['title'] ?? ($row['riddle'] ?? ''); $item['content'] = $row['answer'] ?? ($row['interpret'] ?? ''); $item['summary'] = $item['title']; $item['extra'] = [ 'hint' => $row['hint'] ?? ($row['miidii'] ?? ''), 'riddle' => $row['riddle'] ?? '', 'interpret' => $row['interpret'] ?? '', ]; break; case 'efs': $item['title'] = $row['title'] ?? ($row['facet'] ?? ''); $item['content'] = $row['content'] ?? ($row['undertone'] ?? ''); $item['summary'] = $item['title'] . ' —— ' . $item['content']; $item['extra'] = [ 'facet' => $row['facet'] ?? '', 'undertone' => $row['undertone'] ?? '', ]; break; case 'brainteaser': $item['title'] = $row['title'] ?? ($row['topic'] ?? ''); $item['content'] = $row['answer'] ?? ''; $item['summary'] = $item['title']; $item['extra'] = [ 'topic' => $row['topic'] ?? '', 'answer' => $row['answer'] ?? '', ]; break; case 'saying': $item['content'] = $row['content'] ?? ''; $item['title'] = mb_substr(strip_tags($item['content']), 0, 30); $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'saying' => $row['saying'] ?? '', ]; break; case 'lyric': $item['title'] = $row['title'] ?? ''; $item['author'] = $row['author'] ?? ($row['singer'] ?? ''); $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr($row['content'] ?? '', 0, 100); break; case 'why': $item['title'] = $row['title'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr($row['content'] ?? '', 0, 100); break; case 'composition': $item['title'] = $row['title'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr($row['content'] ?? '', 0, 100); break; case 'couplet': $item['title'] = ($row['upper'] ?? ($row['hp'] ?? '')) . ' / ' . ($row['lower'] ?? ($row['sl'] ?? '')); $item['content'] = ($row['horizontal'] ?? ($row['xl'] ?? '')); $item['summary'] = $item['title']; $item['extra'] = [ 'hp' => $row['hp'] ?? '', 'sl' => $row['sl'] ?? '', 'xl' => $row['xl'] ?? '', 'yy' => $row['yy'] ?? '', ]; break; case 'cs': $item['title'] = $row['title'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr($row['content'] ?? '', 0, 100); break; case 'drug': $item['title'] = $row['name'] ?? ''; $item['content'] = $row['syz'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'name' => $row['name'] ?? '', 'goods_name' => $row['goods_name'] ?? '', 'syz' => $row['syz'] ?? '', 'gg' => $row['gg'] ?? '', 'cf' => $row['cf'] ?? '', 'yfyl' => $row['yfyl'] ?? '', 'blfy' => $row['blfy'] ?? '', 'jj' => $row['jj'] ?? '', 'zysx' => $row['zysx'] ?? '', 'pzwh' => $row['pzwh'] ?? '', 'scqy' => $row['scqy'] ?? '', ]; break; case 'herbal': $item['title'] = $row['name'] ?? ''; $item['content'] = $row['effect'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'effect' => $row['effect'] ?? '', 'name_alias' => $row['name_alias'] ?? '', 'image' => $row['image'] ?? '', 'spell' => $row['spell'] ?? '', 'english_name' => $row['english_name'] ?? '', 'medicinal_parts' => $row['medicinal_parts'] ?? '', 'plant_morphology' => $row['plant_morphology'] ?? '', 'origin_distribution' => $row['origin_distribution'] ?? '', 'harvest_processing' => $row['harvest_processing'] ?? '', 'drug_properties' => $row['drug_properties'] ?? '', 'tropism_taste' => $row['tropism_taste'] ?? '', 'clinic' => $row['clinic'] ?? '', 'pharmacology' => $row['pharmacology'] ?? '', 'chemical_component' => $row['chemical_component'] ?? '', 'usage_taboo' => $row['usage_taboo'] ?? '', 'prescription' => $row['prescription'] ?? '', ]; break; case 'food': $item['title'] = $row['sw'] ?? ''; $item['content'] = $row['yh'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'sw' => $row['sw'] ?? '', 'yh' => $row['yh'] ?? '', ]; break; case 'wine': $item['title'] = $row['name'] ?? ''; $item['content'] = $row['effect'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'name' => $row['name'] ?? '', 'effect' => $row['effect'] ?? '', 'source' => $row['source'] ?? '', 'stock' => $row['stock'] ?? '', 'make' => $row['make'] ?? '', 'usages' => $row['usages'] ?? '', 'taboo' => $row['taboo'] ?? '', ]; break; case 'article': $item['title'] = $row['title'] ?? ''; $item['content'] = $row['summary'] ?? ''; $item['summary'] = $row['summary'] ?? ''; $item['likes'] = intval($row['likes'] ?? 0); $item['extra'] = [ 'user_id' => $row['user_id'] ?? 0, 'image' => $row['image'] ?? '', 'tags' => $row['tags'] ?? '', 'favorites' => intval($row['favorites'] ?? 0), 'comments' => intval($row['comments'] ?? 0), 'rating_sum' => intval($row['rating_sum'] ?? 0), 'rating_count' => intval($row['rating_count'] ?? 0), ]; break; case 'chengyu': $item['title'] = $row['cy'] ?? ''; $item['content'] = $row['cyjs'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'cy' => $row['cy'] ?? '', 'cypy' => $row['cypy'] ?? '', 'cyzy' => $row['cyzy'] ?? '', 'cycx' => $row['cycx'] ?? '', 'cynd' => $row['cynd'] ?? '', 'ywfy' => $row['ywfy'] ?? '', 'cyjs' => $row['cyjs'] ?? '', 'cycc' => $row['cycc'] ?? '', 'cylz' => $row['cylz'] ?? '', 'pinyin' => $row['cypy'] ?? '', 'origin' => $row['cyzy'] ?? '', 'example' => $row['cylz'] ?? '', 'synonym' => $row['cycx'] ?? '', 'antonym' => $row['cynd'] ?? '', 'grammar' => $row['ywfy'] ?? '', 'usage' => $row['cycc'] ?? '', ]; break; case 'hanzi': $item['title'] = $row['zi'] ?? ''; $item['content'] = $row['jj'] ?? ''; $item['summary'] = $item['title']; $item['views'] = intval($row['views'] ?? ($row['pv'] ?? 0)); $item['extra'] = [ 'image' => $row['image'] ?? '', 'smallimage' => $row['smallimage'] ?? '', 'pinyin' => $row['pinyin'] ?? '', 'py' => $row['py'] ?? '', 'wubi' => $row['wubi'] ?? '', 'bushou' => $row['bushou'] ?? '', 'bihua' => $row['bihua'] ?? 0, 'bishun' => $row['bishun'] ?? '', 'fantizi' => $row['fantizi'] ?? '', 'zy' => $row['zy'] ?? '', 'wuxing' => $row['wuxing'] ?? '', 'jg' => $row['jg'] ?? '', 'unicode' => $row['unicode'] ?? '', 'bishunm' => $row['bishunm'] ?? '', 'xiefa' => $row['xiefa'] ?? '', 'ziyi' => $row['ziyi'] ?? '', 'zm' => $row['zm'] ?? '', ]; break; case 'cidian': $item['title'] = $row['zc'] ?? ''; $item['content'] = $row['zcjs'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'zc' => $row['zc'] ?? '', 'zcpy' => $row['zcpy'] ?? '', 'zcpywu' => $row['zcpywu'] ?? '', 'zczy' => $row['zczy'] ?? '', 'zcjs' => $row['zcjs'] ?? '', 'pinyin' => $row['zcpy'] ?? '', 'pinyin_wu' => $row['zcpywu'] ?? '', 'zy' => $row['zczy'] ?? '', 'szm' => $row['zcszm'] ?? '', 'cx' => $row['cx'] ?? '', 'wljs' => $row['wljs'] ?? '', ]; break; case 'prescription': $item['title'] = $row['title'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr($row['content'] ?? '', 0, 100); break; case 'tisana': $item['title'] = $row['name'] ?? ''; $item['content'] = $row['effect'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'effect' => $row['effect'] ?? '', 'recipe' => $row['recipe'] ?? '', 'source' => $row['source'] ?? '', 'usages' => $row['usages'] ?? '', 'taboo' => $row['taboo'] ?? '', ]; break; case 'joke': $item['title'] = $row['title'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr($row['content'] ?? '', 0, 100); break; case 'zgjm': $item['title'] = $row['name'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); break; case 'lunyu': case 'hdnj': case 'jgj': case 'mz': case 'zz': case 'zuozhuan': case 'sj': case 'sgz': case 'sbbf': case 'warring': case 'wlyh': $item['title'] = $row['title'] ?? ''; $item['content'] = $row['original'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'original' => $row['original'] ?? '', 'translation' => $row['translation'] ?? '', 'category_id' => $row['category_id'] ?? '', ]; break; case 'illness': $item['title'] = $row['jb'] ?? ''; $item['content'] = $row['zz'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'jb' => $row['jb'] ?? '', 'zz' => $row['zz'] ?? '', 'yqys' => $row['yqys'] ?? '', 'zlff' => $row['zlff'] ?? '', ]; break; case 'word': $item['title'] = $row['word'] ?? ''; $item['content'] = $row['jbjs'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'word' => $row['word'] ?? '', 'jbjs' => $row['jbjs'] ?? '', 'british' => $row['british'] ?? '', 'american' => $row['american'] ?? '', 'cx' => $row['cx'] ?? '', 'lz' => $row['lz'] ?? '', 'wlsy' => $row['wlsy'] ?? '', ]; break; case 'abbr': $item['title'] = $row['abbr'] ?? ''; $item['content'] = $row['full'] ?? ''; $item['summary'] = $item['title'] . ' - ' . ($row['full'] ?? ''); $item['extra'] = [ 'abbr' => $row['abbr'] ?? '', 'full' => $row['full'] ?? '', 'prc' => $row['prc'] ?? '', 'meaning' => $row['meaning'] ?? '', 'category_id' => $row['category_id'] ?? '', ]; break; case 'surname': $item['title'] = $row['surname'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'image' => $row['image'] ?? '', 'initial' => $row['initial'] ?? '', ]; break; case 'jieqi': $item['title'] = $row['name'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'image' => $row['image'] ?? '', 'english' => $row['english'] ?? '', 'section' => $row['section'] ?? '', 'sanhou' => $row['sanhou'] ?? '', 'origin' => $row['origin'] ?? '', 'convention' => $row['convention'] ?? '', 'poem' => $row['poem'] ?? '', 'legend' => $row['legend'] ?? '', 'health' => $row['health'] ?? '', 'note' => $row['note'] ?? '', ]; break; case 'nation': $item['title'] = $row['title'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'capital' => $row['capital'] ?? '', 'initial' => $row['initial'] ?? '', 'image' => $row['image'] ?? '', 'subhead' => $row['subhead'] ?? '', 'vae' => $row['vae'] ?? '', 'langue' => $row['langue'] ?? '', 'acreage' => $row['acreage'] ?? '', 'currency' => $row['currency'] ?? '', 'region' => $row['region'] ?? '', ]; break; case 'jiufang': $item['title'] = $row['name'] ?? ''; $item['content'] = $row['usage'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = [ 'name' => $row['name'] ?? '', 'source' => $row['source'] ?? '', 'usage' => $row['usage'] ?? '', 'ingredients' => $row['ingredients'] ?? '', 'categories' => $row['categories'] ?? '', 'components' => $row['components'] ?? '', ]; break; case 'bot': $item['title'] = $row['title'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr(strip_tags($item['content']), 0, 100); $item['extra'] = ['chinese' => $row['chinese'] ?? '']; break; default: $item['title'] = $row['title'] ?? ''; $item['content'] = $row['content'] ?? ''; $item['summary'] = mb_substr($row['content'] ?? '', 0, 100); } $item['createtime'] = isset($row['createtime']) ? intval($row['createtime']) : 0; $item['updatetime'] = isset($row['updatetime']) ? intval($row['updatetime']) : 0; return $item; } private function _getUserId() { $token = $this->request->header('token', ''); if ($token) { try { $tokenData = \app\common\library\Token::get($token); if ($tokenData && isset($tokenData['user_id'])) { return $tokenData['user_id']; } } catch (\Exception $e) {} } return 0; } private function _updateProfile($userId, $feedType, $action) { try { $profile = Db::name('feed_profile')->where('user_id', $userId)->find(); $preferredTypes = []; if ($profile && !empty($profile['preferred_types'])) { $preferredTypes = json_decode($profile['preferred_types'], true) ?: []; } $weightMap = [ 'like' => 3, 'favorite' => 5, 'share' => 4, 'dislike' => -2, 'comment' => 4, 'rating' => 2, 'readlater' => 3, ]; $addWeight = isset($weightMap[$action]) ? $weightMap[$action] : 1; if (!isset($preferredTypes[$feedType])) { $preferredTypes[$feedType] = 0; } $preferredTypes[$feedType] = max(0, $preferredTypes[$feedType] + $addWeight); if ($profile) { Db::name('feed_profile')->where('user_id', $userId)->update([ 'preferred_types' => json_encode($preferredTypes), 'updated_at' => time(), ]); } else { Db::name('feed_profile')->insert([ 'user_id' => $userId, 'preferred_types' => json_encode($preferredTypes), 'preferred_channels' => '', 'updated_at' => time(), ]); } } catch (\Exception $e) {} } /** * @name 清除Feed相关缓存 * @desc 在互动操作写入后主动清除相关缓存,确保数据一致性 * @lastUpdate v2.5 修复 _lite 后缀缓存未清理 + Cache::rm 不生效的 bug: * 1) 增加 _lite 后缀变体循环 * 2) 在 Cache::rm 后调用 _unlinkCacheFile 兜底,直接删除缓存文件 * 绕过 opcache 缓存旧代码 / ThinkPHP complex 驱动异常 */ private function _clearFeedCache($feedType, $feedId) { try { // 单键缓存 $singleKeys = [ 'feed_stats', 'feed_channels', 'feed_weight_config', 'feed_weight_config_api', "feed_count_{$feedType}", 'feed_recommend_guest_20', ]; foreach ($singleKeys as $sk) { Cache::rm($sk); $this->_unlinkCacheFile($sk); } // 列表缓存:覆盖 newest/hottest × 5 页 × 4 种 limit × 普通与 _lite × 平台后缀 $sorts = ['newest', 'hottest']; // 修复 v2.5: 必须覆盖客户端所有可能的 limit 值(含 5),否则 limit=5 的缓存不会被清理 $limits = [5, 10, 15, 20, 25, 30, 40, 50]; $liteVariants = ['', '_lite']; // lite 模式变体 for ($p = 1; $p <= 5; $p++) { foreach ($sorts as $sort) { foreach ($limits as $lim) { foreach ($liteVariants as $liteSuffix) { $k1 = "feed_list_{$feedType}_{$sort}_{$p}_{$lim}{$liteSuffix}"; $k2 = "feed_list_all_{$sort}_{$p}_{$lim}{$liteSuffix}"; Cache::rm($k1); Cache::rm($k2); $this->_unlinkCacheFile($k1); $this->_unlinkCacheFile($k2); // 带平台后缀 foreach (self::$platforms as $pKey => $pName) { $pk1 = "feed_list_{$feedType}_{$sort}_{$p}_{$lim}{$liteSuffix}_plat_{$pKey}"; $pk2 = "feed_list_all_{$sort}_{$p}_{$lim}{$liteSuffix}_plat_{$pKey}"; Cache::rm($pk1); Cache::rm($pk2); $this->_unlinkCacheFile($pk1); $this->_unlinkCacheFile($pk2); } } } } } // trending 缓存 $trendingKeys = [ "feed_trending_{$feedType}_20", "feed_trending_all_20", ]; foreach ($trendingKeys as $tk) { Cache::rm($tk); $this->_unlinkCacheFile($tk); } foreach (self::$platforms as $pKey => $pName) { $platKeys = [ "feed_trending_{$feedType}_20_{$pKey}", "feed_trending_all_20_{$pKey}", "feed_recommend_guest_20_plat_{$pKey}", ]; foreach ($platKeys as $pk) { Cache::rm($pk); $this->_unlinkCacheFile($pk); } } } catch (\Exception $e) {} } /** * @name 兜底删除缓存文件 * @desc Cache::rm 在某些环境(opcache 缓存旧代码 / complex 驱动异常)下不删除文件, * 本方法直接按 ThinkPHP 5 File 驱动的路径规则计算缓存文件路径并 unlink * @param string $cacheKey 缓存 key(与 Cache::rm 入参一致) * @return bool 文件已删除返回 true,文件不存在或删除失败返回 false */ private function _unlinkCacheFile($cacheKey) { if (!defined('CACHE_PATH') || empty($cacheKey)) { return false; } try { $hash = md5($cacheKey); $file = CACHE_PATH . substr($hash, 0, 2) . '/' . substr($hash, 2) . '.php'; if (is_file($file) && @unlink($file)) { return true; } } catch (\Exception $e) {} return false; } /** * @name 清除权重配置缓存 * @desc 在install/sync操作后清除权重相关缓存 * @update 2026-06-12 修复缓存key格式,与list/trending缓存key对齐 */ private function _clearWeightCache() { try { Cache::rm('feed_weight_config'); Cache::rm('feed_weight_config_api'); Cache::rm('feed_stats'); Cache::rm('feed_channels'); $sorts = ['newest', 'hottest']; // 修复 v2.5: 必须覆盖客户端所有可能的 limit 值(含 5),否则 limit=5 的缓存不会被清理 $limits = [5, 10, 15, 20, 25, 30, 40, 50]; for ($p = 1; $p <= 5; $p++) { foreach ($sorts as $sort) { foreach ($limits as $lim) { Cache::rm("feed_list_all_{$sort}_{$p}_{$lim}"); // 带平台后缀 foreach (self::$platforms as $pKey => $pName) { Cache::rm("feed_list_all_{$sort}_{$p}_{$lim}_plat_{$pKey}"); } } } } Cache::rm('feed_recommend_guest_20'); foreach (self::$platforms as $pKey => $pName) { Cache::rm("feed_recommend_guest_20_plat_{$pKey}"); } } catch (\Exception $e) {} } /** * @name 平台配置接口 * @desc 获取当前平台的内容分类配置,APP启动时调用,根据请求头或参数自动识别平台 * @param string platform 平台标识(可选,不传则从X-Platform请求头读取) * @return array 包含当前平台启用的分类列表和平台信息 */ public function platform_config() { // 优先从参数获取,其次从请求头获取(_getPlatform已包含此逻辑) $platform = $this->_getPlatform(); $weightConfig = $this->_getWeightConfig(); $enabledChannels = []; $disabledChannels = []; foreach (self::$feedMap as $key => $config) { $isEnabled = true; if (isset($weightConfig[$key])) { $isEnabled = !empty($weightConfig[$key]['is_enabled']); } $isPlatformEnabled = true; if (!empty($platform) && $isEnabled) { $isPlatformEnabled = $this->_isPlatformEnabled($key, $platform, $weightConfig); } $channelInfo = [ 'key' => $key, 'name' => $config['name'], 'icon' => $config['icon'], 'is_enabled' => $isEnabled && $isPlatformEnabled, 'platform_enabled' => $this->_parsePlatformEnabled($weightConfig[$key]['platform_enabled'] ?? null), ]; if ($isEnabled && $isPlatformEnabled) { $enabledChannels[] = $channelInfo; } else { $disabledChannels[] = $channelInfo; } } $result = [ 'platform' => $platform ?: 'unknown', 'platform_name' => isset(self::$platforms[$platform]) ? self::$platforms[$platform] : '未知', 'enabled_count' => count($enabledChannels), 'disabled_count' => count($disabledChannels), 'enabled_channels' => $enabledChannels, 'disabled_channels' => $disabledChannels, 'all_platforms' => self::$platforms, ]; $this->success('成功', $result); } /** * @name 获取平台标识 * @desc 优先从GET参数获取platform,其次从X-Platform请求头获取 * @return string 平台标识(android/ios/harmony/macos/win/web/other) */ private function _getPlatform() { $platform = input('get.platform', '', 'trim'); if (empty($platform)) { $platform = $this->request->header('x-platform', '', 'trim'); } // 验证平台参数有效性 if (!empty($platform) && !isset(self::$platforms[$platform])) { $platform = ''; // 无效平台忽略 } return $platform; } /** * @name 检查分类是否在指定平台启用 * @desc 根据权重配置中的platform_enabled字段判断分类是否在指定平台启用 * @param string $type 内容类型key * @param string $platform 平台标识 * @param array $weightConfig 权重配置数组 * @return bool 是否启用 */ private function _isPlatformEnabled($type, $platform, $weightConfig) { if (!isset($weightConfig[$type])) { return true; // 未配置的类型默认所有平台启用 } $platformJson = $weightConfig[$type]['platform_enabled'] ?? ''; if (empty($platformJson)) { return true; // 空值表示所有平台启用(向后兼容) } $platformData = json_decode($platformJson, true); if (!is_array($platformData)) { return true; // 解析失败默认启用 } // 如果该平台未在配置中定义,默认启用 if (!isset($platformData[$platform])) { return true; } return (bool) $platformData[$platform]; } /** * @name 解析平台开关JSON * @desc 将platform_enabled字段解析为标准数组,缺失平台默认启用 * @param string|null $jsonStr 平台开关JSON字符串 * @return array 平台开关数组 ['android' => true, 'ios' => true, ...] */ private function _parsePlatformEnabled($jsonStr) { $default = []; foreach (self::$platforms as $pKey => $pName) { $default[$pKey] = true; } if (empty($jsonStr)) { return $default; } $decoded = json_decode($jsonStr, true); if (!is_array($decoded)) { return $default; } foreach (self::$platforms as $pKey => $pName) { if (!isset($decoded[$pKey])) { $decoded[$pKey] = true; } $decoded[$pKey] = (bool) $decoded[$pKey]; } return $decoded; } /** * @name 生成默认平台开关JSON * @desc 所有平台默认开启 * @return string JSON字符串 */ private function _defaultPlatformEnabled() { $data = []; foreach (self::$platforms as $pKey => $pName) { $data[$pKey] = true; } return json_encode($data); } /** * @name 批量加载Feed内容 * @desc 按类型分组批量查询,解决N+1查询问题 * @param array $interactions 互动记录列表 * @param string $timeField 时间字段名(favorited_at/liked_at/viewed_at/added_at) */ private function _batchLoadFeedItems($interactions, $timeField) { $grouped = []; foreach ($interactions as $inter) { $type = $inter['feed_type']; if (!isset($grouped[$type])) { $grouped[$type] = []; } $grouped[$type][] = $inter; } $itemMap = []; foreach ($grouped as $type => $typeInteractions) { if (!isset(self::$feedMap[$type])) continue; $config = self::$feedMap[$type]; $ids = array_column($typeInteractions, 'feed_id'); $ids = array_unique($ids); try { $rows = Db::name($config['table']) ->where('id', 'in', $ids) ->field($config['return']) ->select(); foreach ($rows as $row) { $item = $this->_formatItem($type, $row); $itemMap[$type . '_' . $row['id']] = $item; } } catch (\Exception $e) {} } $items = []; foreach ($interactions as $inter) { $key = $inter['feed_type'] . '_' . $inter['feed_id']; if (isset($itemMap[$key])) { $item = $itemMap[$key]; $item[$timeField] = $inter['createtime']; $items[] = $item; } } // 批量附加互动计数(like_count/favorite_count/comment_count/share_count) // 修复: favorites/likes/history/readlater 列表也需返回真实计数 // v2.6: 同时回填当前用户的 is_liked/is_favorited 状态 $this->_batchAttachInteractionCounts($items, $this->_getUserId()); return $items; } /** * @name 批量附加互动计数 * @desc 一次性查询 feed_interaction 表,按 (feed_type, feed_id, action) 分组 COUNT, * 回填到 items 的 like_count/favorite_count/comment_count/share_count 字段。 * 避免 N+1 查询,列表接口(list/favorites/likes/history/readlater)统一调用。 * @param array &$items 待附加计数的 items 数组(引用传递) * @lastUpdate v2.4 新增; 修复客户端点赞按钮显示 0 的问题 */ private function _batchAttachInteractionCounts(&$items, $userId = 0) { if (empty($items) || !is_array($items)) { return; } // 收集所有 (feed_type, feed_id) 对 $pairs = []; foreach ($items as $item) { $feedType = $item['feed_type'] ?? ''; $feedId = intval($item['id'] ?? 0); if (empty($feedType) || $feedId <= 0) { continue; } $pairs[$feedType][] = $feedId; } if (empty($pairs)) { // 无有效对,仍填充 0 防止客户端解析 null foreach ($items as &$item) { if (!isset($item['like_count'])) $item['like_count'] = 0; if (!isset($item['favorite_count'])) $item['favorite_count'] = 0; if (!isset($item['comment_count'])) $item['comment_count'] = 0; if (!isset($item['share_count'])) $item['share_count'] = 0; // v2.6: 回填当前用户互动状态(未登录默认 false) if (!isset($item['is_liked'])) $item['is_liked'] = false; if (!isset($item['is_favorited'])) $item['is_favorited'] = false; } unset($item); return; } // 构造 OR 查询条件: ((feed_type='poetry' AND feed_id IN (...)) OR (feed_type='wisdom' AND feed_id IN (...)) ...) // 仅查询 like/favorite/comment/share 四种 action $countMap = []; // key: "feed_type_feed_id" => ["like"=>n, "favorite"=>n, ...] try { foreach ($pairs as $feedType => $ids) { $ids = array_unique($ids); $rows = Db::name('feed_interaction') ->field('feed_id, action, COUNT(*) AS cnt') ->where('feed_type', $feedType) ->where('feed_id', 'in', $ids) ->where('action', 'in', ['like', 'favorite', 'comment', 'share']) ->group('feed_id, action') ->select(); foreach ($rows as $row) { $fid = intval($row['feed_id']); $action = $row['action']; $cnt = intval($row['cnt']); $key = $feedType . '_' . $fid; if (!isset($countMap[$key])) { $countMap[$key] = [ 'like' => 0, 'favorite' => 0, 'comment' => 0, 'share' => 0, ]; } $countMap[$key][$action] = $cnt; } } } catch (\Exception $e) { // 查询失败时降级为 0 } // v2.6 新增:批量查询当前用户的互动状态,回填 is_liked/is_favorited // 解决:list 接口不返回用户个人状态,导致已收藏句子重复出现时显示"未收藏" $userActionMap = []; // key: "feed_type_feed_id" => ["like"=>bool, "favorite"=>bool] if (!empty($userId)) { try { foreach ($pairs as $feedType => $ids) { $ids = array_unique($ids); $userRows = Db::name('feed_interaction') ->field('feed_id, action') ->where('user_id', $userId) ->where('feed_type', $feedType) ->where('feed_id', 'in', $ids) ->where('action', 'in', ['like', 'favorite']) ->select(); foreach ($userRows as $row) { $fid = intval($row['feed_id']); $action = $row['action']; $key = $feedType . '_' . $fid; if (!isset($userActionMap[$key])) { $userActionMap[$key] = [ 'like' => false, 'favorite' => false, ]; } $userActionMap[$key][$action] = true; } } } catch (\Exception $e) { // 查询失败时降级为 false } } // 回填到 items foreach ($items as &$item) { $feedType = $item['feed_type'] ?? ''; $feedId = intval($item['id'] ?? 0); $key = $feedType . '_' . $feedId; $counts = $countMap[$key] ?? null; $item['like_count'] = $counts ? $counts['like'] : 0; $item['favorite_count'] = $counts ? $counts['favorite'] : 0; $item['comment_count'] = $counts ? $counts['comment'] : 0; $item['share_count'] = $counts ? $counts['share'] : 0; // v2.6: 回填当前用户互动状态 $userActions = $userActionMap[$key] ?? null; $item['is_liked'] = $userActions ? $userActions['like'] : false; $item['is_favorited'] = $userActions ? $userActions['favorite'] : false; } unset($item); } /** * @name 获取权重配置 * @desc 从数据库读取管理员设置的推荐权重配置,带缓存 */ private function _getWeightConfig() { $cacheKey = "feed_weight_config"; $cached = Cache::get($cacheKey); if ($cached) { return $cached; } $config = []; try { $rows = Db::name('feed_weight_config')->select(); foreach ($rows as $row) { $config[$row['feed_type']] = [ 'weight' => intval($row['weight']), 'display_weight' => intval($row['display_weight']), 'push_limit' => intval($row['push_limit']), 'push_count' => intval($row['push_count']), 'push_date' => $row['push_date'], 'is_enabled' => intval($row['is_enabled']), 'platform_enabled' => $row['platform_enabled'] ?? '', ]; } } catch (\Exception $e) { foreach (self::$feedMap as $type => $fc) { $config[$type] = [ 'weight' => 50, 'display_weight' => 50, 'push_limit' => 0, 'push_count' => 0, 'push_date' => null, 'is_enabled' => 1, 'platform_enabled' => '', ]; } } Cache::set($cacheKey, $config, 120); return $config; } /** * @name 重置推送计数 * @desc 每日自动重置推送计数,确保push_limit按天计算 */ private function _resetPushCount($type, &$wc) { $today = date('Y-m-d'); if ($wc['push_date'] !== $today) { try { Db::name('feed_weight_config') ->where('feed_type', $type) ->update(['push_count' => 0, 'push_date' => $today]); } catch (\Exception $e) {} $wc['push_count'] = 0; $wc['push_date'] = $today; } } /** * @name 增加推送计数 * @desc 每次推送内容后增加计数,用于push_limit限制 */ private function _incPushCount($type, $count = 1) { try { Db::name('feed_weight_config') ->where('feed_type', $type) ->setInc('push_count', $count); } catch (\Exception $e) {} } /** * @name 补充获取排除已看内容的新内容 * @desc 当seen_ids过滤后数量不足时,从数据库补充新内容 * @param array $seenMap 已看内容映射 [type => [id1, id2, ...]] * @param array $existIds 当前已有内容映射 [type => [id1, id2, ...]] * @param string $sort 排序方式 * @param int $need 需要补充的数量 */ private function _fetchExcludingSeen($seenMap, $existIds, $sort, $need) { $items = []; $weightConfig = $this->_getWeightConfig(); $enabledTypes = []; $totalWeight = 0; foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { $enabledTypes[$type] = $wc; $totalWeight += $wc['weight']; } } if (empty($enabledTypes) || $totalWeight <= 0) { $enabledTypes = []; foreach (self::$feedMap as $type => $fc) { $enabledTypes[$type] = ['weight' => 50]; } $totalWeight = count($enabledTypes) * 50; } $fetched = 0; foreach ($enabledTypes as $type => $wc) { if ($fetched >= $need) break; $typeWeight = $wc['weight']; $typeNeed = max(1, intval($need * ($typeWeight / $totalWeight)) + 1); $typeNeed = min($typeNeed, $need - $fetched + 2); $excludeIds = array_merge( isset($seenMap[$type]) ? $seenMap[$type] : [], isset($existIds[$type]) ? $existIds[$type] : [] ); $excludeIds = array_unique($excludeIds); if (!isset(self::$feedMap[$type])) continue; $config = self::$feedMap[$type]; try { $query = Db::name($config['table'])->where($this->_getStatusCondition($type)); if (!empty($excludeIds)) { $query->where('id', 'not in', $excludeIds); } $orderField = $sort === 'hottest' ? 'views' : $config['order']; $rows = $query->field($config['return']) ->order($orderField . ' desc') ->limit($typeNeed) ->select(); foreach ($rows as $row) { $items[] = $this->_formatItem($type, $row); $fetched++; } } catch (\Exception $e) {} } usort($items, function ($a, $b) { return ($b['views'] ?? 0) - ($a['views'] ?? 0); }); return array_slice($items, 0, $need); } /** * @name A/B测试配置接口 * @desc 根据平台和用户ID返回当前生效的A/B测试权重覆盖配置 * @param string platform 平台标识(android/ios/harmony等) * @param string user_id 用户ID(可选,用于稳定分配变体) * @return array 包含test_key、variant、weight_overrides */ public function ab_test_config() { $platform = $this->_getPlatform(); $userId = input('get.user_id', 0, 'intval'); // 优先从请求头获取用户ID if (!$userId) { $userId = $this->_getUserId(); } // _getPlatform已包含请求头读取逻辑 $abConfig = $this->_getAbTestConfig($platform, $userId); $this->success('成功', $abConfig); } /** * @name 获取A/B测试配置 * @desc 查找当前运行中的、匹配平台的实验,根据user_id的hash分配变体,返回权重覆盖配置 * @param string $platform 平台标识 * @param int $userId 用户ID * @return array A/B测试配置 */ private function _getAbTestConfig($platform = '', $userId = 0) { $cacheKey = "ab_test_running"; $cached = Cache::get($cacheKey); if ($cached === false || !is_array($cached)) { // 查找所有运行中的实验 try { $runningTests = Db::name('ab_test') ->where('status', 1) ->select(); } catch (\Exception $e) { $runningTests = []; } Cache::set($cacheKey, $runningTests, 30); } else { $runningTests = $cached; } if (empty($runningTests)) { return [ 'has_test' => false, 'test_key' => '', 'variant' => '', 'weight_overrides' => new \stdClass(), ]; } // 筛选匹配平台的实验 $matchedTests = []; foreach ($runningTests as $test) { // 平台匹配:all匹配所有,或者精确匹配 if ($test['platform'] === 'all' || $test['platform'] === $platform || empty($platform)) { // 检查流量占比:用hash决定该用户是否参与实验 $trafficHash = abs(crc32('ab_traffic_' . $test['id'] . '_' . $userId)) % 100; if ($trafficHash < $test['traffic_percent']) { $matchedTests[] = $test; } } } if (empty($matchedTests)) { return [ 'has_test' => false, 'test_key' => '', 'variant' => '', 'weight_overrides' => new \stdClass(), ]; } // 取优先级最高的实验(最新创建的) $test = $matchedTests[0]; if (count($matchedTests) > 1) { // 按ID降序取最新的 usort($matchedTests, function ($a, $b) { return $b['id'] - $a['id']; }); $test = $matchedTests[0]; } // 获取变体列表 try { $variants = Db::name('ab_test_variant') ->where('test_id', $test['id']) ->order('id', 'asc') ->select(); } catch (\Exception $e) { $variants = []; } if (empty($variants)) { return [ 'has_test' => false, 'test_key' => '', 'variant' => '', 'weight_overrides' => new \stdClass(), ]; } // 根据用户ID的hash分配变体 $totalTraffic = array_sum(array_column($variants, 'traffic_percent')); if ($totalTraffic <= 0) { $totalTraffic = 100; } $variantHash = abs(crc32('ab_variant_' . $test['id'] . '_' . $userId)) % $totalTraffic; $accumulated = 0; $assignedVariant = $variants[0]; // 默认取第一个 foreach ($variants as $v) { $accumulated += $v['traffic_percent']; if ($variantHash < $accumulated) { $assignedVariant = $v; break; } } // 解析权重覆盖配置 $weightOverrides = []; if (!empty($assignedVariant['weight_config'])) { $decoded = json_decode($assignedVariant['weight_config'], true); if (is_array($decoded)) { $weightOverrides = $decoded; } } return [ 'has_test' => true, 'test_key' => $test['test_key'], 'test_name' => $test['test_name'], 'variant' => $assignedVariant['variant_key'], 'variant_name' => $assignedVariant['variant_name'], 'is_control' => $assignedVariant['is_control'] ? true : false, 'weight_overrides' => !empty($weightOverrides) ? $weightOverrides : new \stdClass(), ]; } /** * @name 权重配置接口 * @desc 获取当前推荐权重配置(供APP展示和管理员调试) */ public function weight_config() { $cacheKey = "feed_weight_config_api"; $cached = Cache::get($cacheKey); if ($cached) { $this->success('成功(cache)', $cached); return; } $config = []; try { $rows = Db::name('feed_weight_config')->order('weight', 'desc')->select(); foreach ($rows as $row) { $platformEnabled = $this->_parsePlatformEnabled($row['platform_enabled'] ?? null); $config[] = [ 'type' => $row['feed_type'], 'name' => $row['feed_name'], 'icon' => $row['feed_icon'], 'weight' => intval($row['weight']), 'display_weight' => intval($row['display_weight']), 'push_limit' => intval($row['push_limit']), 'push_count' => intval($row['push_count']), 'push_date' => $row['push_date'], 'is_enabled' => intval($row['is_enabled']) ? true : false, 'platform_enabled' => $platformEnabled, ]; } } catch (\Exception $e) { $this->error('权重配置暂不可用'); } $totalWeight = array_sum(array_column($config, 'weight')); $enabledCount = count(array_filter($config, function ($c) { return $c['is_enabled']; })); $result = [ 'config' => $config, 'total_weight' => $totalWeight, 'enabled_count' => $enabledCount, 'total_types' => count($config), 'platforms' => self::$platforms, ]; Cache::set($cacheKey, $result, 60); $this->success('成功', $result); } /** * @name 混合信息流 * @desc 根据用户指定的混合规则,从多个频道获取内容并按规则排列 * @param string mode 混合模式: uniform/ratio/specific/random/group * @param string channels 参与混合的频道(逗号分隔) * @param string ratios 比例模式专用(JSON: {"poetry":40,"wisdom":30}) * @param int group_size 分组循环模式每组条数(默认3) * @param int limit 总条数(默认20,最大50) * @param string sort 排序方式(newest/hottest,默认hottest) * @param string platform 平台标识(android/ios/harmony/macos/win/web/other),按平台过滤 */ public function mix() { $mode = input('get.mode', 'random', 'trim'); $channels = input('get.channels', '', 'trim'); $ratios = input('get.ratios', '', 'trim'); $groupSize = max(1, input('get.group_size', 3, 'intval')); $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $sort = input('get.sort', 'hottest', 'trim'); $platform = $this->_getPlatform(); $allowedModes = ['uniform', 'ratio', 'specific', 'random', 'group']; if (!in_array($mode, $allowedModes)) { $this->error('不支持的混合模式: ' . $mode . ',可选: ' . implode('/', $allowedModes)); } $channelList = array_filter(array_map('trim', explode(',', $channels))); $validChannels = []; foreach ($channelList as $ch) { if (isset(self::$feedMap[$ch])) { $validChannels[] = $ch; } } if (empty($validChannels)) { $weightConfig = $this->_getWeightConfig(); foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { // 按平台过滤 if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { continue; } $validChannels[] = $type; } } if (empty($validChannels)) { // 指定了平台但无启用分类 → 返回空列表 $result = [ 'list' => [], 'total' => 0, 'mode' => $mode, 'channels' => [], 'limit' => $limit, 'platform' => $platform, ]; $this->success('成功', $result); return; } } $items = []; switch ($mode) { case 'uniform': $items = $this->_mixUniform($validChannels, $limit, $sort); break; case 'ratio': $ratioMap = []; if (!empty($ratios)) { $decoded = json_decode($ratios, true); if (is_array($decoded)) { foreach ($decoded as $k => $v) { if (in_array($k, $validChannels) && intval($v) > 0) { $ratioMap[$k] = intval($v); } } } } if (empty($ratioMap)) { foreach ($validChannels as $ch) { $ratioMap[$ch] = 1; } } $items = $this->_mixRatio($ratioMap, $limit, $sort); break; case 'specific': $items = $this->_mixSpecific($validChannels, $limit, $sort); break; case 'random': $items = $this->_mixRandom($validChannels, $limit); break; case 'group': $items = $this->_mixGroup($validChannels, $limit, $groupSize, $sort); break; } $result = [ 'list' => $items, 'total' => count($items), 'mode' => $mode, 'channels' => $validChannels, 'limit' => $limit, ]; $this->success('成功', $result); } /** * @name 均匀交叉 — 各频道轮流出场 */ private function _mixUniform($channels, $limit, $sort) { $perChannel = max(1, intval($limit / count($channels)) + 2); $channelItems = []; foreach ($channels as $ch) { $channelItems[$ch] = $this->_fetchFeedItems($ch, $sort, 1, $perChannel); } $result = []; $idx = 0; while (count($result) < $limit) { $ch = $channels[$idx % count($channels)]; if (!empty($channelItems[$ch])) { $result[] = array_shift($channelItems[$ch]); } else { $idx++; if ($idx > $limit * count($channels)) break; continue; } $idx++; } return array_slice($result, 0, $limit); } /** * @name 比例混合 — 按权重比例分配 */ private function _mixRatio($ratioMap, $limit, $sort) { $totalRatio = array_sum($ratioMap); if ($totalRatio <= 0) return []; $items = []; foreach ($ratioMap as $ch => $ratio) { $chLimit = max(1, intval($limit * ($ratio / $totalRatio)) + 1); $chItems = $this->_fetchFeedItems($ch, $sort, 1, $chLimit); foreach ($chItems as $item) { $item['_ratio'] = $ratio; $items[] = $item; } } usort($items, function ($a, $b) { return ($b['_ratio'] ?? 0) - ($a['_ratio'] ?? 0); }); foreach ($items as &$item) { unset($item['_ratio']); } return array_slice($items, 0, $limit); } /** * @name 仅指定分类 — 只从勾选频道获取 */ private function _mixSpecific($channels, $limit, $sort) { $items = []; $perChannel = max(1, intval($limit / count($channels)) + 1); foreach ($channels as $ch) { $chItems = $this->_fetchFeedItems($ch, $sort, 1, $perChannel); foreach ($chItems as $item) { $items[] = $item; } } shuffle($items); return array_slice($items, 0, $limit); } /** * @name 随机混排 — 完全随机 */ private function _mixRandom($channels, $limit) { $items = []; $seed = uniqid('mix_', true); $perChannel = max(1, intval($limit / count($channels)) + 2); foreach ($channels as $ch) { $chItems = $this->_fetchRandomItems($ch, $perChannel, $seed . $ch); foreach ($chItems as $item) { $items[] = $item; } } shuffle($items); return array_slice($items, 0, $limit); } /** * @name 分组循环 — 每分类连续N条后切换 */ private function _mixGroup($channels, $limit, $groupSize, $sort) { $perChannel = max($groupSize, intval($limit / count($channels)) + $groupSize); $channelItems = []; foreach ($channels as $ch) { $channelItems[$ch] = $this->_fetchFeedItems($ch, $sort, 1, $perChannel); } $result = []; $round = 0; while (count($result) < $limit) { $allEmpty = true; foreach ($channels as $ch) { for ($i = 0; $i < $groupSize && count($result) < $limit; $i++) { if (!empty($channelItems[$ch])) { $result[] = array_shift($channelItems[$ch]); $allEmpty = false; } } } if ($allEmpty) break; $round++; if ($round > 100) break; } return array_slice($result, 0, $limit); } }