3738 lines
154 KiB
PHP
3738 lines
154 KiB
PHP
<?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);
|
||
}
|
||
}
|