Files
xianyan/docs/toolsapi/application/api/controller/Searchall.php
Developer 847ebc8501 feat: 新增精灵图贴纸支持及多项功能优化
新增精灵图贴纸类型及内置资源
优化分类图标使用SVG替代emoji
实现分页预加载功能
修复API基础地址与客户端一致
新增健康生活、国学经典服务模块
扩展Feed频道至44种并整合互动统计
修正多处UI显示问题及逻辑错误
2026-04-29 09:23:59 +08:00

1655 lines
72 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-29
* @desc 提供全量搜索功能,支持精确/模糊/相关搜索、条件筛选、ID反查、ID查询聚合18+种数据源
* @update v1.1 统一getById/getByIds返回格式与Feed detail一致; 新增fullDetail/relatedRecommend/highlight接口; 补充createtime/互动计数/交互状态等APP扩展字段
*/
namespace app\api\controller;
use app\common\controller\Api;
use think\Db;
use think\Cache;
class Searchall extends Api
{
protected $noNeedLogin = ['*'];
protected $noNeedRight = ['*'];
private static $sourceMap = [
'poetry' => ['table' => 'poetry', 'name' => '古诗词', 'icon' => '📜', 'search' => ['name','content','author'], 'return' => ['id','name as title','author','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'wisdom' => ['table' => 'wisdom', 'name' => '名言金句', 'icon' => '💡', 'search' => ['name','content'], 'return' => ['id','name as author','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'story' => ['table' => 'story', 'name' => '故事', 'icon' => '📚', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'hitokoto' => ['table' => 'hitokoto', 'name' => '一言', 'icon' => '💬', 'search' => ['hitokoto','from_source'], 'return' => ['id','hitokoto as content','from_source','from_who as author','type_name','views'], 'status' => ['field'=>'switch','value'=>1]],
'riddle' => ['table' => 'riddle', 'name' => '谜语', 'icon' => '🧩', 'search' => ['riddle','interpret'], 'return' => ['id','riddle as title','interpret as answer','miidii as hint','views'], 'status' => ['field'=>'switch','value'=>1]],
'efs' => ['table' => 'efs', 'name' => '歇后语', 'icon' => '🎭', 'search' => ['facet','undertone'], 'return' => ['id','facet as title','undertone as content','views'], 'status' => ['field'=>'switch','value'=>1]],
'brainteaser' => ['table' => 'brainteaser', 'name' => '脑筋急转弯', 'icon' => '🧠', 'search' => ['topic','answer'], 'return' => ['id','topic as title','answer','views'], 'status' => ['field'=>'switch','value'=>1]],
'saying' => ['table' => 'saying', 'name' => '俗语', 'icon' => '🗣️', 'search' => ['saying','content'], 'return' => ['id','saying as title','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'lyric' => ['table' => 'lyric', 'name' => '歌词', 'icon' => '🎵', 'search' => ['title','singer','content'],'return' => ['id','title','singer as author','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'why' => ['table' => 'why', 'name' => '十万个为什么','icon' => '❓', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'status' => ['field'=>'status','value'=>0]],
'composition' => ['table' => 'composition', 'name' => '作文', 'icon' => '✍️', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'couplet' => ['table' => 'couplet', 'name' => '对联', 'icon' => '🏮', 'search' => ['hp','sl'], 'return' => ['id','hp as upper','sl as lower','xl as horizontal','views'], 'status' => ['field'=>'switch','value'=>1]],
'cs' => ['table' => 'cs', 'name' => '常识', 'icon' => '📖', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'drug' => ['table' => 'drug', 'name' => '药品', 'icon' => '💊', 'search' => ['name','goods_name'], 'return' => ['id','name','goods_name','syz as indication','views'], 'status' => ['field'=>'switch','value'=>1]],
'herbal' => ['table' => 'herbal', 'name' => '中草药', 'icon' => '🌿', 'search' => ['name','name_alias'], 'return' => ['id','name','name_alias','effect','views'], 'status' => ['field'=>'switch','value'=>1]],
'food' => ['table' => 'food', 'name' => '食物', 'icon' => '🍽️', 'search' => ['sw'], 'return' => ['id','sw as name','yh as effect','views'], 'status' => ['field'=>'switch','value'=>1]],
'wine' => ['table' => 'wine', 'name' => '酒方', 'icon' => '🍷', 'search' => ['name','effect'], 'return' => ['id','name as title','effect as content','views'], 'status' => ['field'=>'switch','value'=>1]],
'article' => ['table' => 'article', 'name' => '文章', 'icon' => '📰', 'search' => ['title','summary'], 'return' => ['id','title','summary','views','likes','user_id'], 'status' => ['field'=>'status','value'=>'normal']],
'chengyu' => ['table' => 'cy', 'name' => '成语', 'icon' => '🔤', 'search' => ['cy','cyjs','cyzy'], 'return' => ['id','cy as title','cypy as pinyin','cyjs as content','views'], 'status' => ['field'=>'switch','value'=>1]],
'hanzi' => ['table' => 'hanzi', 'name' => '汉字', 'icon' => '🈯', 'search' => ['zi','pinyin','bushou'], 'return' => ['id','zi as title','pinyin','wubi','bushou','bihua','pv as views'], 'status' => ['field'=>'switch','value'=>1]],
'cidian' => ['table' => 'zc', 'name' => '词典', 'icon' => '📚', 'search' => ['zc','zcjs'], 'return' => ['id','zc as title','zcpy as pinyin','zcjs as content','views'], 'status' => ['field'=>'switch','value'=>1]],
'prescription'=> ['table' => 'prescription','name' => '偏方', 'icon' => '🧪', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'tisana' => ['table' => 'tisana', 'name' => '药茶', 'icon' => '🍵', 'search' => ['name','effect'], 'return' => ['id','name as title','effect as content','recipe','views'], 'status' => ['field'=>'switch','value'=>1]],
'joke' => ['table' => 'joke', 'name' => '笑话', 'icon' => '😄', 'search' => ['title','content'], 'return' => ['id','title','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'zgjm' => ['table' => 'zgjm', 'name' => '周公解梦', 'icon' => '🌙', 'search' => ['name','content'], 'return' => ['id','name as title','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'lunyu' => ['table' => 'lunyu', 'name' => '论语', 'icon' => '📖', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'hdnj' => ['table' => 'hdnj', 'name' => '黄帝内经', 'icon' => '⚕️', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'jgj' => ['table' => 'jgj', 'name' => '金刚经', 'icon' => '📿', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'mz' => ['table' => 'mz', 'name' => '孟子', 'icon' => '📜', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'zz' => ['table' => 'zz', 'name' => '庄子', 'icon' => '🦋', 'search' => ['original','translation'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'zuozhuan' => ['table' => 'zuozhuan', 'name' => '左传', 'icon' => '竹', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'sj' => ['table' => 'sj', 'name' => '史记', 'icon' => '🏛️', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'sgz' => ['table' => 'sgz', 'name' => '三国志', 'icon' => '⚔️', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'sbbf' => ['table' => 'sbbf', 'name' => '孙膑兵法', 'icon' => '🗡️', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'warring' => ['table' => 'warring', 'name' => '兵法', 'icon' => '🛡️', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'illness' => ['table' => 'illness', 'name' => '疾病', 'icon' => '🩺', 'search' => ['jb','zz','yqys'], 'return' => ['id','jb as title','zz as content','yqys','zlff','views'], 'status' => ['field'=>'switch','value'=>1]],
'word' => ['table' => 'word', 'name' => '英语单词', 'icon' => '🔤', 'search' => ['word','jbjs'], 'return' => ['id','word as title','british','american','jbjs as content','views'], 'status' => ['field'=>'switch','value'=>1]],
'abbr' => ['table' => 'abbr', 'name' => '缩写', 'icon' => '📝', 'search' => ['abbr','full','meaning'], 'return' => ['id','abbr as title','full','meaning as content','views'], 'status' => ['field'=>'switch','value'=>1]],
'surname' => ['table' => 'surname', 'name' => '姓氏', 'icon' => '👤', 'search' => ['surname','content'], 'return' => ['id','surname as title','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'jieqi' => ['table' => 'jieqi', 'name' => '节气', 'icon' => '🌤️', 'search' => ['name','content'], 'return' => ['id','name as title','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'nation' => ['table' => 'nation', 'name' => '国家', 'icon' => '🌍', 'search' => ['title','content'], 'return' => ['id','title','capital','content','views'], 'status' => ['field'=>'switch','value'=>1]],
'wlyh' => ['table' => 'wlyh', 'name' => '网络用语', 'icon' => '💬', 'search' => ['title','original'], 'return' => ['id','title','original as content','translation','views'], 'status' => ['field'=>'switch','value'=>1]],
'jiufang' => ['table' => 'jiufang', 'name' => '酒方', 'icon' => '🍶', 'search' => ['name','source'], 'return' => ['id','name as title','source','method as content','views'], 'status' => ['field'=>'switch','value'=>1]],
'bot' => ['table' => 'bot', 'name' => '星座', 'icon' => '⭐', 'search' => ['title','chinese'], 'return' => ['id','title','chinese','content','views'], 'status' => ['field'=>'switch','value'=>1]],
];
public function _initialize()
{
if (isset($_SERVER['HTTP_ORIGIN'])) {
header('Access-Control-Expose-Headers: __token__');
}
check_cors_request();
parent::_initialize();
}
private function _validateKeyword($keyword)
{
$keyword = trim($keyword);
if (empty($keyword)) {
$this->error('请输入搜索关键词');
}
if (mb_strlen($keyword) > 100) {
$this->error('关键词不能超过100个字符');
}
if (preg_match('/[<>"\']/', $keyword)) {
$this->error('关键词包含非法字符');
}
return $keyword;
}
private function _applyStatus($query, $config)
{
$sf = $config['status']['field'];
$sv = $config['status']['value'];
if (!empty($sf)) {
$query->where($sf, $sv);
}
}
private function _formatItem($type, $row)
{
$config = self::$sourceMap[$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['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'] ?? '',
'image' => $row['image'] ?? '',
];
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;
}
/**
* @name 格式化详情(含互动统计)
* @desc 在_formatItem基础上补充互动计数和用户交互状态
*/
private function _formatDetailItem($type, $row, $withInteraction = true)
{
$item = $this->_formatItem($type, $row);
if ($withInteraction) {
$feedId = $row['id'] ?? 0;
try {
$likeCount = Db::name('feed_interaction')
->where('feed_type', $type)->where('feed_id', $feedId)->where('action', 'like')->count();
$favCount = Db::name('feed_interaction')
->where('feed_type', $type)->where('feed_id', $feedId)->where('action', 'favorite')->count();
$commentCount = Db::name('feed_interaction')
->where('feed_type', $type)->where('feed_id', $feedId)->where('action', 'comment')->count();
$shareCount = Db::name('feed_interaction')
->where('feed_type', $type)->where('feed_id', $feedId)->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) {
try {
$myActions = Db::name('feed_interaction')
->where('user_id', $userId)->where('feed_type', $type)->where('feed_id', $feedId)
->column('action');
$item['is_liked'] = in_array('like', $myActions);
$item['is_favorited'] = in_array('favorite', $myActions);
$item['my_actions'] = $myActions ?: [];
} catch (\Exception $e) {
$item['is_liked'] = false;
$item['is_favorited'] = false;
$item['my_actions'] = [];
}
} else {
$item['is_liked'] = false;
$item['is_favorited'] = false;
$item['my_actions'] = [];
}
}
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 _recordSearch($keyword)
{
$dir = RUNTIME_PATH . 'search_hot';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$today = date('Y-m-d');
$file = $dir . DS . $today . '.txt';
$data = [];
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true) ?: [];
}
if (!isset($data[$keyword])) $data[$keyword] = 0;
$data[$keyword]++;
file_put_contents($file, json_encode($data, JSON_UNESCAPED_UNICODE));
$userId = $this->_getUserId();
$dir2 = RUNTIME_PATH . 'search_history';
if (!is_dir($dir2)) mkdir($dir2, 0755, true);
$file2 = $dir2 . DS . ($userId ?: md5($this->request->ip())) . '.txt';
$history = [];
if (file_exists($file2)) {
$history = json_decode(file_get_contents($file2), true) ?: [];
}
$history = array_diff($history, [$keyword]);
array_unshift($history, $keyword);
$history = array_slice($history, 0, 50);
file_put_contents($file2, json_encode($history, JSON_UNESCAPED_UNICODE));
}
/**
* @name 全量搜索
* @desc 聚合所有数据源搜索,支持精确/模糊/相关模式,返回统一格式结果
* @param string keyword 关键词
* @param string type 数据类型(all或具体类型)
* @param string mode 搜索模式: exact/fuzzy/related
* @param string field 指定搜索字段(auto或具体字段名)
* @param int page 页码
* @param int limit 每页数量
* @param string sort 排序(relevance/views/newest)
*/
public function search()
{
$keyword = input('get.keyword', '', 'trim');
$type = input('get.type', 'all', 'trim');
$mode = input('get.mode', 'fuzzy', 'trim');
$field = input('get.field', 'auto', 'trim');
$page = max(1, input('get.page', 1, 'intval'));
$limit = min(50, max(1, input('get.limit', 20, 'intval')));
$sort = input('get.sort', 'relevance', 'trim');
$keyword = $this->_validateKeyword($keyword);
$validModes = ['exact', 'fuzzy', 'related'];
if (!in_array($mode, $validModes)) {
$this->error('不支持的搜索模式,可选: ' . implode('/', $validModes));
}
$this->_recordSearch($keyword);
$types = ($type === 'all') ? array_keys(self::$sourceMap) : explode(',', $type);
$allItems = [];
$typeStats = [];
foreach ($types as $t) {
if (!isset(self::$sourceMap[$t])) continue;
$config = self::$sourceMap[$t];
try {
$query = Db::name($config['table']);
$this->_applyStatus($query, $config);
$searchFields = $config['search'];
if ($field !== 'auto' && in_array($field, $searchFields)) {
$searchFields = [$field];
}
if ($mode === 'exact') {
$query->where(function ($q) use ($searchFields, $keyword) {
foreach ($searchFields as $f) {
$q->whereOr($f, '=', $keyword);
}
});
} elseif ($mode === 'fuzzy') {
$like = '%' . $keyword . '%';
$query->where(function ($q) use ($searchFields, $like) {
foreach ($searchFields as $f) {
$q->whereOr($f, 'like', $like);
}
});
} elseif ($mode === 'related') {
$keywords = $this->_extractKeywords($keyword);
if (!empty($keywords)) {
$query->where(function ($q) use ($searchFields, $keywords) {
foreach ($keywords as $kw) {
$like = '%' . $kw . '%';
foreach ($searchFields as $f) {
$q->whereOr($f, 'like', $like);
}
}
});
}
}
$typeTotal = $query->count();
if ($typeTotal > 0) {
$typeStats[] = [
'type' => $t,
'name' => $config['name'],
'icon' => $config['icon'],
'count' => $typeTotal,
];
$orderField = 'views';
$orderDir = 'desc';
if ($sort === 'newest') {
$orderField = 'id';
$orderDir = 'desc';
} elseif ($sort === 'relevance') {
$orderField = 'views';
$orderDir = 'desc';
}
$list = $query->field($config['return'])
->page($page, $limit)
->order($orderField, $orderDir)
->select();
foreach ($list as $row) {
$item = $this->_formatItem($t, $row);
$item['_relevance'] = $this->_calcRelevance($item, $keyword, $mode);
$allItems[] = $item;
}
}
} catch (\Exception $e) {
$typeStats[] = [
'type' => $t,
'name' => $config['name'],
'icon' => $config['icon'],
'count' => 0,
'error' => $e->getMessage(),
];
}
}
if ($sort === 'relevance') {
usort($allItems, function ($a, $b) {
if ($b['_relevance'] !== $a['_relevance']) {
return $b['_relevance'] - $a['_relevance'];
}
return ($b['views'] ?? 0) - ($a['views'] ?? 0);
});
}
foreach ($allItems as &$item) {
unset($item['_relevance']);
}
unset($item);
$totalItems = count($allItems);
$allItems = array_slice($allItems, 0, $limit);
$this->success('搜索完成', [
'keyword' => $keyword,
'type' => $type,
'mode' => $mode,
'sort' => $sort,
'page' => $page,
'limit' => $limit,
'total' => $totalItems,
'list' => $allItems,
'type_stats' => $typeStats,
]);
}
/**
* @name 精确搜索
* @desc 完全匹配搜索使用SQL = 操作符
*/
public function exact()
{
$keyword = input('get.keyword', '', 'trim');
$type = input('get.type', 'all', 'trim');
$field = input('get.field', 'auto', 'trim');
$limit = min(50, max(1, input('get.limit', 20, 'intval')));
$keyword = $this->_validateKeyword($keyword);
$this->_recordSearch($keyword);
$types = ($type === 'all') ? array_keys(self::$sourceMap) : explode(',', $type);
$results = [];
foreach ($types as $t) {
if (!isset(self::$sourceMap[$t])) continue;
$config = self::$sourceMap[$t];
try {
$query = Db::name($config['table']);
$this->_applyStatus($query, $config);
$searchFields = $config['search'];
if ($field !== 'auto' && in_array($field, $searchFields)) {
$searchFields = [$field];
}
$query->where(function ($q) use ($searchFields, $keyword) {
foreach ($searchFields as $f) {
$q->whereOr($f, '=', $keyword);
}
});
$count = $query->count();
if ($count > 0) {
$list = $query->field($config['return'])->limit($limit)->order('views desc')->select();
$items = [];
foreach ($list as $row) {
$items[] = $this->_formatItem($t, $row);
}
$results[] = [
'type' => $t,
'name' => $config['name'],
'icon' => $config['icon'],
'count' => $count,
'list' => $items,
];
}
} catch (\Exception $e) {}
}
$totalMatched = array_sum(array_column($results, 'count'));
$this->success('精确搜索完成', [
'keyword' => $keyword,
'mode' => 'exact',
'total_matched' => $totalMatched,
'type_count' => count($results),
'results' => $results,
]);
}
/**
* @name 模糊搜索
* @desc LIKE关键词匹配搜索
*/
public function fuzzy()
{
$keyword = input('get.keyword', '', 'trim');
$type = input('get.type', 'all', 'trim');
$field = input('get.field', 'auto', 'trim');
$page = max(1, input('get.page', 1, 'intval'));
$limit = min(50, max(1, input('get.limit', 20, 'intval')));
$keyword = $this->_validateKeyword($keyword);
$this->_recordSearch($keyword);
$types = ($type === 'all') ? array_keys(self::$sourceMap) : explode(',', $type);
$results = [];
foreach ($types as $t) {
if (!isset(self::$sourceMap[$t])) continue;
$config = self::$sourceMap[$t];
try {
$query = Db::name($config['table']);
$this->_applyStatus($query, $config);
$searchFields = $config['search'];
if ($field !== 'auto' && in_array($field, $searchFields)) {
$searchFields = [$field];
}
$like = '%' . $keyword . '%';
$query->where(function ($q) use ($searchFields, $like) {
foreach ($searchFields as $f) {
$q->whereOr($f, 'like', $like);
}
});
$count = $query->count();
if ($count > 0) {
$list = $query->field($config['return'])
->page($page, $limit)
->order('views desc')
->select();
$items = [];
foreach ($list as $row) {
$items[] = $this->_formatItem($t, $row);
}
$results[] = [
'type' => $t,
'name' => $config['name'],
'icon' => $config['icon'],
'count' => $count,
'list' => $items,
];
}
} catch (\Exception $e) {}
}
$totalMatched = array_sum(array_column($results, 'count'));
$this->success('模糊搜索完成', [
'keyword' => $keyword,
'mode' => 'fuzzy',
'page' => $page,
'limit' => $limit,
'total_matched' => $totalMatched,
'type_count' => count($results),
'results' => $results,
]);
}
/**
* @name 相关搜索
* @desc 基于关键词分词的相关搜索,扩大搜索范围
*/
public function related()
{
$keyword = input('get.keyword', '', 'trim');
$type = input('get.type', 'all', 'trim');
$limit = min(50, max(1, input('get.limit', 20, 'intval')));
$keyword = $this->_validateKeyword($keyword);
$this->_recordSearch($keyword);
$keywords = $this->_extractKeywords($keyword);
if (empty($keywords)) {
$this->error('未能提取有效关键词');
}
$types = ($type === 'all') ? array_keys(self::$sourceMap) : explode(',', $type);
$results = [];
foreach ($types as $t) {
if (!isset(self::$sourceMap[$t])) continue;
$config = self::$sourceMap[$t];
try {
$query = Db::name($config['table']);
$this->_applyStatus($query, $config);
$query->where(function ($q) use ($config, $keywords) {
foreach ($keywords as $kw) {
$like = '%' . $kw . '%';
foreach ($config['search'] as $f) {
$q->whereOr($f, 'like', $like);
}
}
});
$count = $query->count();
if ($count > 0) {
$list = $query->field($config['return'])->limit($limit)->order('views desc')->select();
$items = [];
foreach ($list as $row) {
$item = $this->_formatItem($t, $row);
$item['_score'] = $this->_calcRelevance($item, $keyword, 'related');
$items[] = $item;
}
usort($items, function ($a, $b) {
return $b['_score'] - $a['_score'];
});
foreach ($items as &$it) {
unset($it['_score']);
}
unset($it);
$results[] = [
'type' => $t,
'name' => $config['name'],
'icon' => $config['icon'],
'count' => $count,
'list' => $items,
];
}
} catch (\Exception $e) {}
}
$totalMatched = array_sum(array_column($results, 'count'));
$this->success('相关搜索完成', [
'keyword' => $keyword,
'mode' => 'related',
'keywords' => $keywords,
'total_matched' => $totalMatched,
'type_count' => count($results),
'results' => $results,
]);
}
/**
* @name 条件搜索
* @desc 支持多条件组合筛选搜索
* @param string type 数据类型(必填)
* @param array conditions 条件列表JSON [{"field":"author","op":"like","value":"李白"}]
* @param string sort 排序字段
* @param string order 排序方向(asc/desc)
* @param int page 页码
* @param int limit 每页数量
*/
public function condition()
{
$type = input('get.type', '', 'trim');
$conditions = input('get.conditions', '', 'trim');
$sort = input('get.sort', 'views', 'trim');
$order = input('get.order', 'desc', 'trim');
$page = max(1, input('get.page', 1, 'intval'));
$limit = min(50, max(1, input('get.limit', 20, 'intval')));
if (empty($type) || !isset(self::$sourceMap[$type])) {
$allowed = implode('/', array_keys(self::$sourceMap));
$this->error('请指定有效的数据类型,可选: ' . $allowed);
}
if (empty($conditions)) {
$this->error('请提供搜索条件');
}
$condList = json_decode($conditions, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($condList)) {
$this->error('条件格式错误需为JSON数组');
}
$config = self::$sourceMap[$type];
$query = Db::name($config['table']);
$this->_applyStatus($query, $config);
$validOps = ['=', '!=', '>', '<', '>=', '<=', 'like', 'not like', 'in', 'not in', 'between'];
$searchFields = $config['search'];
$allFields = array_merge($searchFields, ['id', 'views', 'createtime', 'updatetime']);
foreach ($condList as $cond) {
if (!isset($cond['field']) || !isset($cond['value'])) continue;
$f = trim($cond['field']);
$op = isset($cond['op']) ? strtolower(trim($cond['op'])) : '=';
$v = $cond['value'];
if (!in_array($op, $validOps)) {
$op = '=';
}
if ($op === 'like') {
$v = '%' . $v . '%';
}
try {
$query->where($f, $op, $v);
} catch (\Exception $e) {}
}
$total = $query->count();
$list = $query->field($config['return'])
->page($page, $limit)
->order($sort, $order)
->select();
$items = [];
foreach ($list as $row) {
$items[] = $this->_formatItem($type, $row);
}
$this->success('条件搜索完成', [
'type' => $type,
'name' => $config['name'],
'icon' => $config['icon'],
'conditions' => $condList,
'total' => $total,
'page' => $page,
'limit' => $limit,
'list' => $items,
]);
}
/**
* @name ID查询
* @desc 根据类型和ID获取单条内容详情
* @param string type 数据类型
* @param int id 内容ID
*/
public function getById()
{
$type = input('get.type', '', 'trim');
$id = input('get.id', 0, 'intval');
if (empty($type) || !isset(self::$sourceMap[$type])) {
$allowed = implode('/', array_keys(self::$sourceMap));
$this->error('请指定有效的数据类型,可选: ' . $allowed);
}
if (!$id) {
$this->error('请提供内容ID');
}
$config = self::$sourceMap[$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->_formatDetailItem($type, $row);
$this->success('查询成功', $item);
}
/**
* @name ID反查
* @desc 根据ID列表批量查询多条内容支持跨类型
* @param string ids ID列表格式: type_id,type_id (如 poetry_1,story_5)
* @param int lite 轻量模式(1=只返回summary)
*/
public function getByIds()
{
$ids = input('get.ids', '', 'trim');
$lite = input('get.lite', 0, 'intval');
if (empty($ids)) {
$this->error('请提供ID列表格式: type_id,type_id');
}
$pairs = explode(',', $ids);
if (count($pairs) > 50) {
$this->error('最多查询50条');
}
$grouped = [];
foreach ($pairs as $pair) {
$parts = explode('_', $pair, 2);
if (count($parts) === 2 && isset(self::$sourceMap[$parts[0]])) {
$grouped[$parts[0]][] = intval($parts[1]);
}
}
$items = [];
$notFound = [];
foreach ($grouped as $type => $typeIds) {
$config = self::$sourceMap[$type];
try {
$query = Db::name($config['table'])->where('id', 'in', $typeIds);
$this->_applyStatus($query, $config);
$list = $query->field($config['return'])->select();
$foundIds = [];
foreach ($list as $row) {
$item = $this->_formatItem($type, $row);
if ($lite) {
unset($item['content']);
}
$items[] = $item;
$foundIds[] = $row['id'];
}
$missing = array_diff($typeIds, $foundIds);
foreach ($missing as $mid) {
$notFound[] = $type . '_' . $mid;
}
} catch (\Exception $e) {
foreach ($typeIds as $mid) {
$notFound[] = $type . '_' . $mid;
}
}
}
$this->success('查询完成', [
'total' => count($items),
'list' => $items,
'not_found' => $notFound,
]);
}
/**
* @name 搜索建议
* @desc 输入前缀返回搜索建议
*/
public function suggest()
{
$keyword = input('get.keyword', '', 'trim');
$limit = min(20, max(1, input('get.limit', 10, 'intval')));
if (empty($keyword)) $this->error('请输入关键词');
if (mb_strlen($keyword) > 30) $this->error('关键词过长');
$like = $keyword . '%';
$suggestions = [];
$suggestConfig = [
'chengyu' => ['table' => 'cy', 'field' => 'cy', 'label' => '成语'],
'poetry' => ['table' => 'poetry', 'field' => 'name', 'label' => '诗词'],
'hanzi' => ['table' => 'hanzi', 'field' => 'zi', 'label' => '汉字'],
'wisdom' => ['table' => 'wisdom', 'field' => 'name', 'label' => '名言'],
'story' => ['table' => 'story', 'field' => 'title', 'label' => '故事'],
'lyric' => ['table' => 'lyric', 'field' => 'title', 'label' => '歌词'],
'drug' => ['table' => 'drug', 'field' => 'name', 'label' => '药品'],
'herbal' => ['table' => 'herbal', 'field' => 'name', 'label' => '中草药'],
'article' => ['table' => 'article', 'field' => 'title', 'label' => '文章'],
];
foreach ($suggestConfig as $type => $sc) {
if (count($suggestions) >= $limit) break;
try {
$query = Db::name($sc['table'])->where($sc['field'], 'like', $like);
if (isset(self::$sourceMap[$type])) {
$this->_applyStatus($query, self::$sourceMap[$type]);
}
$list = $query->limit($limit - count($suggestions))->column($sc['field'] . ' as text');
foreach ($list as $text) {
$suggestions[] = ['text' => $text, 'type' => $type, 'label' => $sc['label']];
}
} catch (\Exception $e) {}
}
$suggestions = array_slice($suggestions, 0, $limit);
$this->success('成功', ['suggestions' => $suggestions]);
}
/**
* @name 热门搜索
* @desc 返回热门搜索关键词
*/
public function hot()
{
$limit = min(50, max(1, input('get.limit', 20, 'intval')));
$days = min(7, max(1, input('get.days', 1, 'intval')));
$dir = RUNTIME_PATH . 'search_hot';
$merged = [];
for ($i = 0; $i < $days; $i++) {
$date = date('Y-m-d', strtotime("-{$i} days"));
$file = $dir . DS . $date . '.txt';
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true) ?: [];
foreach ($data as $word => $count) {
if (!isset($merged[$word])) $merged[$word] = 0;
$merged[$word] += $count;
}
}
}
arsort($merged);
$hotList = [];
$i = 0;
foreach ($merged as $word => $count) {
$hotList[] = ['keyword' => $word, 'count' => $count];
$i++;
if ($i >= $limit) break;
}
$this->success('成功', ['hot_list' => $hotList, 'days' => $days]);
}
/**
* @name 搜索历史
* @desc 获取/清除用户搜索历史
*/
public function history()
{
$action = input('get.action', 'list', 'trim');
$userId = $this->_getUserId();
$dir = RUNTIME_PATH . 'search_history';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$file = $dir . DS . ($userId ?: md5($this->request->ip())) . '.txt';
if ($action === 'clear') {
if (file_exists($file)) unlink($file);
$this->success('已清除');
}
$history = [];
if (file_exists($file)) {
$history = json_decode(file_get_contents($file), true) ?: [];
}
$limit = min(50, max(1, input('get.limit', 20, 'intval')));
$history = array_slice($history, 0, $limit);
$this->success('成功', ['history' => $history]);
}
/**
* @name 数据源列表
* @desc 返回所有可搜索的数据源及字段信息
*/
public function sources()
{
$sources = [];
foreach (self::$sourceMap as $key => $config) {
$count = 0;
try {
$query = Db::name($config['table']);
$this->_applyStatus($query, $config);
$count = $query->count();
} catch (\Exception $e) {}
$sources[] = [
'key' => $key,
'name' => $config['name'],
'icon' => $config['icon'],
'total' => $count,
'search_fields'=> $config['search'],
'return_fields'=> $config['return'],
];
}
$this->success('成功', [
'total_sources' => count($sources),
'sources' => $sources,
]);
}
/**
* @name 多字段搜索
* @desc 在指定类型的指定字段中搜索,支持字段级精确/模糊控制
* @param string type 数据类型(必填)
* @param string field 搜索字段(必填)
* @param string keyword 关键词
* @param string op 操作符(=/like)
* @param int page 页码
* @param int limit 每页数量
*/
public function fieldSearch()
{
$type = input('get.type', '', 'trim');
$field = input('get.field', '', 'trim');
$keyword = input('get.keyword', '', 'trim');
$op = input('get.op', 'like', 'trim');
$page = max(1, input('get.page', 1, 'intval'));
$limit = min(50, max(1, input('get.limit', 20, 'intval')));
if (empty($type) || !isset(self::$sourceMap[$type])) {
$this->error('请指定有效的数据类型');
}
if (empty($field)) {
$this->error('请指定搜索字段');
}
if (empty($keyword)) {
$this->error('请输入搜索关键词');
}
$config = self::$sourceMap[$type];
if (!in_array($field, $config['search']) && !in_array($field, ['id', 'views'])) {
$this->error('不支持的字段: ' . $field . ',可选: ' . implode(',', $config['search']));
}
$query = Db::name($config['table']);
$this->_applyStatus($query, $config);
if ($op === '=') {
$query->where($field, '=', $keyword);
} else {
$query->where($field, 'like', '%' . $keyword . '%');
}
$total = $query->count();
$list = $query->field($config['return'])
->page($page, $limit)
->order('views desc')
->select();
$items = [];
foreach ($list as $row) {
$items[] = $this->_formatItem($type, $row);
}
$this->success('搜索完成', [
'type' => $type,
'name' => $config['name'],
'icon' => $config['icon'],
'field' => $field,
'op' => $op,
'keyword'=> $keyword,
'total' => $total,
'page' => $page,
'limit' => $limit,
'list' => $items,
]);
}
/**
* @name 全量信息详情
* @desc 返回指定类型+ID的完整原始数据包含数据库所有字段用于APP详情页展示全量信息
* @param string type 数据类型
* @param int id 内容ID
* @param int formatted 是否格式化(1=统一格式0=原始字段)
*/
public function fullDetail()
{
$type = input('get.type', '', 'trim');
$id = input('get.id', 0, 'intval');
$formatted = input('get.formatted', 1, 'intval');
if (empty($type) || !isset(self::$sourceMap[$type])) {
$this->error('请指定有效的数据类型');
}
if (!$id) {
$this->error('请提供内容ID');
}
$config = self::$sourceMap[$type];
$row = Db::name($config['table'])->where('id', $id)->find();
if (!$row) {
$this->error('内容不存在');
}
Db::name($config['table'])->where('id', $id)->setInc('views');
if ($formatted) {
$item = $this->_formatDetailItem($type, $row);
$rawData = [];
foreach ($row as $k => $v) {
if (!array_key_exists($k, $item)) {
$rawData[$k] = $v;
}
}
$item['raw_data'] = $rawData;
} else {
$item = $row;
$item['feed_type'] = $type;
$item['feed_name'] = $config['name'];
$item['feed_icon'] = $config['icon'];
}
$this->success('查询成功', $item);
}
/**
* @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) || !isset(self::$sourceMap[$type])) {
$this->error('请指定有效的数据类型');
}
if (!$id) {
$this->error('请提供内容ID');
}
$config = self::$sourceMap[$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('views 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('views 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,
]);
}
/**
* @name 搜索高亮
* @desc 对搜索结果文本进行关键词高亮标记返回带em标签的结果
* @param string keyword 关键词
* @param string type 数据类型
* @param string mode 搜索模式
* @param int limit 每页数量
* @param string tag 高亮标签(默认em)
*/
public function highlight()
{
$keyword = input('get.keyword', '', 'trim');
$type = input('get.type', 'all', 'trim');
$mode = input('get.mode', 'fuzzy', 'trim');
$limit = min(20, max(1, input('get.limit', 10, 'intval')));
$tag = input('get.tag', 'em', 'trim');
$keyword = $this->_validateKeyword($keyword);
$types = ($type === 'all') ? array_keys(self::$sourceMap) : explode(',', $type);
$allItems = [];
foreach ($types as $t) {
if (!isset(self::$sourceMap[$t])) continue;
$config = self::$sourceMap[$t];
try {
$query = Db::name($config['table']);
$this->_applyStatus($query, $config);
$searchFields = $config['search'];
$like = '%' . $keyword . '%';
$query->where(function ($q) use ($searchFields, $like) {
foreach ($searchFields as $f) {
$q->whereOr($f, 'like', $like);
}
});
$list = $query->field($config['return'])
->order('views desc')
->limit($limit)
->select();
foreach ($list as $row) {
$item = $this->_formatItem($t, $row);
$item['title_highlight'] = $this->_highlightText($item['title'], $keyword, $tag);
$item['content_highlight'] = $this->_highlightText($item['summary'] ?: $item['content'], $keyword, $tag);
$item['author_highlight'] = $this->_highlightText($item['author'], $keyword, $tag);
$allItems[] = $item;
}
} catch (\Exception $e) {}
}
$this->success('搜索完成', [
'keyword' => $keyword,
'tag' => $tag,
'total' => count($allItems),
'list' => array_slice($allItems, 0, $limit),
]);
}
private function _highlightText($text, $keyword, $tag = 'em')
{
if (empty($text) || empty($keyword)) return $text;
$kw = preg_quote($keyword, '/');
return preg_replace('/(' . $kw . ')/iu', '<' . $tag . '>$1</' . $tag . '>', $text);
}
private function _extractKeywords($text)
{
$text = preg_replace('/[^\x{4e00}-\x{9fa5}a-zA-Z0-9]/u', ' ', $text);
$text = trim($text);
if (empty($text)) return [];
$words = preg_split('/\s+/', $text);
$keywords = [];
foreach ($words as $w) {
$w = trim($w);
if (mb_strlen($w) >= 2) {
$keywords[] = $w;
}
}
if (empty($keywords) && mb_strlen($text) >= 2) {
$len = mb_strlen($text);
for ($i = 0; $i < $len - 1; $i++) {
$keywords[] = mb_substr($text, $i, 2);
}
}
return array_unique($keywords);
}
private function _calcRelevance($item, $keyword, $mode)
{
$score = 0;
$title = $item['title'] ?? ($item['name'] ?? '');
$content = $item['content'] ?? ($item['summary'] ?? '');
$author = $item['author'] ?? '';
if ($mode === 'exact') {
if ($title === $keyword) $score += 100;
elseif (strpos($title, $keyword) !== false) $score += 60;
if ($author === $keyword) $score += 50;
if (strpos($content, $keyword) !== false) $score += 30;
} else {
if ($title === $keyword) $score += 100;
elseif (strpos($title, $keyword) !== false) $score += 60;
if ($author === $keyword) $score += 50;
elseif (strpos($author, $keyword) !== false) $score += 30;
if (strpos($content, $keyword) !== false) $score += 20;
$keywords = $this->_extractKeywords($keyword);
foreach ($keywords as $kw) {
if (strpos($title, $kw) !== false) $score += 15;
if (strpos($author, $kw) !== false) $score += 10;
if (strpos($content, $kw) !== false) $score += 5;
}
}
$score += min(10, intval(($item['views'] ?? 0) / 1000));
return $score;
}
}