mirror of
https://github.com/kuaifan/dootask.git
synced 2026-01-21 16:48:13 +00:00
feat: 集成 SeekDB AI 搜索引擎实现文件内容搜索
This commit is contained in:
parent
a8d4f261a4
commit
23faf28f7f
211
app/Console/Commands/SyncFileToSeekDB.php
Normal file
211
app/Console/Commands/SyncFileToSeekDB.php
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\File;
|
||||||
|
use App\Module\Apps;
|
||||||
|
use App\Module\SeekDB\SeekDBFile;
|
||||||
|
use App\Module\SeekDB\SeekDBKeyValue;
|
||||||
|
use Cache;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class SyncFileToSeekDB extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 更新数据
|
||||||
|
* --f: 全量更新 (默认)
|
||||||
|
* --i: 增量更新(从上次更新的最后一个ID接上)
|
||||||
|
* --u: 仅同步文件用户关系(不同步文件内容)
|
||||||
|
*
|
||||||
|
* 清理数据
|
||||||
|
* --c: 清除索引
|
||||||
|
*/
|
||||||
|
|
||||||
|
protected $signature = 'seekdb:sync-files {--f} {--i} {--c} {--u} {--batch=100}';
|
||||||
|
protected $description = '同步文件内容到 SeekDB';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
$this->error("应用「SeekDB」未安装");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册信号处理器(仅在支持pcntl扩展的环境下)
|
||||||
|
if (extension_loaded('pcntl')) {
|
||||||
|
pcntl_async_signals(true); // 启用异步信号处理
|
||||||
|
pcntl_signal(SIGINT, [$this, 'handleSignal']); // Ctrl+C
|
||||||
|
pcntl_signal(SIGTERM, [$this, 'handleSignal']); // kill
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查锁,如果已被占用则退出
|
||||||
|
$lockInfo = $this->getLock();
|
||||||
|
if ($lockInfo) {
|
||||||
|
$this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置锁
|
||||||
|
$this->setLock();
|
||||||
|
|
||||||
|
// 清除索引
|
||||||
|
if ($this->option('c')) {
|
||||||
|
$this->info('清除索引...');
|
||||||
|
SeekDBKeyValue::clear();
|
||||||
|
SeekDBFile::clear();
|
||||||
|
$this->info("索引删除成功");
|
||||||
|
$this->releaseLock();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅同步文件用户关系
|
||||||
|
if ($this->option('u')) {
|
||||||
|
$this->info('开始同步文件用户关系...');
|
||||||
|
$count = SeekDBFile::syncAllFileUsers(function ($count) {
|
||||||
|
if ($count % 1000 === 0) {
|
||||||
|
$this->info(" 已同步 {$count} 条关系...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$this->info("文件用户关系同步完成,共 {$count} 条");
|
||||||
|
$this->releaseLock();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('开始同步文件数据...');
|
||||||
|
|
||||||
|
// 同步文件数据
|
||||||
|
$this->syncFiles();
|
||||||
|
|
||||||
|
// 全量同步时,同步文件用户关系
|
||||||
|
if ($this->option('f') || (!$this->option('i') && !$this->option('u'))) {
|
||||||
|
$this->info("\n同步文件用户关系...");
|
||||||
|
$count = SeekDBFile::syncAllFileUsers(function ($count) {
|
||||||
|
if ($count % 1000 === 0) {
|
||||||
|
$this->info(" 已同步 {$count} 条关系...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$this->info("文件用户关系同步完成,共 {$count} 条");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成
|
||||||
|
$this->info("\n同步完成");
|
||||||
|
$this->releaseLock();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取锁信息
|
||||||
|
*
|
||||||
|
* @return array|null 如果锁存在返回锁信息,否则返回null
|
||||||
|
*/
|
||||||
|
private function getLock(): ?array
|
||||||
|
{
|
||||||
|
$lockKey = md5($this->signature);
|
||||||
|
return Cache::has($lockKey) ? Cache::get($lockKey) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置锁
|
||||||
|
*/
|
||||||
|
private function setLock(): void
|
||||||
|
{
|
||||||
|
$lockKey = md5($this->signature);
|
||||||
|
$lockInfo = [
|
||||||
|
'started_at' => date('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
Cache::put($lockKey, $lockInfo, 600); // 10分钟(文件同步可能较慢)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放锁
|
||||||
|
*/
|
||||||
|
private function releaseLock(): void
|
||||||
|
{
|
||||||
|
$lockKey = md5($this->signature);
|
||||||
|
Cache::forget($lockKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理终端信号
|
||||||
|
*
|
||||||
|
* @param int $signal
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function handleSignal(int $signal): void
|
||||||
|
{
|
||||||
|
// 释放锁
|
||||||
|
$this->releaseLock();
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步文件数据
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function syncFiles(): void
|
||||||
|
{
|
||||||
|
// 获取上次同步的最后ID
|
||||||
|
$lastKey = "sync:seekdbFileLastId";
|
||||||
|
$lastId = $this->option('i') ? intval(SeekDBKeyValue::get($lastKey, 0)) : 0;
|
||||||
|
|
||||||
|
if ($lastId > 0) {
|
||||||
|
$this->info("\n同步文件数据({$lastId})...");
|
||||||
|
} else {
|
||||||
|
$this->info("\n同步文件数据...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询条件:排除文件夹,使用最大文件限制
|
||||||
|
// 具体的文件类型大小检查在 SeekDBFile::sync 中进行
|
||||||
|
$maxFileSize = SeekDBFile::getMaxFileSize();
|
||||||
|
$query = File::where('id', '>', $lastId)
|
||||||
|
->where('type', '!=', 'folder')
|
||||||
|
->where('size', '<=', $maxFileSize);
|
||||||
|
|
||||||
|
$num = 0;
|
||||||
|
$count = $query->count();
|
||||||
|
$batchSize = $this->option('batch');
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
$lastNum = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
// 获取一批
|
||||||
|
$files = File::where('id', '>', $lastId)
|
||||||
|
->where('type', '!=', 'folder')
|
||||||
|
->where('size', '<=', $maxFileSize)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($batchSize)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($files->isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$num += count($files);
|
||||||
|
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
|
||||||
|
if ($progress < 100) {
|
||||||
|
$progress = number_format($progress, 2);
|
||||||
|
}
|
||||||
|
$this->info("{$num}/{$count} ({$progress}%) 正在同步文件ID {$files->first()->id} ~ {$files->last()->id} ({$total}|{$lastNum})");
|
||||||
|
|
||||||
|
// 刷新锁
|
||||||
|
$this->setLock();
|
||||||
|
|
||||||
|
// 同步数据
|
||||||
|
$lastNum = SeekDBFile::batchSync($files);
|
||||||
|
$total += $lastNum;
|
||||||
|
|
||||||
|
// 更新最后ID
|
||||||
|
$lastId = $files->last()->id;
|
||||||
|
SeekDBKeyValue::set($lastKey, $lastId);
|
||||||
|
} while (count($files) == $batchSize);
|
||||||
|
|
||||||
|
$this->info("同步文件结束 - 最后ID {$lastId}");
|
||||||
|
$this->info("已索引文件数量: " . SeekDBFile::getIndexedCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ use App\Models\FileLink;
|
|||||||
use App\Models\FileUser;
|
use App\Models\FileUser;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserRecentItem;
|
use App\Models\UserRecentItem;
|
||||||
|
use App\Module\Apps;
|
||||||
use App\Module\Base;
|
use App\Module\Base;
|
||||||
use App\Module\Down;
|
use App\Module\Down;
|
||||||
use App\Module\Timer;
|
use App\Module\Timer;
|
||||||
@ -125,6 +126,8 @@ class FileController extends AbstractController
|
|||||||
* @apiParam {String} [link] 通过分享地址搜索(如:https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==)
|
* @apiParam {String} [link] 通过分享地址搜索(如:https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==)
|
||||||
* @apiParam {String} [key] 关键词
|
* @apiParam {String} [key] 关键词
|
||||||
* @apiParam {Number} [take] 获取数量(默认:50,最大:100)
|
* @apiParam {Number} [take] 获取数量(默认:50,最大:100)
|
||||||
|
* @apiParam {String} [search_content] 是否搜索文件内容(yes/no,默认:no)
|
||||||
|
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 search_content=yes 时有效)
|
||||||
*
|
*
|
||||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||||
@ -136,6 +139,8 @@ class FileController extends AbstractController
|
|||||||
//
|
//
|
||||||
$link = trim(Request::input('link'));
|
$link = trim(Request::input('link'));
|
||||||
$key = trim(Request::input('key'));
|
$key = trim(Request::input('key'));
|
||||||
|
$searchContent = Request::input('search_content', 'no') === 'yes';
|
||||||
|
$searchType = Request::input('search_type', 'hybrid');
|
||||||
$id = 0;
|
$id = 0;
|
||||||
$take = Base::getPaginate(100, 50, 'take');
|
$take = Base::getPaginate(100, 50, 'take');
|
||||||
if (preg_match("/\/single\/file\/(.*?)$/i", $link, $match)) {
|
if (preg_match("/\/single\/file\/(.*?)$/i", $link, $match)) {
|
||||||
@ -145,6 +150,35 @@ class FileController extends AbstractController
|
|||||||
return Base::retSuccess('success', []);
|
return Base::retSuccess('success', []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果需要搜索文件内容且有关键词
|
||||||
|
if ($searchContent && $key && !$id && Apps::isInstalled('seekdb')) {
|
||||||
|
$results = \App\Module\SeekDB\SeekDBFile::search(
|
||||||
|
$user->userid,
|
||||||
|
$key,
|
||||||
|
$searchType,
|
||||||
|
0,
|
||||||
|
$take
|
||||||
|
);
|
||||||
|
// 获取完整的文件信息
|
||||||
|
if (!empty($results)) {
|
||||||
|
$fileIds = array_column($results, 'file_id');
|
||||||
|
$files = File::whereIn('id', $fileIds)->get()->keyBy('id');
|
||||||
|
$array = [];
|
||||||
|
foreach ($results as $result) {
|
||||||
|
$file = $files->get($result['file_id']);
|
||||||
|
if ($file) {
|
||||||
|
$temp = $file->toArray();
|
||||||
|
$temp['content_preview'] = $result['content_preview'] ?? null;
|
||||||
|
$temp['relevance'] = $result['relevance'] ?? 0;
|
||||||
|
$array[] = $temp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Base::retSuccess('success', $array);
|
||||||
|
}
|
||||||
|
return Base::retSuccess('success', []);
|
||||||
|
}
|
||||||
|
|
||||||
// 搜索自己的
|
// 搜索自己的
|
||||||
$builder = File::whereUserid($user->userid);
|
$builder = File::whereUserid($user->userid);
|
||||||
if ($id) {
|
if ($id) {
|
||||||
|
|||||||
@ -22,6 +22,7 @@ use App\Tasks\DeleteBotMsgTask;
|
|||||||
use App\Tasks\CheckinRemindTask;
|
use App\Tasks\CheckinRemindTask;
|
||||||
use App\Tasks\CloseMeetingRoomTask;
|
use App\Tasks\CloseMeetingRoomTask;
|
||||||
use App\Tasks\ZincSearchSyncTask;
|
use App\Tasks\ZincSearchSyncTask;
|
||||||
|
use App\Tasks\SeekDBFileSyncTask;
|
||||||
use App\Tasks\UnclaimedTaskRemindTask;
|
use App\Tasks\UnclaimedTaskRemindTask;
|
||||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||||
use Laravolt\Avatar\Avatar;
|
use Laravolt\Avatar\Avatar;
|
||||||
@ -273,6 +274,8 @@ class IndexController extends InvokeController
|
|||||||
Task::deliver(new CloseMeetingRoomTask());
|
Task::deliver(new CloseMeetingRoomTask());
|
||||||
// ZincSearch 同步
|
// ZincSearch 同步
|
||||||
Task::deliver(new ZincSearchSyncTask());
|
Task::deliver(new ZincSearchSyncTask());
|
||||||
|
// SeekDB 文件同步
|
||||||
|
Task::deliver(new SeekDBFileSyncTask());
|
||||||
|
|
||||||
return "success";
|
return "success";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -738,4 +738,137 @@ class AI
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 OpenAI 兼容接口获取文本的 Embedding 向量
|
||||||
|
*
|
||||||
|
* @param string $text 需要转换的文本
|
||||||
|
* @param bool $noCache 是否禁用缓存
|
||||||
|
* @return array 返回结果,成功时 data 为向量数组
|
||||||
|
*/
|
||||||
|
public static function getEmbedding($text, $noCache = false)
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled('ai')) {
|
||||||
|
return Base::retError('应用「AI Assistant」未安装');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($text)) {
|
||||||
|
return Base::retError('文本内容不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截断过长的文本(OpenAI 限制 8191 tokens,约 32K 字符)
|
||||||
|
$text = mb_substr($text, 0, 30000);
|
||||||
|
|
||||||
|
$cacheKey = "openAIEmbedding::" . md5($text);
|
||||||
|
if ($noCache) {
|
||||||
|
Cache::forget($cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = self::resolveEmbeddingProvider();
|
||||||
|
if (!$provider) {
|
||||||
|
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $provider) {
|
||||||
|
$payload = [
|
||||||
|
"model" => $provider['model'],
|
||||||
|
"input" => $text,
|
||||||
|
];
|
||||||
|
$post = json_encode($payload);
|
||||||
|
|
||||||
|
$ai = new self($post);
|
||||||
|
$ai->setProvider($provider);
|
||||||
|
$ai->setUrlPath('/embeddings');
|
||||||
|
$ai->setTimeout(30);
|
||||||
|
|
||||||
|
$res = $ai->request(true);
|
||||||
|
if (Base::isError($res)) {
|
||||||
|
return Base::retError("Embedding 请求失败", $res);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resData = Base::json2array($res['data']);
|
||||||
|
if (empty($resData['data'][0]['embedding'])) {
|
||||||
|
return Base::retError("Embedding 接口返回数据格式错误", $resData);
|
||||||
|
}
|
||||||
|
|
||||||
|
$embedding = $resData['data'][0]['embedding'];
|
||||||
|
if (!is_array($embedding) || empty($embedding)) {
|
||||||
|
return Base::retError("Embedding 向量为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Base::retSuccess("success", $embedding);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Base::isError($result)) {
|
||||||
|
Cache::forget($cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Embedding 模型配置
|
||||||
|
*
|
||||||
|
* @return array|null
|
||||||
|
*/
|
||||||
|
protected static function resolveEmbeddingProvider()
|
||||||
|
{
|
||||||
|
$setting = Base::setting('aibotSetting');
|
||||||
|
if (!is_array($setting)) {
|
||||||
|
$setting = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先使用 OpenAI(支持 embedding 接口)
|
||||||
|
$key = trim((string)($setting['openai_key'] ?? ''));
|
||||||
|
if ($key !== '') {
|
||||||
|
$baseUrl = trim((string)($setting['openai_base_url'] ?? ''));
|
||||||
|
$baseUrl = $baseUrl ?: 'https://api.openai.com/v1';
|
||||||
|
$agency = trim((string)($setting['openai_agency'] ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'vendor' => 'openai',
|
||||||
|
'model' => 'text-embedding-ada-002',
|
||||||
|
'api_key' => $key,
|
||||||
|
'base_url' => rtrim($baseUrl, '/'),
|
||||||
|
'agency' => $agency,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各厂商的默认 baseUrl 和 embedding 模型
|
||||||
|
$vendorDefaults = [
|
||||||
|
'deepseek' => [
|
||||||
|
'base_url' => 'https://api.deepseek.com',
|
||||||
|
'model' => 'deepseek-embedding',
|
||||||
|
],
|
||||||
|
'zhipu' => [
|
||||||
|
'base_url' => 'https://open.bigmodel.cn/api/paas/v4',
|
||||||
|
'model' => 'embedding-2',
|
||||||
|
],
|
||||||
|
'qianwen' => [
|
||||||
|
'base_url' => 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||||
|
'model' => 'text-embedding-v3',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// 尝试其他支持 embedding 的服务(如 deepseek、zhipu、qianwen 等)
|
||||||
|
foreach ($vendorDefaults as $vendor => $defaults) {
|
||||||
|
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
|
||||||
|
|
||||||
|
if ($key !== '') {
|
||||||
|
$baseUrl = trim((string)($setting[$vendor . '_base_url'] ?? ''));
|
||||||
|
$baseUrl = $baseUrl ?: $defaults['base_url']; // 使用配置或默认值
|
||||||
|
$agency = trim((string)($setting[$vendor . '_agency'] ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'vendor' => $vendor,
|
||||||
|
'model' => $defaults['model'],
|
||||||
|
'api_key' => $key,
|
||||||
|
'base_url' => rtrim($baseUrl, '/'),
|
||||||
|
'agency' => $agency,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,6 +55,7 @@ class Apps
|
|||||||
'drawio' => 'Drawio',
|
'drawio' => 'Drawio',
|
||||||
'minder' => 'Minder',
|
'minder' => 'Minder',
|
||||||
'search' => 'ZincSearch',
|
'search' => 'ZincSearch',
|
||||||
|
'seekdb' => 'SeekDB',
|
||||||
default => $appId,
|
default => $appId,
|
||||||
};
|
};
|
||||||
throw new ApiException("应用「{$name}」未安装", [], 0, false);
|
throw new ApiException("应用「{$name}」未安装", [], 0, false);
|
||||||
|
|||||||
776
app/Module/SeekDB/SeekDBBase.php
Normal file
776
app/Module/SeekDB/SeekDBBase.php
Normal file
@ -0,0 +1,776 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Module\SeekDB;
|
||||||
|
|
||||||
|
use App\Module\Apps;
|
||||||
|
use App\Module\Doo;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SeekDB 基础类
|
||||||
|
*
|
||||||
|
* SeekDB 兼容 MySQL 协议,可以直接使用 PDO 连接
|
||||||
|
*/
|
||||||
|
class SeekDBBase
|
||||||
|
{
|
||||||
|
private static ?PDO $pdo = null;
|
||||||
|
private static bool $initialized = false;
|
||||||
|
|
||||||
|
private string $host;
|
||||||
|
private int $port;
|
||||||
|
private string $user;
|
||||||
|
private string $pass;
|
||||||
|
private string $database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->host = env('SEEKDB_HOST', 'seekdb');
|
||||||
|
$this->port = (int) env('SEEKDB_PORT', 2881);
|
||||||
|
$this->user = env('SEEKDB_USER', 'root');
|
||||||
|
$this->pass = env('SEEKDB_PASSWORD', '');
|
||||||
|
$this->database = env('SEEKDB_DATABASE', 'dootask_search');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 PDO 连接
|
||||||
|
*/
|
||||||
|
private function getConnection(): ?PDO
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::$pdo === null) {
|
||||||
|
try {
|
||||||
|
// 先连接不指定数据库,用于初始化
|
||||||
|
$dsn = "mysql:host={$this->host};port={$this->port};charset=utf8mb4";
|
||||||
|
$pdo = new PDO($dsn, $this->user, $this->pass, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 初始化数据库和表
|
||||||
|
if (!self::$initialized) {
|
||||||
|
$this->initializeDatabase($pdo);
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到目标数据库
|
||||||
|
$pdo->exec("USE `{$this->database}`");
|
||||||
|
self::$pdo = $pdo;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
Log::error('SeekDB connection failed: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化数据库和表结构
|
||||||
|
*/
|
||||||
|
private function initializeDatabase(PDO $pdo): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 创建数据库
|
||||||
|
$pdo->exec("CREATE DATABASE IF NOT EXISTS `{$this->database}`");
|
||||||
|
$pdo->exec("USE `{$this->database}`");
|
||||||
|
|
||||||
|
// 创建文件向量表
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS file_vectors (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
file_id BIGINT NOT NULL,
|
||||||
|
userid BIGINT NOT NULL,
|
||||||
|
pshare BIGINT NOT NULL DEFAULT 0,
|
||||||
|
file_name VARCHAR(500),
|
||||||
|
file_type VARCHAR(50),
|
||||||
|
file_ext VARCHAR(20),
|
||||||
|
content LONGTEXT,
|
||||||
|
content_vector VECTOR(1536),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY uk_file_id (file_id),
|
||||||
|
KEY idx_userid (userid),
|
||||||
|
KEY idx_pshare (pshare),
|
||||||
|
FULLTEXT KEY ft_content (file_name, content)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
// 创建键值存储表
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS key_values (
|
||||||
|
k VARCHAR(255) PRIMARY KEY,
|
||||||
|
v TEXT,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
// 创建文件用户关系表(用于权限过滤)
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS file_users (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
file_id BIGINT NOT NULL,
|
||||||
|
userid BIGINT NOT NULL,
|
||||||
|
permission TINYINT DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY uk_file_user (file_id, userid),
|
||||||
|
KEY idx_userid (userid),
|
||||||
|
KEY idx_file_id (file_id)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
Log::info('SeekDB database initialized successfully');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
Log::warning('SeekDB initialization warning: ' . $e->getMessage());
|
||||||
|
// 不抛出异常,表可能已存在
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置连接(在长连接环境中使用)
|
||||||
|
*/
|
||||||
|
public static function resetConnection(): void
|
||||||
|
{
|
||||||
|
self::$pdo = null;
|
||||||
|
self::$initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已安装
|
||||||
|
*/
|
||||||
|
public static function isInstalled(): bool
|
||||||
|
{
|
||||||
|
return Apps::isInstalled("seekdb");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 SQL(不返回结果)
|
||||||
|
*
|
||||||
|
* @param string $sql SQL语句
|
||||||
|
* @param array $params 参数
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public function execute(string $sql, array $params = []): bool
|
||||||
|
{
|
||||||
|
$pdo = $this->getConnection();
|
||||||
|
if (!$pdo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
return $stmt->execute($params);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
Log::error('SeekDB execute error: ' . $e->getMessage(), [
|
||||||
|
'sql' => $sql,
|
||||||
|
'params' => $params
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 SQL 并返回影响行数
|
||||||
|
*
|
||||||
|
* @param string $sql SQL语句
|
||||||
|
* @param array $params 参数
|
||||||
|
* @return int 影响行数,-1 表示失败
|
||||||
|
*/
|
||||||
|
public function executeWithRowCount(string $sql, array $params = []): int
|
||||||
|
{
|
||||||
|
$pdo = $this->getConnection();
|
||||||
|
if (!$pdo) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
return $stmt->rowCount();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
Log::error('SeekDB execute error: ' . $e->getMessage(), [
|
||||||
|
'sql' => $sql,
|
||||||
|
'params' => $params
|
||||||
|
]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询并返回结果
|
||||||
|
*
|
||||||
|
* @param string $sql SQL语句
|
||||||
|
* @param array $params 参数
|
||||||
|
* @return array 查询结果
|
||||||
|
*/
|
||||||
|
public function query(string $sql, array $params = []): array
|
||||||
|
{
|
||||||
|
$pdo = $this->getConnection();
|
||||||
|
if (!$pdo) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
Log::error('SeekDB query error: ' . $e->getMessage(), [
|
||||||
|
'sql' => $sql,
|
||||||
|
'params' => $params
|
||||||
|
]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单行
|
||||||
|
*
|
||||||
|
* @param string $sql SQL语句
|
||||||
|
* @param array $params 参数
|
||||||
|
* @return array|null 单行结果
|
||||||
|
*/
|
||||||
|
public function queryOne(string $sql, array $params = []): ?array
|
||||||
|
{
|
||||||
|
$pdo = $this->getConnection();
|
||||||
|
if (!$pdo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$result = $stmt->fetch();
|
||||||
|
return $result ?: null;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
Log::error('SeekDB queryOne error: ' . $e->getMessage(), [
|
||||||
|
'sql' => $sql,
|
||||||
|
'params' => $params
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最后插入的 ID
|
||||||
|
*/
|
||||||
|
public function lastInsertId(): ?int
|
||||||
|
{
|
||||||
|
$pdo = $this->getConnection();
|
||||||
|
if (!$pdo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (int) $pdo->lastInsertId();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// 静态便捷方法
|
||||||
|
// ==============================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全文搜索
|
||||||
|
*
|
||||||
|
* @param string $keyword 关键词
|
||||||
|
* @param int $userid 用户ID(0表示不限制权限)
|
||||||
|
* @param int $limit 返回数量
|
||||||
|
* @param int $offset 偏移量
|
||||||
|
* @return array 搜索结果
|
||||||
|
*/
|
||||||
|
public static function fullTextSearch(string $keyword, int $userid = 0, int $limit = 20, int $offset = 0): array
|
||||||
|
{
|
||||||
|
if (empty($keyword)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new self();
|
||||||
|
$likeKeyword = "%{$keyword}%";
|
||||||
|
|
||||||
|
// 构建 SQL - 同时搜索文件名和内容
|
||||||
|
// 权限过滤通过 JOIN file_users 表实现
|
||||||
|
if ($userid > 0) {
|
||||||
|
// 用户可以看到:1) 自己的文件 2) 共享给自己或公开的文件
|
||||||
|
// 注意:pshare 指向共享根文件夹的 ID,file_users 存储的是共享文件夹的权限关系
|
||||||
|
$sql = "
|
||||||
|
SELECT DISTINCT
|
||||||
|
fv.file_id,
|
||||||
|
fv.userid,
|
||||||
|
fv.file_name,
|
||||||
|
fv.file_type,
|
||||||
|
fv.file_ext,
|
||||||
|
SUBSTRING(fv.content, 1, 500) as content_preview,
|
||||||
|
(
|
||||||
|
CASE WHEN fv.file_name LIKE ? THEN 10 ELSE 0 END +
|
||||||
|
IFNULL(MATCH(fv.content) AGAINST(? IN NATURAL LANGUAGE MODE), 0)
|
||||||
|
) AS relevance
|
||||||
|
FROM file_vectors fv
|
||||||
|
LEFT JOIN file_users fu ON fv.pshare = fu.file_id AND fv.pshare > 0
|
||||||
|
WHERE (fv.file_name LIKE ? OR MATCH(fv.content) AGAINST(? IN NATURAL LANGUAGE MODE))
|
||||||
|
AND (fv.userid = ? OR fu.userid IN (0, ?))
|
||||||
|
ORDER BY relevance DESC
|
||||||
|
LIMIT " . (int)$limit . " OFFSET " . (int)$offset;
|
||||||
|
|
||||||
|
$params = [$likeKeyword, $keyword, $likeKeyword, $keyword, $userid, $userid];
|
||||||
|
} else {
|
||||||
|
// 不限制权限(管理员或后台)
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
file_id,
|
||||||
|
userid,
|
||||||
|
file_name,
|
||||||
|
file_type,
|
||||||
|
file_ext,
|
||||||
|
SUBSTRING(content, 1, 500) as content_preview,
|
||||||
|
(
|
||||||
|
CASE WHEN file_name LIKE ? THEN 10 ELSE 0 END +
|
||||||
|
IFNULL(MATCH(content) AGAINST(? IN NATURAL LANGUAGE MODE), 0)
|
||||||
|
) AS relevance
|
||||||
|
FROM file_vectors
|
||||||
|
WHERE file_name LIKE ? OR MATCH(content) AGAINST(? IN NATURAL LANGUAGE MODE)
|
||||||
|
ORDER BY relevance DESC
|
||||||
|
LIMIT " . (int)$limit . " OFFSET " . (int)$offset;
|
||||||
|
|
||||||
|
$params = [$likeKeyword, $keyword, $likeKeyword, $keyword];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->query($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向量相似度搜索
|
||||||
|
*
|
||||||
|
* @param array $queryVector 查询向量
|
||||||
|
* @param int $userid 用户ID(0表示不限制权限)
|
||||||
|
* @param int $limit 返回数量
|
||||||
|
* @return array 搜索结果
|
||||||
|
*/
|
||||||
|
public static function vectorSearch(array $queryVector, int $userid = 0, int $limit = 20): array
|
||||||
|
{
|
||||||
|
if (empty($queryVector)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new self();
|
||||||
|
$vectorStr = '[' . implode(',', $queryVector) . ']';
|
||||||
|
|
||||||
|
if ($userid > 0) {
|
||||||
|
// 权限过滤:pshare 指向共享根文件夹的 ID
|
||||||
|
$sql = "
|
||||||
|
SELECT DISTINCT
|
||||||
|
fv.file_id,
|
||||||
|
fv.userid,
|
||||||
|
fv.file_name,
|
||||||
|
fv.file_type,
|
||||||
|
fv.file_ext,
|
||||||
|
SUBSTRING(fv.content, 1, 500) as content_preview,
|
||||||
|
COSINE_SIMILARITY(fv.content_vector, ?) AS similarity
|
||||||
|
FROM file_vectors fv
|
||||||
|
LEFT JOIN file_users fu ON fv.pshare = fu.file_id AND fv.pshare > 0
|
||||||
|
WHERE fv.content_vector IS NOT NULL
|
||||||
|
AND (fv.userid = ? OR fu.userid IN (0, ?))
|
||||||
|
ORDER BY similarity DESC
|
||||||
|
LIMIT " . (int)$limit;
|
||||||
|
|
||||||
|
$params = [$vectorStr, $userid, $userid];
|
||||||
|
} else {
|
||||||
|
// 不限制权限
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
file_id,
|
||||||
|
userid,
|
||||||
|
file_name,
|
||||||
|
file_type,
|
||||||
|
file_ext,
|
||||||
|
SUBSTRING(content, 1, 500) as content_preview,
|
||||||
|
COSINE_SIMILARITY(content_vector, ?) AS similarity
|
||||||
|
FROM file_vectors
|
||||||
|
WHERE content_vector IS NOT NULL
|
||||||
|
ORDER BY similarity DESC
|
||||||
|
LIMIT " . (int)$limit;
|
||||||
|
|
||||||
|
$params = [$vectorStr];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->query($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 混合搜索(全文 + 向量,使用 RRF 融合)
|
||||||
|
*
|
||||||
|
* @param string $keyword 关键词
|
||||||
|
* @param array $queryVector 查询向量
|
||||||
|
* @param int $userid 用户ID(0表示不限制权限)
|
||||||
|
* @param int $limit 返回数量
|
||||||
|
* @param float $textWeight 全文搜索权重
|
||||||
|
* @param float $vectorWeight 向量搜索权重
|
||||||
|
* @return array 搜索结果
|
||||||
|
*/
|
||||||
|
public static function hybridSearch(
|
||||||
|
string $keyword,
|
||||||
|
array $queryVector,
|
||||||
|
int $userid = 0,
|
||||||
|
int $limit = 20,
|
||||||
|
float $textWeight = 0.5,
|
||||||
|
float $vectorWeight = 0.5
|
||||||
|
): array {
|
||||||
|
// 分别执行两种搜索(权限过滤在各自方法内通过 JOIN 实现)
|
||||||
|
$textResults = self::fullTextSearch($keyword, $userid, 50, 0);
|
||||||
|
$vectorResults = !empty($queryVector)
|
||||||
|
? self::vectorSearch($queryVector, $userid, 50)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 使用 RRF (Reciprocal Rank Fusion) 融合结果
|
||||||
|
$scores = [];
|
||||||
|
$items = [];
|
||||||
|
$k = 60; // RRF 常数
|
||||||
|
|
||||||
|
// 处理全文搜索结果
|
||||||
|
foreach ($textResults as $rank => $item) {
|
||||||
|
$fileId = $item['file_id'];
|
||||||
|
$scores[$fileId] = ($scores[$fileId] ?? 0) + $textWeight / ($k + $rank + 1);
|
||||||
|
$items[$fileId] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理向量搜索结果
|
||||||
|
foreach ($vectorResults as $rank => $item) {
|
||||||
|
$fileId = $item['file_id'];
|
||||||
|
$scores[$fileId] = ($scores[$fileId] ?? 0) + $vectorWeight / ($k + $rank + 1);
|
||||||
|
if (!isset($items[$fileId])) {
|
||||||
|
$items[$fileId] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按融合分数排序
|
||||||
|
arsort($scores);
|
||||||
|
|
||||||
|
// 构建最终结果
|
||||||
|
$results = [];
|
||||||
|
$count = 0;
|
||||||
|
foreach ($scores as $fileId => $score) {
|
||||||
|
if ($count >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$item = $items[$fileId];
|
||||||
|
$item['rrf_score'] = $score;
|
||||||
|
$results[] = $item;
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入或更新文件向量
|
||||||
|
*
|
||||||
|
* @param array $data 文件数据
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function upsertFileVector(array $data): bool
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
|
||||||
|
$fileId = $data['file_id'] ?? 0;
|
||||||
|
if ($fileId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否存在
|
||||||
|
$existing = $instance->queryOne(
|
||||||
|
"SELECT id FROM file_vectors WHERE file_id = ?",
|
||||||
|
[$fileId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// 更新
|
||||||
|
$sql = "UPDATE file_vectors SET
|
||||||
|
userid = ?,
|
||||||
|
pshare = ?,
|
||||||
|
file_name = ?,
|
||||||
|
file_type = ?,
|
||||||
|
file_ext = ?,
|
||||||
|
content = ?,
|
||||||
|
content_vector = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE file_id = ?";
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
$data['userid'] ?? 0,
|
||||||
|
$data['pshare'] ?? 0,
|
||||||
|
$data['file_name'] ?? '',
|
||||||
|
$data['file_type'] ?? '',
|
||||||
|
$data['file_ext'] ?? '',
|
||||||
|
$data['content'] ?? '',
|
||||||
|
$data['content_vector'] ?? null,
|
||||||
|
$fileId
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// 插入
|
||||||
|
$sql = "INSERT INTO file_vectors
|
||||||
|
(file_id, userid, pshare, file_name, file_type, file_ext, content, content_vector, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())";
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
$fileId,
|
||||||
|
$data['userid'] ?? 0,
|
||||||
|
$data['pshare'] ?? 0,
|
||||||
|
$data['file_name'] ?? '',
|
||||||
|
$data['file_type'] ?? '',
|
||||||
|
$data['file_ext'] ?? '',
|
||||||
|
$data['content'] ?? '',
|
||||||
|
$data['content_vector'] ?? null
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->execute($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件向量
|
||||||
|
*
|
||||||
|
* @param int $fileId 文件ID
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function deleteFileVector(int $fileId): bool
|
||||||
|
{
|
||||||
|
if ($fileId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new self();
|
||||||
|
return $instance->execute(
|
||||||
|
"DELETE FROM file_vectors WHERE file_id = ?",
|
||||||
|
[$fileId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除文件向量
|
||||||
|
*
|
||||||
|
* @param array $fileIds 文件ID列表
|
||||||
|
* @return int 删除数量
|
||||||
|
*/
|
||||||
|
public static function batchDeleteFileVectors(array $fileIds): int
|
||||||
|
{
|
||||||
|
if (empty($fileIds)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new self();
|
||||||
|
$placeholders = implode(',', array_fill(0, count($fileIds), '?'));
|
||||||
|
|
||||||
|
return $instance->executeWithRowCount(
|
||||||
|
"DELETE FROM file_vectors WHERE file_id IN ({$placeholders})",
|
||||||
|
$fileIds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新文件的 pshare 值
|
||||||
|
*
|
||||||
|
* @param array $fileIds 文件ID列表
|
||||||
|
* @param int $pshare 新的 pshare 值
|
||||||
|
* @return int 更新数量
|
||||||
|
*/
|
||||||
|
public static function batchUpdatePshare(array $fileIds, int $pshare): int
|
||||||
|
{
|
||||||
|
if (empty($fileIds)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new self();
|
||||||
|
$placeholders = implode(',', array_fill(0, count($fileIds), '?'));
|
||||||
|
$params = array_merge([$pshare], $fileIds);
|
||||||
|
|
||||||
|
return $instance->executeWithRowCount(
|
||||||
|
"UPDATE file_vectors SET pshare = ?, updated_at = NOW() WHERE file_id IN ({$placeholders})",
|
||||||
|
$params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有文件向量
|
||||||
|
*
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function clearAllFileVectors(): bool
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
return $instance->execute("TRUNCATE TABLE file_vectors");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已索引的文件数量
|
||||||
|
*
|
||||||
|
* @return int 文件数量
|
||||||
|
*/
|
||||||
|
public static function getIndexedFileCount(): int
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
$result = $instance->queryOne("SELECT COUNT(*) as cnt FROM file_vectors");
|
||||||
|
return $result ? (int) $result['cnt'] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最后索引的文件ID
|
||||||
|
*
|
||||||
|
* @return int 文件ID
|
||||||
|
*/
|
||||||
|
public static function getLastIndexedFileId(): int
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
$result = $instance->queryOne("SELECT MAX(file_id) as max_id FROM file_vectors");
|
||||||
|
return $result ? (int) ($result['max_id'] ?? 0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// 文件用户关系方法
|
||||||
|
// ==============================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入或更新文件用户关系
|
||||||
|
*
|
||||||
|
* @param int $fileId 文件ID
|
||||||
|
* @param int $userid 用户ID(0表示公开)
|
||||||
|
* @param int $permission 权限(0只读,1读写)
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function upsertFileUser(int $fileId, int $userid, int $permission = 0): bool
|
||||||
|
{
|
||||||
|
if ($fileId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new self();
|
||||||
|
|
||||||
|
// 检查是否存在
|
||||||
|
$existing = $instance->queryOne(
|
||||||
|
"SELECT id FROM file_users WHERE file_id = ? AND userid = ?",
|
||||||
|
[$fileId, $userid]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// 更新
|
||||||
|
return $instance->execute(
|
||||||
|
"UPDATE file_users SET permission = ?, updated_at = NOW() WHERE file_id = ? AND userid = ?",
|
||||||
|
[$permission, $fileId, $userid]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 插入
|
||||||
|
return $instance->execute(
|
||||||
|
"INSERT INTO file_users (file_id, userid, permission) VALUES (?, ?, ?)",
|
||||||
|
[$fileId, $userid, $permission]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量同步文件用户关系(替换指定文件的所有关系)
|
||||||
|
*
|
||||||
|
* @param int $fileId 文件ID
|
||||||
|
* @param array $users 用户列表 [['userid' => int, 'permission' => int], ...]
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function syncFileUsers(int $fileId, array $users): bool
|
||||||
|
{
|
||||||
|
if ($fileId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new self();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 删除旧关系
|
||||||
|
$instance->execute("DELETE FROM file_users WHERE file_id = ?", [$fileId]);
|
||||||
|
|
||||||
|
// 插入新关系
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$userid = (int)($user['userid'] ?? 0);
|
||||||
|
$permission = (int)($user['permission'] ?? 0);
|
||||||
|
$instance->execute(
|
||||||
|
"INSERT INTO file_users (file_id, userid, permission) VALUES (?, ?, ?)",
|
||||||
|
[$fileId, $userid, $permission]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('SeekDB syncFileUsers error: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件的所有用户关系
|
||||||
|
*
|
||||||
|
* @param int $fileId 文件ID
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function deleteFileUsers(int $fileId): bool
|
||||||
|
{
|
||||||
|
if ($fileId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new self();
|
||||||
|
return $instance->execute("DELETE FROM file_users WHERE file_id = ?", [$fileId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定文件和用户的关系
|
||||||
|
*
|
||||||
|
* @param int $fileId 文件ID
|
||||||
|
* @param int $userid 用户ID
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function deleteFileUser(int $fileId, int $userid): bool
|
||||||
|
{
|
||||||
|
if ($fileId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new self();
|
||||||
|
return $instance->execute(
|
||||||
|
"DELETE FROM file_users WHERE file_id = ? AND userid = ?",
|
||||||
|
[$fileId, $userid]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件用户关系数量
|
||||||
|
*
|
||||||
|
* @return int 关系数量
|
||||||
|
*/
|
||||||
|
public static function getFileUserCount(): int
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
$result = $instance->queryOne("SELECT COUNT(*) as cnt FROM file_users");
|
||||||
|
return $result ? (int) $result['cnt'] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有文件用户关系
|
||||||
|
*
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function clearAllFileUsers(): bool
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
return $instance->execute("TRUNCATE TABLE file_users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
539
app/Module/SeekDB/SeekDBFile.php
Normal file
539
app/Module/SeekDB/SeekDBFile.php
Normal file
@ -0,0 +1,539 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Module\SeekDB;
|
||||||
|
|
||||||
|
use App\Models\File;
|
||||||
|
use App\Models\FileContent;
|
||||||
|
use App\Models\FileUser;
|
||||||
|
use App\Module\Apps;
|
||||||
|
use App\Module\Base;
|
||||||
|
use App\Module\TextExtractor;
|
||||||
|
use App\Module\AI;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SeekDB 文件搜索类
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
*
|
||||||
|
* 1. 搜索方法
|
||||||
|
* - 搜索文件: search($userid, $keyword, $searchType, $from, $size);
|
||||||
|
*
|
||||||
|
* 2. 同步方法
|
||||||
|
* - 单个同步: sync(File $file);
|
||||||
|
* - 批量同步: batchSync($files);
|
||||||
|
* - 删除索引: delete($fileId);
|
||||||
|
*
|
||||||
|
* 3. 工具方法
|
||||||
|
* - 清空索引: clear();
|
||||||
|
*/
|
||||||
|
class SeekDBFile
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 可搜索的文件类型
|
||||||
|
*/
|
||||||
|
public const SEARCHABLE_TYPES = ['document', 'word', 'excel', 'ppt', 'txt', 'md', 'text', 'code'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最大内容长度(字符)- 提取后的文本内容限制
|
||||||
|
*/
|
||||||
|
public const MAX_CONTENT_LENGTH = 100000; // 100K 字符
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不同文件类型的最大大小限制(字节)
|
||||||
|
*/
|
||||||
|
public const MAX_FILE_SIZE = [
|
||||||
|
'office' => 50 * 1024 * 1024, // 50MB - Office 文件图片占空间大但文本少
|
||||||
|
'text' => 5 * 1024 * 1024, // 5MB - 纯文本文件
|
||||||
|
'other' => 20 * 1024 * 1024, // 20MB - PDF 等其他文件
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Office 文件扩展名
|
||||||
|
*/
|
||||||
|
public const OFFICE_EXTENSIONS = [
|
||||||
|
'doc', 'docx', 'dot', 'dotx', 'odt', 'ott', 'rtf',
|
||||||
|
'xls', 'xlsx', 'xlsm', 'xlt', 'xltx', 'ods', 'ots', 'csv', 'tsv',
|
||||||
|
'ppt', 'pptx', 'pps', 'ppsx', 'odp', 'otp'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯文本文件扩展名
|
||||||
|
*/
|
||||||
|
public const TEXT_EXTENSIONS = [
|
||||||
|
'txt', 'md', 'text', 'log', 'json', 'xml', 'html', 'htm', 'css', 'js', 'ts',
|
||||||
|
'php', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'rb', 'sh', 'bash', 'sql',
|
||||||
|
'yaml', 'yml', 'ini', 'conf', 'vue', 'jsx', 'tsx'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索文件(支持全文、向量、混合搜索)
|
||||||
|
*
|
||||||
|
* @param int $userid 用户ID
|
||||||
|
* @param string $keyword 搜索关键词
|
||||||
|
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||||
|
* @param int $from 起始位置
|
||||||
|
* @param int $size 返回数量
|
||||||
|
* @return array 搜索结果
|
||||||
|
*/
|
||||||
|
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $from = 0, int $size = 20): array
|
||||||
|
{
|
||||||
|
if (empty($keyword)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
// 未安装 SeekDB,降级到 MySQL LIKE 搜索
|
||||||
|
return self::searchByMysql($userid, $keyword, $from, $size);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 权限过滤已在 SeekDBBase 中通过 JOIN file_users 表实现
|
||||||
|
switch ($searchType) {
|
||||||
|
case 'text':
|
||||||
|
// 纯全文搜索
|
||||||
|
return self::formatSearchResults(
|
||||||
|
SeekDBBase::fullTextSearch($keyword, $userid, $size, $from)
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'vector':
|
||||||
|
// 纯向量搜索(需要先获取 embedding)
|
||||||
|
$embedding = self::getEmbedding($keyword);
|
||||||
|
if (empty($embedding)) {
|
||||||
|
// embedding 获取失败,降级到全文搜索
|
||||||
|
return self::formatSearchResults(
|
||||||
|
SeekDBBase::fullTextSearch($keyword, $userid, $size, $from)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return self::formatSearchResults(
|
||||||
|
SeekDBBase::vectorSearch($embedding, $userid, $size)
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'hybrid':
|
||||||
|
default:
|
||||||
|
// 混合搜索
|
||||||
|
$embedding = self::getEmbedding($keyword);
|
||||||
|
return self::formatSearchResults(
|
||||||
|
SeekDBBase::hybridSearch($keyword, $embedding, $userid, $size)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('SeekDB search error: ' . $e->getMessage());
|
||||||
|
return self::searchByMysql($userid, $keyword, $from, $size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文本的 Embedding 向量
|
||||||
|
*
|
||||||
|
* @param string $text 文本
|
||||||
|
* @return array 向量数组(空数组表示失败)
|
||||||
|
*/
|
||||||
|
private static function getEmbedding(string $text): array
|
||||||
|
{
|
||||||
|
if (empty($text)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用 AI 模块获取 embedding
|
||||||
|
$result = AI::getEmbedding($text);
|
||||||
|
if (Base::isSuccess($result)) {
|
||||||
|
return $result['data'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::warning('Get embedding error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化搜索结果
|
||||||
|
*
|
||||||
|
* @param array $results SeekDB 返回的结果
|
||||||
|
* @return array 格式化后的结果
|
||||||
|
*/
|
||||||
|
private static function formatSearchResults(array $results): array
|
||||||
|
{
|
||||||
|
$formatted = [];
|
||||||
|
foreach ($results as $item) {
|
||||||
|
$formatted[] = [
|
||||||
|
'id' => $item['file_id'],
|
||||||
|
'file_id' => $item['file_id'],
|
||||||
|
'name' => $item['file_name'],
|
||||||
|
'type' => $item['file_type'],
|
||||||
|
'ext' => $item['file_ext'],
|
||||||
|
'userid' => $item['userid'],
|
||||||
|
'content_preview' => $item['content_preview'] ?? null,
|
||||||
|
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL 降级搜索(仅搜索文件名)
|
||||||
|
*
|
||||||
|
* @param int $userid 用户ID
|
||||||
|
* @param string $keyword 关键词
|
||||||
|
* @param int $from 起始位置
|
||||||
|
* @param int $size 返回数量
|
||||||
|
* @return array 搜索结果
|
||||||
|
*/
|
||||||
|
private static function searchByMysql(int $userid, string $keyword, int $from, int $size): array
|
||||||
|
{
|
||||||
|
// 搜索用户自己的文件
|
||||||
|
$builder = File::where('userid', $userid)
|
||||||
|
->where('name', 'like', "%{$keyword}%")
|
||||||
|
->where('type', '!=', 'folder');
|
||||||
|
|
||||||
|
$results = $builder->skip($from)->take($size)->get();
|
||||||
|
|
||||||
|
return $results->map(function ($file) {
|
||||||
|
return [
|
||||||
|
'id' => $file->id,
|
||||||
|
'file_id' => $file->id,
|
||||||
|
'name' => $file->name,
|
||||||
|
'type' => $file->type,
|
||||||
|
'ext' => $file->ext,
|
||||||
|
'userid' => $file->userid,
|
||||||
|
'content_preview' => null,
|
||||||
|
'relevance' => 0,
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// 同步方法
|
||||||
|
// ==============================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步单个文件到 SeekDB
|
||||||
|
*
|
||||||
|
* @param File $file 文件模型
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function sync(File $file): bool
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不处理文件夹
|
||||||
|
if ($file->type === 'folder') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据文件类型检查大小限制
|
||||||
|
$maxSize = self::getMaxFileSizeByExt($file->ext);
|
||||||
|
if ($file->size > $maxSize) {
|
||||||
|
Log::info("SeekDB: Skip large file {$file->id} ({$file->size} bytes, max: {$maxSize})");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 提取文件内容
|
||||||
|
$content = self::extractFileContent($file);
|
||||||
|
|
||||||
|
// 限制提取后的内容长度
|
||||||
|
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
|
||||||
|
|
||||||
|
// 获取 embedding(如果有内容且 AI 可用)
|
||||||
|
$embedding = null;
|
||||||
|
if (!empty($content) && Apps::isInstalled('ai')) {
|
||||||
|
$embeddingResult = self::getEmbedding($content);
|
||||||
|
if (!empty($embeddingResult)) {
|
||||||
|
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入 SeekDB
|
||||||
|
// pshare 指向共享根文件夹的 ID,用于权限过滤
|
||||||
|
$result = SeekDBBase::upsertFileVector([
|
||||||
|
'file_id' => $file->id,
|
||||||
|
'userid' => $file->userid,
|
||||||
|
'pshare' => $file->pshare ?? 0,
|
||||||
|
'file_name' => $file->name,
|
||||||
|
'file_type' => $file->type,
|
||||||
|
'file_ext' => $file->ext,
|
||||||
|
'content' => $content,
|
||||||
|
'content_vector' => $embedding,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 注意:file_users 只需要同步共享文件夹的关系,不需要同步每个文件
|
||||||
|
// 因为搜索时是通过 pshare 关联 file_users 表
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('SeekDB sync error: ' . $e->getMessage(), [
|
||||||
|
'file_id' => $file->id,
|
||||||
|
'file_name' => $file->name,
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件扩展名获取最大文件大小限制
|
||||||
|
*
|
||||||
|
* @param string|null $ext 文件扩展名
|
||||||
|
* @return int 最大文件大小(字节)
|
||||||
|
*/
|
||||||
|
private static function getMaxFileSizeByExt(?string $ext): int
|
||||||
|
{
|
||||||
|
$ext = strtolower($ext ?? '');
|
||||||
|
|
||||||
|
if (in_array($ext, self::OFFICE_EXTENSIONS)) {
|
||||||
|
return self::MAX_FILE_SIZE['office'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($ext, self::TEXT_EXTENSIONS)) {
|
||||||
|
return self::MAX_FILE_SIZE['text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::MAX_FILE_SIZE['other'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有文件类型中的最大文件大小限制
|
||||||
|
*
|
||||||
|
* @return int 最大文件大小(字节)
|
||||||
|
*/
|
||||||
|
public static function getMaxFileSize(): int
|
||||||
|
{
|
||||||
|
return max(self::MAX_FILE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量同步文件
|
||||||
|
*
|
||||||
|
* @param iterable $files 文件列表
|
||||||
|
* @return int 成功同步的数量
|
||||||
|
*/
|
||||||
|
public static function batchSync(iterable $files): int
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (self::sync($file)) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件索引
|
||||||
|
*
|
||||||
|
* @param int $fileId 文件ID
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function delete(int $fileId): bool
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SeekDBBase::deleteFileVector($fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取文件内容
|
||||||
|
*
|
||||||
|
* @param File $file 文件模型
|
||||||
|
* @return string 文件内容文本
|
||||||
|
*/
|
||||||
|
private static function extractFileContent(File $file): string
|
||||||
|
{
|
||||||
|
// 1. 先尝试从 FileContent 的 text 字段获取(已提取的文本内容)
|
||||||
|
$fileContent = FileContent::where('fid', $file->id)->orderByDesc('id')->first();
|
||||||
|
if ($fileContent && !empty($fileContent->text)) {
|
||||||
|
return $fileContent->text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试从 FileContent 的 content 字段获取
|
||||||
|
if ($fileContent && !empty($fileContent->content)) {
|
||||||
|
$contentData = Base::json2array($fileContent->content);
|
||||||
|
|
||||||
|
// 2.1 某些文件类型直接存储内容
|
||||||
|
if (!empty($contentData['content'])) {
|
||||||
|
return is_string($contentData['content']) ? $contentData['content'] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2 尝试使用 TextExtractor 提取文件内容
|
||||||
|
$filePath = $contentData['url'] ?? null;
|
||||||
|
if ($filePath && str_starts_with($filePath, 'uploads/')) {
|
||||||
|
$fullPath = public_path($filePath);
|
||||||
|
if (file_exists($fullPath)) {
|
||||||
|
// 根据文件类型设置不同的大小限制
|
||||||
|
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||||
|
$maxFileSize = self::getMaxFileSizeByExt($ext);
|
||||||
|
$maxContentSize = self::MAX_CONTENT_LENGTH;
|
||||||
|
|
||||||
|
$result = TextExtractor::extractFile(
|
||||||
|
$fullPath,
|
||||||
|
(int) ($maxFileSize / 1024), // 转换为 KB
|
||||||
|
(int) ($maxContentSize / 1024) // 转换为 KB
|
||||||
|
);
|
||||||
|
if (Base::isSuccess($result)) {
|
||||||
|
return $result['data'] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有索引
|
||||||
|
*
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function clear(): bool
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SeekDBBase::clearAllFileVectors();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已索引文件数量
|
||||||
|
*
|
||||||
|
* @return int 数量
|
||||||
|
*/
|
||||||
|
public static function getIndexedCount(): int
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SeekDBBase::getIndexedFileCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// 文件用户关系同步方法
|
||||||
|
// ==============================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步单个文件的用户关系到 SeekDB
|
||||||
|
*
|
||||||
|
* @param int $fileId 文件ID
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function syncFileUsers(int $fileId): bool
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb") || $fileId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 从 MySQL 获取文件的用户关系
|
||||||
|
$users = FileUser::where('file_id', $fileId)
|
||||||
|
->select(['userid', 'permission'])
|
||||||
|
->get()
|
||||||
|
->map(function ($item) {
|
||||||
|
return [
|
||||||
|
'userid' => $item->userid,
|
||||||
|
'permission' => $item->permission,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 同步到 SeekDB
|
||||||
|
return SeekDBBase::syncFileUsers($fileId, $users);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('SeekDB syncFileUsers error: ' . $e->getMessage(), ['file_id' => $fileId]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加文件用户关系到 SeekDB
|
||||||
|
*
|
||||||
|
* @param int $fileId 文件ID
|
||||||
|
* @param int $userid 用户ID
|
||||||
|
* @param int $permission 权限
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function addFileUser(int $fileId, int $userid, int $permission = 0): bool
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb") || $fileId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SeekDBBase::upsertFileUser($fileId, $userid, $permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件用户关系
|
||||||
|
*
|
||||||
|
* @param int $fileId 文件ID
|
||||||
|
* @param int|null $userid 用户ID,null 表示删除所有
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function removeFileUser(int $fileId, ?int $userid = null): bool
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb") || $fileId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userid === null) {
|
||||||
|
return SeekDBBase::deleteFileUsers($fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SeekDBBase::deleteFileUser($fileId, $userid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量同步所有文件用户关系(全量同步)
|
||||||
|
*
|
||||||
|
* @param callable|null $progressCallback 进度回调
|
||||||
|
* @return int 同步数量
|
||||||
|
*/
|
||||||
|
public static function syncAllFileUsers(?callable $progressCallback = null): int
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$lastId = 0;
|
||||||
|
$batchSize = 1000;
|
||||||
|
|
||||||
|
// 先清空 SeekDB 中的 file_users 表
|
||||||
|
SeekDBBase::clearAllFileUsers();
|
||||||
|
|
||||||
|
// 分批同步
|
||||||
|
while (true) {
|
||||||
|
$records = FileUser::where('id', '>', $lastId)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($batchSize)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($records->isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
SeekDBBase::upsertFileUser($record->file_id, $record->userid, $record->permission);
|
||||||
|
$count++;
|
||||||
|
$lastId = $record->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($progressCallback) {
|
||||||
|
$progressCallback($count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
181
app/Module/SeekDB/SeekDBKeyValue.php
Normal file
181
app/Module/SeekDB/SeekDBKeyValue.php
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Module\SeekDB;
|
||||||
|
|
||||||
|
use App\Module\Apps;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SeekDB 键值存储类
|
||||||
|
*
|
||||||
|
* 用于存储同步进度、配置等键值数据
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
*
|
||||||
|
* 1. 基本操作
|
||||||
|
* - 设置键值: set('sync_last_id', 12345);
|
||||||
|
* - 获取键值: $lastId = get('sync_last_id', 0);
|
||||||
|
* - 删除键值: delete('sync_last_id');
|
||||||
|
*
|
||||||
|
* 2. 批量操作
|
||||||
|
* - 批量设置: batchSet(['key1' => 'value1', 'key2' => 'value2']);
|
||||||
|
* - 批量获取: $values = batchGet(['key1', 'key2']);
|
||||||
|
*/
|
||||||
|
class SeekDBKeyValue
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 设置键值
|
||||||
|
*
|
||||||
|
* @param string $key 键名
|
||||||
|
* @param mixed $value 值(会被 JSON 编码)
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function set(string $key, mixed $value): bool
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb") || empty($key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new SeekDBBase();
|
||||||
|
$jsonValue = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
// 使用 REPLACE INTO 实现 upsert
|
||||||
|
$sql = "REPLACE INTO key_values (k, v, updated_at) VALUES (?, ?, NOW())";
|
||||||
|
|
||||||
|
return $instance->execute($sql, [$key, $jsonValue]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取键值
|
||||||
|
*
|
||||||
|
* @param string $key 键名
|
||||||
|
* @param mixed $default 默认值
|
||||||
|
* @return mixed 值或默认值
|
||||||
|
*/
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb") || empty($key)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new SeekDBBase();
|
||||||
|
$result = $instance->queryOne(
|
||||||
|
"SELECT v FROM key_values WHERE k = ?",
|
||||||
|
[$key]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$result || !isset($result['v'])) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $result['v'];
|
||||||
|
|
||||||
|
// 尝试 JSON 解码
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除键值
|
||||||
|
*
|
||||||
|
* @param string $key 键名
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function delete(string $key): bool
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb") || empty($key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new SeekDBBase();
|
||||||
|
return $instance->execute(
|
||||||
|
"DELETE FROM key_values WHERE k = ?",
|
||||||
|
[$key]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置键值
|
||||||
|
*
|
||||||
|
* @param array $keyValues 键值对数组
|
||||||
|
* @return bool 是否全部成功
|
||||||
|
*/
|
||||||
|
public static function batchSet(array $keyValues): bool
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb") || empty($keyValues)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new SeekDBBase();
|
||||||
|
$success = true;
|
||||||
|
|
||||||
|
foreach ($keyValues as $key => $value) {
|
||||||
|
$jsonValue = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||||
|
$result = $instance->execute(
|
||||||
|
"REPLACE INTO key_values (k, v, updated_at) VALUES (?, ?, NOW())",
|
||||||
|
[$key, $jsonValue]
|
||||||
|
);
|
||||||
|
if (!$result) {
|
||||||
|
$success = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取键值
|
||||||
|
*
|
||||||
|
* @param array $keys 键名数组
|
||||||
|
* @return array 键值对数组
|
||||||
|
*/
|
||||||
|
public static function batchGet(array $keys): array
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb") || empty($keys)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new SeekDBBase();
|
||||||
|
$placeholders = implode(',', array_fill(0, count($keys), '?'));
|
||||||
|
$results = $instance->query(
|
||||||
|
"SELECT k, v FROM key_values WHERE k IN ({$placeholders})",
|
||||||
|
$keys
|
||||||
|
);
|
||||||
|
|
||||||
|
$values = [];
|
||||||
|
foreach ($results as $row) {
|
||||||
|
$value = $row['v'];
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
$values[$row['k']] = (json_last_error() === JSON_ERROR_NONE) ? $decoded : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充未找到的键为 null
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (!isset($values[$key])) {
|
||||||
|
$values[$key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有键值
|
||||||
|
*
|
||||||
|
* @return bool 是否成功
|
||||||
|
*/
|
||||||
|
public static function clear(): bool
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new SeekDBBase();
|
||||||
|
return $instance->execute("TRUNCATE TABLE key_values");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
96
app/Observers/FileObserver.php
Normal file
96
app/Observers/FileObserver.php
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\File;
|
||||||
|
use App\Tasks\SeekDBFileSyncTask;
|
||||||
|
|
||||||
|
class FileObserver extends AbstractObserver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the File "created" event.
|
||||||
|
*
|
||||||
|
* @param \App\Models\File $file
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function created(File $file)
|
||||||
|
{
|
||||||
|
// 文件夹不需要同步
|
||||||
|
if ($file->type === 'folder') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::taskDeliver(new SeekDBFileSyncTask('sync', $file->toArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the File "updated" event.
|
||||||
|
*
|
||||||
|
* @param \App\Models\File $file
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function updated(File $file)
|
||||||
|
{
|
||||||
|
// 检查共享设置是否变化(影响子文件的 pshare)
|
||||||
|
if ($file->type === 'folder' && $file->isDirty('share')) {
|
||||||
|
// 共享文件夹的 share 字段变化,需要批量更新子文件的 pshare
|
||||||
|
// 注意:updataShare 方法会批量更新子文件,但不会触发 Observer
|
||||||
|
// 这里通过任务异步处理
|
||||||
|
$newPshare = $file->share ? $file->id : 0;
|
||||||
|
$childFileIds = File::where('pids', 'like', "%,{$file->id},%")
|
||||||
|
->where('type', '!=', 'folder')
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
if (!empty($childFileIds)) {
|
||||||
|
self::taskDeliver(new SeekDBFileSyncTask('update_pshare', [
|
||||||
|
'file_ids' => $childFileIds,
|
||||||
|
'pshare' => $newPshare,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件夹不需要同步内容
|
||||||
|
if ($file->type === 'folder') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::taskDeliver(new SeekDBFileSyncTask('sync', $file->toArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the File "deleted" event.
|
||||||
|
*
|
||||||
|
* @param \App\Models\File $file
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function deleted(File $file)
|
||||||
|
{
|
||||||
|
self::taskDeliver(new SeekDBFileSyncTask('delete', $file->toArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the File "restored" event.
|
||||||
|
*
|
||||||
|
* @param \App\Models\File $file
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function restored(File $file)
|
||||||
|
{
|
||||||
|
// 文件夹不需要同步
|
||||||
|
if ($file->type === 'folder') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::taskDeliver(new SeekDBFileSyncTask('sync', $file->toArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the File "force deleted" event.
|
||||||
|
*
|
||||||
|
* @param \App\Models\File $file
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function forceDeleted(File $file)
|
||||||
|
{
|
||||||
|
self::taskDeliver(new SeekDBFileSyncTask('delete', $file->toArray()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
54
app/Observers/FileUserObserver.php
Normal file
54
app/Observers/FileUserObserver.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\FileUser;
|
||||||
|
use App\Tasks\SeekDBFileSyncTask;
|
||||||
|
|
||||||
|
class FileUserObserver extends AbstractObserver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the FileUser "created" event.
|
||||||
|
*
|
||||||
|
* @param \App\Models\FileUser $fileUser
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function created(FileUser $fileUser)
|
||||||
|
{
|
||||||
|
self::taskDeliver(new SeekDBFileSyncTask('add_file_user', [
|
||||||
|
'file_id' => $fileUser->file_id,
|
||||||
|
'userid' => $fileUser->userid,
|
||||||
|
'permission' => $fileUser->permission,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the FileUser "updated" event.
|
||||||
|
*
|
||||||
|
* @param \App\Models\FileUser $fileUser
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function updated(FileUser $fileUser)
|
||||||
|
{
|
||||||
|
self::taskDeliver(new SeekDBFileSyncTask('add_file_user', [
|
||||||
|
'file_id' => $fileUser->file_id,
|
||||||
|
'userid' => $fileUser->userid,
|
||||||
|
'permission' => $fileUser->permission,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the FileUser "deleted" event.
|
||||||
|
*
|
||||||
|
* @param \App\Models\FileUser $fileUser
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function deleted(FileUser $fileUser)
|
||||||
|
{
|
||||||
|
self::taskDeliver(new SeekDBFileSyncTask('remove_file_user', [
|
||||||
|
'file_id' => $fileUser->file_id,
|
||||||
|
'userid' => $fileUser->userid,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\File;
|
||||||
|
use App\Models\FileUser;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectTask;
|
use App\Models\ProjectTask;
|
||||||
use App\Models\ProjectTaskUser;
|
use App\Models\ProjectTaskUser;
|
||||||
@ -9,6 +11,8 @@ use App\Models\ProjectUser;
|
|||||||
use App\Models\WebSocketDialog;
|
use App\Models\WebSocketDialog;
|
||||||
use App\Models\WebSocketDialogMsg;
|
use App\Models\WebSocketDialogMsg;
|
||||||
use App\Models\WebSocketDialogUser;
|
use App\Models\WebSocketDialogUser;
|
||||||
|
use App\Observers\FileObserver;
|
||||||
|
use App\Observers\FileUserObserver;
|
||||||
use App\Observers\ProjectObserver;
|
use App\Observers\ProjectObserver;
|
||||||
use App\Observers\ProjectTaskObserver;
|
use App\Observers\ProjectTaskObserver;
|
||||||
use App\Observers\ProjectTaskUserObserver;
|
use App\Observers\ProjectTaskUserObserver;
|
||||||
@ -40,6 +44,8 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
|
File::observe(FileObserver::class);
|
||||||
|
FileUser::observe(FileUserObserver::class);
|
||||||
Project::observe(ProjectObserver::class);
|
Project::observe(ProjectObserver::class);
|
||||||
ProjectTask::observe(ProjectTaskObserver::class);
|
ProjectTask::observe(ProjectTaskObserver::class);
|
||||||
ProjectTaskUser::observe(ProjectTaskUserObserver::class);
|
ProjectTaskUser::observe(ProjectTaskUserObserver::class);
|
||||||
|
|||||||
120
app/Tasks/SeekDBFileSyncTask.php
Normal file
120
app/Tasks/SeekDBFileSyncTask.php
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tasks;
|
||||||
|
|
||||||
|
use App\Models\File;
|
||||||
|
use App\Module\Apps;
|
||||||
|
use App\Module\SeekDB\SeekDBFile;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步文件数据到 SeekDB
|
||||||
|
*/
|
||||||
|
class SeekDBFileSyncTask extends AbstractTask
|
||||||
|
{
|
||||||
|
private $action;
|
||||||
|
|
||||||
|
private $data;
|
||||||
|
|
||||||
|
public function __construct($action = null, $data = null)
|
||||||
|
{
|
||||||
|
parent::__construct(...func_get_args());
|
||||||
|
$this->action = $action;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start()
|
||||||
|
{
|
||||||
|
if (!Apps::isInstalled("seekdb")) {
|
||||||
|
// 如果没有安装 SeekDB 模块,则不执行
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($this->action) {
|
||||||
|
case 'sync':
|
||||||
|
// 同步文件数据
|
||||||
|
$file = File::find($this->data['id'] ?? 0);
|
||||||
|
if ($file) {
|
||||||
|
SeekDBFile::sync($file);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'delete':
|
||||||
|
// 删除文件索引
|
||||||
|
$fileId = $this->data['id'] ?? 0;
|
||||||
|
if ($fileId > 0) {
|
||||||
|
SeekDBFile::delete($fileId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'sync_file_user':
|
||||||
|
// 同步文件用户关系
|
||||||
|
$fileId = $this->data['file_id'] ?? 0;
|
||||||
|
if ($fileId > 0) {
|
||||||
|
SeekDBFile::syncFileUsers($fileId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'add_file_user':
|
||||||
|
// 添加文件用户关系
|
||||||
|
$fileId = $this->data['file_id'] ?? 0;
|
||||||
|
$userid = $this->data['userid'] ?? 0;
|
||||||
|
$permission = $this->data['permission'] ?? 0;
|
||||||
|
if ($fileId > 0) {
|
||||||
|
SeekDBFile::addFileUser($fileId, $userid, $permission);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'remove_file_user':
|
||||||
|
// 删除文件用户关系
|
||||||
|
$fileId = $this->data['file_id'] ?? 0;
|
||||||
|
$userid = $this->data['userid'] ?? null;
|
||||||
|
if ($fileId > 0) {
|
||||||
|
SeekDBFile::removeFileUser($fileId, $userid);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'update_pshare':
|
||||||
|
// 批量更新文件的 pshare(共享设置变化时调用)
|
||||||
|
$fileIds = $this->data['file_ids'] ?? [];
|
||||||
|
$pshare = $this->data['pshare'] ?? 0;
|
||||||
|
if (!empty($fileIds)) {
|
||||||
|
\App\Module\SeekDB\SeekDBBase::batchUpdatePshare($fileIds, $pshare);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 增量更新
|
||||||
|
$this->incrementalUpdate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增量更新
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function incrementalUpdate()
|
||||||
|
{
|
||||||
|
// 120分钟执行一次
|
||||||
|
$time = intval(Cache::get("SeekDBFileSyncTask:Time"));
|
||||||
|
if (time() - $time < 120 * 60) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行开始,120分钟后缓存标记失效
|
||||||
|
Cache::put("SeekDBFileSyncTask:Time", time(), Carbon::now()->addMinutes(120));
|
||||||
|
|
||||||
|
// 开始执行同步
|
||||||
|
@shell_exec("php /var/www/artisan seekdb:sync-files --i");
|
||||||
|
|
||||||
|
// 执行完成,5分钟后缓存标记失效(5分钟任务可重复执行)
|
||||||
|
Cache::put("SeekDBFileSyncTask:Time", time(), Carbon::now()->addMinutes(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function end()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -17,6 +17,10 @@
|
|||||||
<Form class="search-form" action="javascript:void(0)" @submit.native.prevent="$A.eeuiAppKeyboardHide">
|
<Form class="search-form" action="javascript:void(0)" @submit.native.prevent="$A.eeuiAppKeyboardHide">
|
||||||
<Input type="search" ref="searchKey" v-model="searchKey" :placeholder="$L('请输入关键字')"/>
|
<Input type="search" ref="searchKey" v-model="searchKey" :placeholder="$L('请输入关键字')"/>
|
||||||
</Form>
|
</Form>
|
||||||
|
<div v-if="aiSearchAvailable" class="search-ai" :class="{active: aiSearch}" @click="toggleAiSearch">
|
||||||
|
<i class="taskfont"></i>
|
||||||
|
<span>{{ $L('AI 搜索') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-close" @click="onHide">
|
<div class="search-close" @click="onHide">
|
||||||
<i class="taskfont"></i>
|
<i class="taskfont"></i>
|
||||||
@ -87,7 +91,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {mapState} from "vuex";
|
import {mapState, mapGetters} from "vuex";
|
||||||
import emitter from "../store/events";
|
import emitter from "../store/events";
|
||||||
import transformEmojiToHtml from "../utils/emoji";
|
import transformEmojiToHtml from "../utils/emoji";
|
||||||
|
|
||||||
@ -116,6 +120,8 @@ export default {
|
|||||||
{type: 'file', name: '文件', icon: ''},
|
{type: 'file', name: '文件', icon: ''},
|
||||||
],
|
],
|
||||||
action: '',
|
action: '',
|
||||||
|
|
||||||
|
aiSearch: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -144,9 +150,16 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapState([
|
...mapState([
|
||||||
'themeName',
|
'themeName',
|
||||||
'keyboardShow'
|
'keyboardShow',
|
||||||
|
'microAppsIds'
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
aiSearchAvailable() {
|
||||||
|
return this.microAppsIds
|
||||||
|
&& this.microAppsIds.includes('seekdb')
|
||||||
|
&& this.microAppsIds.includes('ai')
|
||||||
|
},
|
||||||
|
|
||||||
isFullscreen({windowWidth}) {
|
isFullscreen({windowWidth}) {
|
||||||
return windowWidth < 576
|
return windowWidth < 576
|
||||||
},
|
},
|
||||||
@ -516,12 +529,20 @@ export default {
|
|||||||
|
|
||||||
searchFile(key) {
|
searchFile(key) {
|
||||||
this.loadIng++;
|
this.loadIng++;
|
||||||
this.$store.dispatch("call", {
|
const requestData = {
|
||||||
url: 'file/search',
|
|
||||||
data: {
|
|
||||||
key,
|
key,
|
||||||
take: this.action ? 50 : 10,
|
take: this.action ? 50 : 10,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
// 如果开启了 AI 搜索(需要同时安装 seekdb 和 ai)
|
||||||
|
if (this.aiSearchAvailable && this.aiSearch) {
|
||||||
|
requestData.search_content = 'yes';
|
||||||
|
requestData.search_type = 'hybrid';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.dispatch("call", {
|
||||||
|
url: 'file/search',
|
||||||
|
data: requestData,
|
||||||
}).then(({data}) => {
|
}).then(({data}) => {
|
||||||
const items = data.map(item => {
|
const items = data.map(item => {
|
||||||
const tags = [];
|
const tags = [];
|
||||||
@ -531,6 +552,13 @@ export default {
|
|||||||
style: 'background-color:#0bc037',
|
style: 'background-color:#0bc037',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 如果有内容预览,显示 AI 搜索标记
|
||||||
|
if (item.content_preview) {
|
||||||
|
tags.push({
|
||||||
|
name: 'AI',
|
||||||
|
style: 'background-color:#4F46E5',
|
||||||
|
})
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
type: 'file',
|
type: 'file',
|
||||||
@ -539,7 +567,9 @@ export default {
|
|||||||
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
desc: item.type === 'folder' ? '' : $A.bytesToSize(item.size),
|
desc: item.content_preview
|
||||||
|
? this.truncateContent(item.content_preview)
|
||||||
|
: (item.type === 'folder' ? '' : $A.bytesToSize(item.size)),
|
||||||
activity: item.updated_at,
|
activity: item.updated_at,
|
||||||
|
|
||||||
rawData: item,
|
rawData: item,
|
||||||
@ -549,6 +579,24 @@ export default {
|
|||||||
}).finally(_ => {
|
}).finally(_ => {
|
||||||
this.loadIng--;
|
this.loadIng--;
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
truncateContent(content) {
|
||||||
|
if (!content) return '';
|
||||||
|
const maxLen = 100;
|
||||||
|
const text = content.replace(/\s+/g, ' ').trim();
|
||||||
|
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleAiSearch() {
|
||||||
|
this.aiSearch = !this.aiSearch;
|
||||||
|
// 如果有搜索内容,重新搜索
|
||||||
|
if (this.searchKey.trim()) {
|
||||||
|
this.searchResults = this.searchResults.filter(item => item.type !== 'file');
|
||||||
|
if (this.action === 'file' || !this.action) {
|
||||||
|
this.searchFile(this.searchKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
28
resources/assets/sass/components/search-box.scss
vendored
28
resources/assets/sass/components/search-box.scss
vendored
@ -84,6 +84,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-ai {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
> i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-close {
|
.search-close {
|
||||||
|
|||||||
9
resources/assets/sass/dark.scss
vendored
9
resources/assets/sass/dark.scss
vendored
@ -715,6 +715,15 @@ body.dark-mode-reverse {
|
|||||||
.ivu-modal {
|
.ivu-modal {
|
||||||
.ivu-modal-content {
|
.ivu-modal-content {
|
||||||
.ivu-modal-body {
|
.ivu-modal-body {
|
||||||
|
.search-header {
|
||||||
|
.search-input {
|
||||||
|
.search-ai {
|
||||||
|
&.active {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.search-body {
|
.search-body {
|
||||||
border-top-color: #e9e9e9;
|
border-top-color: #e9e9e9;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user