Files
xianyan/docs/toolsapi/application/api/controller/Feed.php
2026-06-27 04:57:00 +08:00

3738 lines
154 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @name 信息流API接口
* @author AI Coder
* @date 2026-04-28
* @desc 聚合18种内容表为统一信息流支持频道筛选、热门排行、互动操作、个性化推荐、随机内容、搜索、收藏列表
* @update v7.7.0 新增ab_test_config接口支持A/B测试权重覆盖配置; 新增_getAbTestConfig方法
*/
namespace app\api\controller;
use app\common\controller\Api;
use think\Db;
use think\Cache;
class Feed extends Api
{
protected $noNeedLogin = ['list', 'detail', 'channels', 'trending', 'recommend', 'stats', 'random', 'search', 'refresh', 'comments', 'weight_config', 'relatedRecommend', 'mix', 'refresh_content', 'preferences', 'install', 'platform_config', 'ab_test_config'];
protected $noNeedRight = ['*'];
/**
* @name 支持的平台列表
* @desc 定义所有可控制的平台标识和名称
*/
private static $platforms = [
'android' => '安卓',
'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);
}
}