Files
xianyan/docs/toolsapi/application/api/controller/Feed.php
Developer 228095f80a chore: 完成v6.7.0版本迭代更新
本次更新涵盖多个功能模块的优化与新增:
1. 新增Wi-Fi直连配对方式与协作画布模块
2. 完成设备管理重命名API与前端适配
3. 优化日签卡片空数据保护与UI细节
4. 新增剪贴板工具与每日运势会话
5. 修复应用锁恢复、语音消息录制等已知问题
6. 完善文件传输统计与配对逻辑
7. 更新安卓权限配置与iOS隐私描述
8. 新增自动化测试脚本与文档
9. 清理旧版审计报告与测试文件
2026-05-14 05:35:18 +08:00

2748 lines
113 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @name 信息流API接口
* @author AI Coder
* @date 2026-04-28
* @desc 聚合18种内容表为统一信息流支持频道筛选、热门排行、互动操作、个性化推荐、随机内容、搜索、收藏列表
* @update v7.5.0 补充7种数据源(chengyu/cidian/hanzi/joke/prescription/tisana/zgjm); _formatItem补充createtime/互动计数/交互状态; 新增relatedRecommend接口
*/
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'];
protected $noNeedRight = ['*'];
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 聚合多类型内容为统一信息流,支持频道筛选、分页、轻量模式
* @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快速下滑优化)
*/
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');
$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}";
}
$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 = [];
$total = 0;
if ($channel === 'all') {
$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)) {
$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);
}
$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);
}
$result = [
'list' => $items,
'total' => $total,
'page' => $page,
'limit' => $limit,
'channel' => $channel,
'sort' => $sort,
'lite' => $lite ? true : false,
];
if (!$hasSeenIds) {
Cache::set($cacheKey, $result, 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()
{
$channels = [];
$channels[] = ['key' => 'all', 'name' => '推荐', 'icon' => '🔥', 'count' => 0];
foreach (self::$feedMap as $key => $config) {
$count = $this->_countFeedItems($key);
$channels[] = [
'key' => $key,
'name' => $config['name'],
'icon' => $config['icon'],
'count' => $count,
];
$channels[0]['count'] += $count;
}
$this->success('成功', ['channels' => $channels]);
}
/**
* @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')));
$cacheKey = "feed_trending_{$channel}_{$limit}";
$cached = Cache::get($cacheKey);
if ($cached) {
$this->success('成功(cache)', $cached);
return;
}
$items = [];
if ($channel === 'all') {
$topPerType = max(1, intval($limit / min(5, count(self::$feedMap))));
$mainTypes = ['poetry', 'wisdom', 'story', 'hitokoto', 'article', 'riddle', 'brainteaser', 'lyric'];
foreach ($mainTypes 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('不支持的频道');
}
$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 返回数量
*/
public function recommend()
{
$limit = min(50, max(1, input('get.limit', 20, 'intval')));
$userId = $this->_getUserId();
$seenIds = input('get.seen_ids', '', 'trim');
$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}";
$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;
$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])) {
$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);
} 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 随机种子(可选,相同种子返回相同内容,用于翻页一致性)
*/
public function random()
{
$channel = input('get.channel', 'all', 'trim');
$limit = min(30, max(1, input('get.limit', 10, 'intval')));
$seed = input('get.seed', '', 'trim');
if ($seed === '') {
$seed = uniqid('rnd_', true);
}
$cacheKey = "feed_random_{$channel}_{$limit}_" . md5($seed);
$cached = Cache::get($cacheKey);
if ($cached) {
$this->success('成功(cache)', $cached);
return;
}
$items = [];
if ($channel === 'all') {
$types = 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('不支持的频道');
}
$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');
if ($sinceId <= 0) {
$this->error('需要since_id参数');
}
$newCount = 0;
$latestId = $sinceId;
if ($channel === 'all') {
$mainTypes = ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article'];
foreach ($mainTypes 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('不支持的频道');
}
$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位,逗号分隔),用于内容去重
*/
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');
$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') {
$items = $this->_fetchExcludingSeen($seenMap, [], $sort, $limit);
if (empty($items)) {
$mainTypes = ['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);
}
$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;
}
$channelStats = [];
$totalContent = 0;
$totalViews = 0;
foreach (self::$feedMap as $key => $config) {
$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' => count(self::$feedMap),
'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 在互动操作写入后主动清除相关缓存,确保数据一致性
*/
private function _clearFeedCache($feedType, $feedId)
{
try {
Cache::rm('feed_stats');
Cache::rm('feed_channels');
Cache::rm('feed_weight_config');
Cache::rm('feed_weight_config_api');
Cache::rm("feed_count_{$feedType}");
Cache::rm("feed_recommend_guest_20");
for ($p = 1; $p <= 3; $p++) {
Cache::rm("feed_list_{$feedType}_newest_{$p}_20");
Cache::rm("feed_list_{$feedType}_hottest_{$p}_20");
Cache::rm("feed_list_all_newest_{$p}_20");
Cache::rm("feed_list_all_hottest_{$p}_20");
}
Cache::rm("feed_trending_{$feedType}_20");
Cache::rm("feed_trending_all_20");
} catch (\Exception $e) {}
}
/**
* @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;
}
}
return $items;
}
/**
* @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']),
];
}
} 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,
];
}
}
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 权重配置接口
* @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) {
$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,
];
}
} 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),
];
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)
*/
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');
$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])) {
$validChannels[] = $type;
}
}
if (empty($validChannels)) {
$validChannels = ['poetry', 'wisdom', 'story', 'hitokoto', 'lyric'];
}
}
$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);
}
}