refactor: 优化网络请求和错误处理 fix: 修复颜色引用和UI细节问题 docs: 更新API文档和设计规范 chore: 清理无用文件和脚本 perf: 优化图片导出和压缩逻辑 build: 更新依赖和构建配置 style: 调整代码格式和注释 test: 添加接口验证脚本 ci: 更新CI配置和脚本
2030 lines
80 KiB
PHP
2030 lines
80 KiB
PHP
<?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'];
|
||
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'],
|
||
];
|
||
|
||
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 获取信息流各频道内容数量和互动统计,无需登录
|
||
*/
|
||
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 _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);
|
||
if (isset($row['dynasty'])) $item['extra'] = ['dynasty' => $row['dynasty']];
|
||
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'] ?? '',
|
||
];
|
||
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'] ?? '')];
|
||
break;
|
||
case 'efs':
|
||
$item['title'] = $row['title'] ?? ($row['facet'] ?? '');
|
||
$item['content'] = $row['content'] ?? ($row['undertone'] ?? '');
|
||
$item['summary'] = $item['title'] . ' —— ' . $item['content'];
|
||
break;
|
||
case 'brainteaser':
|
||
$item['title'] = $row['title'] ?? ($row['topic'] ?? '');
|
||
$item['content'] = $row['answer'] ?? '';
|
||
$item['summary'] = $item['title'];
|
||
break;
|
||
case 'saying':
|
||
$item['title'] = $row['title'] ?? ($row['saying'] ?? '');
|
||
$item['content'] = $row['content'] ?? '';
|
||
$item['summary'] = mb_substr($row['content'] ?? '', 0, 100);
|
||
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'];
|
||
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['indication'] ?? ($row['syz'] ?? '');
|
||
$item['summary'] = mb_substr($item['content'], 0, 100);
|
||
$item['extra'] = ['goods_name' => $row['goods_name'] ?? ''];
|
||
break;
|
||
case 'herbal':
|
||
$item['title'] = $row['name'] ?? '';
|
||
$item['content'] = $row['effect'] ?? '';
|
||
$item['summary'] = mb_substr($item['content'], 0, 100);
|
||
$item['extra'] = [
|
||
'name_alias' => $row['name_alias'] ?? '',
|
||
'image' => $row['image'] ?? '',
|
||
];
|
||
break;
|
||
case 'food':
|
||
$item['title'] = $row['name'] ?? ($row['sw'] ?? '');
|
||
$item['content'] = $row['effect'] ?? ($row['yh'] ?? '');
|
||
$item['summary'] = mb_substr($item['content'], 0, 100);
|
||
break;
|
||
case 'wine':
|
||
$item['title'] = $row['title'] ?? ($row['name'] ?? '');
|
||
$item['content'] = $row['content'] ?? ($row['effect'] ?? '');
|
||
$item['summary'] = mb_substr($item['content'], 0, 100);
|
||
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'] ?? '',
|
||
];
|
||
break;
|
||
case 'chengyu':
|
||
$item['title'] = $row['title'] ?? ($row['cy'] ?? '');
|
||
$item['content'] = $row['content'] ?? ($row['cyjs'] ?? '');
|
||
$item['summary'] = mb_substr($item['content'], 0, 100);
|
||
$item['extra'] = [
|
||
'pinyin' => $row['pinyin'] ?? ($row['cypy'] ?? ''),
|
||
'origin' => $row['cyzy'] ?? '',
|
||
'example' => $row['cylz'] ?? '',
|
||
];
|
||
break;
|
||
case 'hanzi':
|
||
$item['title'] = $row['title'] ?? ($row['zi'] ?? '');
|
||
$item['content'] = $row['jj'] ?? '';
|
||
$item['summary'] = $item['title'];
|
||
$item['views'] = intval($row['views'] ?? ($row['pv'] ?? 0));
|
||
$item['extra'] = [
|
||
'pinyin' => $row['pinyin'] ?? '',
|
||
'wubi' => $row['wubi'] ?? '',
|
||
'bushou' => $row['bushou'] ?? '',
|
||
'bihua' => $row['bihua'] ?? 0,
|
||
'bishun' => $row['bishun'] ?? '',
|
||
];
|
||
break;
|
||
case 'cidian':
|
||
$item['title'] = $row['title'] ?? ($row['zc'] ?? '');
|
||
$item['content'] = $row['content'] ?? ($row['zcjs'] ?? '');
|
||
$item['summary'] = mb_substr($item['content'], 0, 100);
|
||
$item['extra'] = ['pinyin' => $row['pinyin'] ?? ($row['zcpy'] ?? '')];
|
||
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['title'] ?? ($row['name'] ?? '');
|
||
$item['content'] = $row['content'] ?? ($row['effect'] ?? '');
|
||
$item['summary'] = mb_substr($item['content'], 0, 100);
|
||
$item['extra'] = ['recipe' => $row['recipe'] ?? ''];
|
||
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['title'] ?? ($row['name'] ?? '');
|
||
$item['content'] = $row['content'] ?? '';
|
||
$item['summary'] = mb_substr($row['content'] ?? '', 0, 100);
|
||
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);
|
||
}
|
||
}
|