diff --git a/app/Console/Commands/SyncFileToSeekDB.php b/app/Console/Commands/SyncFileToSeekDB.php new file mode 100644 index 000000000..1ae62fbb4 --- /dev/null +++ b/app/Console/Commands/SyncFileToSeekDB.php @@ -0,0 +1,211 @@ +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()); + } +} + diff --git a/app/Http/Controllers/Api/FileController.php b/app/Http/Controllers/Api/FileController.php index bee114847..e6e68e68f 100755 --- a/app/Http/Controllers/Api/FileController.php +++ b/app/Http/Controllers/Api/FileController.php @@ -12,6 +12,7 @@ use App\Models\FileLink; use App\Models\FileUser; use App\Models\User; use App\Models\UserRecentItem; +use App\Module\Apps; use App\Module\Base; use App\Module\Down; use App\Module\Timer; @@ -122,9 +123,11 @@ class FileController extends AbstractController * @apiGroup file * @apiName search * - * @apiParam {String} [link] 通过分享地址搜索(如:https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==) - * @apiParam {String} [key] 关键词 - * @apiParam {Number} [take] 获取数量(默认:50,最大:100) + * @apiParam {String} [link] 通过分享地址搜索(如:https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==) + * @apiParam {String} [key] 关键词 + * @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 {String} msg 返回信息(错误描述) @@ -136,6 +139,8 @@ class FileController extends AbstractController // $link = trim(Request::input('link')); $key = trim(Request::input('key')); + $searchContent = Request::input('search_content', 'no') === 'yes'; + $searchType = Request::input('search_type', 'hybrid'); $id = 0; $take = Base::getPaginate(100, 50, 'take'); if (preg_match("/\/single\/file\/(.*?)$/i", $link, $match)) { @@ -145,6 +150,35 @@ class FileController extends AbstractController 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); if ($id) { diff --git a/app/Http/Controllers/IndexController.php b/app/Http/Controllers/IndexController.php index 4a99e70e3..fcf3894d8 100755 --- a/app/Http/Controllers/IndexController.php +++ b/app/Http/Controllers/IndexController.php @@ -22,6 +22,7 @@ use App\Tasks\DeleteBotMsgTask; use App\Tasks\CheckinRemindTask; use App\Tasks\CloseMeetingRoomTask; use App\Tasks\ZincSearchSyncTask; +use App\Tasks\SeekDBFileSyncTask; use App\Tasks\UnclaimedTaskRemindTask; use Hhxsv5\LaravelS\Swoole\Task\Task; use Laravolt\Avatar\Avatar; @@ -273,6 +274,8 @@ class IndexController extends InvokeController Task::deliver(new CloseMeetingRoomTask()); // ZincSearch 同步 Task::deliver(new ZincSearchSyncTask()); + // SeekDB 文件同步 + Task::deliver(new SeekDBFileSyncTask()); return "success"; } diff --git a/app/Module/AI.php b/app/Module/AI.php index 64c830ad7..d68f672e8 100644 --- a/app/Module/AI.php +++ b/app/Module/AI.php @@ -738,4 +738,137 @@ class AI 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; + } } diff --git a/app/Module/Apps.php b/app/Module/Apps.php index acbbae35f..ef29dbd93 100644 --- a/app/Module/Apps.php +++ b/app/Module/Apps.php @@ -55,6 +55,7 @@ class Apps 'drawio' => 'Drawio', 'minder' => 'Minder', 'search' => 'ZincSearch', + 'seekdb' => 'SeekDB', default => $appId, }; throw new ApiException("应用「{$name}」未安装", [], 0, false); diff --git a/app/Module/SeekDB/SeekDBBase.php b/app/Module/SeekDB/SeekDBBase.php new file mode 100644 index 000000000..b86a194d4 --- /dev/null +++ b/app/Module/SeekDB/SeekDBBase.php @@ -0,0 +1,776 @@ +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"); + } +} + diff --git a/app/Module/SeekDB/SeekDBFile.php b/app/Module/SeekDB/SeekDBFile.php new file mode 100644 index 000000000..bed5b4afc --- /dev/null +++ b/app/Module/SeekDB/SeekDBFile.php @@ -0,0 +1,539 @@ + 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; + } +} + diff --git a/app/Module/SeekDB/SeekDBKeyValue.php b/app/Module/SeekDB/SeekDBKeyValue.php new file mode 100644 index 000000000..db0a856af --- /dev/null +++ b/app/Module/SeekDB/SeekDBKeyValue.php @@ -0,0 +1,181 @@ + '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"); + } +} + diff --git a/app/Observers/FileObserver.php b/app/Observers/FileObserver.php new file mode 100644 index 000000000..65a24905e --- /dev/null +++ b/app/Observers/FileObserver.php @@ -0,0 +1,96 @@ +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())); + } +} + diff --git a/app/Observers/FileUserObserver.php b/app/Observers/FileUserObserver.php new file mode 100644 index 000000000..64f8e1837 --- /dev/null +++ b/app/Observers/FileUserObserver.php @@ -0,0 +1,54 @@ + $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, + ])); + } +} + diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 3e9a0328e..9675b5085 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Models\File; +use App\Models\FileUser; use App\Models\Project; use App\Models\ProjectTask; use App\Models\ProjectTaskUser; @@ -9,6 +11,8 @@ use App\Models\ProjectUser; use App\Models\WebSocketDialog; use App\Models\WebSocketDialogMsg; use App\Models\WebSocketDialogUser; +use App\Observers\FileObserver; +use App\Observers\FileUserObserver; use App\Observers\ProjectObserver; use App\Observers\ProjectTaskObserver; use App\Observers\ProjectTaskUserObserver; @@ -40,6 +44,8 @@ class EventServiceProvider extends ServiceProvider */ public function boot() { + File::observe(FileObserver::class); + FileUser::observe(FileUserObserver::class); Project::observe(ProjectObserver::class); ProjectTask::observe(ProjectTaskObserver::class); ProjectTaskUser::observe(ProjectTaskUserObserver::class); diff --git a/app/Tasks/SeekDBFileSyncTask.php b/app/Tasks/SeekDBFileSyncTask.php new file mode 100644 index 000000000..e9414c138 --- /dev/null +++ b/app/Tasks/SeekDBFileSyncTask.php @@ -0,0 +1,120 @@ +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() + { + } +} + diff --git a/resources/assets/js/components/SearchBox.vue b/resources/assets/js/components/SearchBox.vue index 428b86226..4e634763d 100755 --- a/resources/assets/js/components/SearchBox.vue +++ b/resources/assets/js/components/SearchBox.vue @@ -17,6 +17,10 @@
+