From fe7a2a0e73a003b2915649e95dff406f4d309a35 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Tue, 30 Dec 2025 07:47:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20SeekDB=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=81=94=E7=B3=BB=E4=BA=BA=E3=80=81=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E3=80=81=E4=BB=BB=E5=8A=A1=E7=9A=84=20AI=20=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并 SeekDBFileSyncTask 到 SeekDBSyncTask - 统一 AI 搜索 API 入口 --- app/Console/Commands/SyncProjectToSeekDB.php | 166 +++ app/Console/Commands/SyncTaskToSeekDB.php | 166 +++ app/Console/Commands/SyncUserToSeekDB.php | 143 +++ app/Http/Controllers/Api/FileController.php | 35 +- app/Http/Controllers/Api/SearchController.php | 246 ++++ app/Http/Controllers/IndexController.php | 6 +- app/Module/SeekDB/SeekDBBase.php | 1127 +++++++++++++++++ app/Module/SeekDB/SeekDBProject.php | 384 ++++++ app/Module/SeekDB/SeekDBTask.php | 571 +++++++++ app/Module/SeekDB/SeekDBUser.php | 275 ++++ app/Observers/FileObserver.php | 14 +- app/Observers/FileUserObserver.php | 8 +- app/Observers/ProjectObserver.php | 27 +- app/Observers/ProjectTaskObserver.php | 33 +- app/Observers/ProjectTaskUserObserver.php | 22 +- .../ProjectTaskVisibilityUserObserver.php | 84 ++ app/Observers/ProjectUserObserver.php | 11 +- app/Observers/UserObserver.php | 69 + app/Providers/EventServiceProvider.php | 6 + app/Tasks/SeekDBFileSyncTask.php | 120 -- app/Tasks/SeekDBSyncTask.php | 242 ++++ resources/assets/js/components/SearchBox.vue | 127 +- routes/web.php | 4 + 23 files changed, 3679 insertions(+), 207 deletions(-) create mode 100644 app/Console/Commands/SyncProjectToSeekDB.php create mode 100644 app/Console/Commands/SyncTaskToSeekDB.php create mode 100644 app/Console/Commands/SyncUserToSeekDB.php create mode 100644 app/Http/Controllers/Api/SearchController.php create mode 100644 app/Module/SeekDB/SeekDBProject.php create mode 100644 app/Module/SeekDB/SeekDBTask.php create mode 100644 app/Module/SeekDB/SeekDBUser.php create mode 100644 app/Observers/ProjectTaskVisibilityUserObserver.php create mode 100644 app/Observers/UserObserver.php delete mode 100644 app/Tasks/SeekDBFileSyncTask.php create mode 100644 app/Tasks/SeekDBSyncTask.php diff --git a/app/Console/Commands/SyncProjectToSeekDB.php b/app/Console/Commands/SyncProjectToSeekDB.php new file mode 100644 index 000000000..2c904184a --- /dev/null +++ b/app/Console/Commands/SyncProjectToSeekDB.php @@ -0,0 +1,166 @@ +error("应用「SeekDB」未安装"); + return 1; + } + + // 注册信号处理器 + if (extension_loaded('pcntl')) { + pcntl_async_signals(true); + pcntl_signal(SIGINT, [$this, 'handleSignal']); + pcntl_signal(SIGTERM, [$this, 'handleSignal']); + } + + // 检查锁 + $lockInfo = $this->getLock(); + if ($lockInfo) { + $this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}"); + return 1; + } + + $this->setLock(); + + // 清除索引 + if ($this->option('c')) { + $this->info('清除索引...'); + SeekDBProject::clear(); + SeekDBKeyValue::set('sync:seekdbProjectLastId', 0); + $this->info("索引删除成功"); + $this->releaseLock(); + return 0; + } + + // 仅同步项目成员关系 + if ($this->option('u')) { + $this->info('开始同步项目成员关系...'); + $count = SeekDBProject::syncAllProjectUsers(function ($count) { + if ($count % 1000 === 0) { + $this->info(" 已同步 {$count} 条关系..."); + } + }); + $this->info("项目成员关系同步完成,共 {$count} 条"); + $this->releaseLock(); + return 0; + } + + $this->info('开始同步项目数据...'); + $this->syncProjects(); + + // 全量同步时,同步项目成员关系 + if ($this->option('f') || (!$this->option('i') && !$this->option('u'))) { + $this->info("\n同步项目成员关系..."); + $count = SeekDBProject::syncAllProjectUsers(function ($count) { + if ($count % 1000 === 0) { + $this->info(" 已同步 {$count} 条关系..."); + } + }); + $this->info("项目成员关系同步完成,共 {$count} 条"); + } + + $this->info("\n同步完成"); + $this->releaseLock(); + return 0; + } + + private function getLock(): ?array + { + $lockKey = md5($this->signature); + return Cache::has($lockKey) ? Cache::get($lockKey) : null; + } + + private function setLock(): void + { + $lockKey = md5($this->signature); + Cache::put($lockKey, ['started_at' => date('Y-m-d H:i:s')], 300); + } + + private function releaseLock(): void + { + Cache::forget(md5($this->signature)); + } + + public function handleSignal(int $signal): void + { + $this->releaseLock(); + exit(0); + } + + private function syncProjects(): void + { + $lastKey = "sync:seekdbProjectLastId"; + $lastId = $this->option('i') ? intval(SeekDBKeyValue::get($lastKey, 0)) : 0; + + if ($lastId > 0) { + $this->info("\n增量同步项目数据(从ID: {$lastId})..."); + } else { + $this->info("\n全量同步项目数据..."); + } + + // 只同步未归档的项目 + $query = Project::where('id', '>', $lastId) + ->whereNull('archived_at'); + + $num = 0; + $count = $query->count(); + $batchSize = $this->option('batch'); + $total = 0; + + do { + $projects = Project::where('id', '>', $lastId) + ->whereNull('archived_at') + ->orderBy('id') + ->limit($batchSize) + ->get(); + + if ($projects->isEmpty()) { + break; + } + + $num += count($projects); + $progress = $count > 0 ? round($num / $count * 100, 2) : 100; + $this->info("{$num}/{$count} ({$progress}%) 正在同步项目ID {$projects->first()->id} ~ {$projects->last()->id}"); + + $this->setLock(); + + $synced = SeekDBProject::batchSync($projects); + $total += $synced; + + $lastId = $projects->last()->id; + SeekDBKeyValue::set($lastKey, $lastId); + } while (count($projects) == $batchSize); + + $this->info("同步项目结束 - 最后ID {$lastId},共同步 {$total} 个项目"); + $this->info("已索引项目数量: " . SeekDBProject::getIndexedCount()); + } +} + diff --git a/app/Console/Commands/SyncTaskToSeekDB.php b/app/Console/Commands/SyncTaskToSeekDB.php new file mode 100644 index 000000000..6f8d8281f --- /dev/null +++ b/app/Console/Commands/SyncTaskToSeekDB.php @@ -0,0 +1,166 @@ +error("应用「SeekDB」未安装"); + return 1; + } + + // 注册信号处理器 + if (extension_loaded('pcntl')) { + pcntl_async_signals(true); + pcntl_signal(SIGINT, [$this, 'handleSignal']); + pcntl_signal(SIGTERM, [$this, 'handleSignal']); + } + + // 检查锁 + $lockInfo = $this->getLock(); + if ($lockInfo) { + $this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}"); + return 1; + } + + $this->setLock(); + + // 清除索引 + if ($this->option('c')) { + $this->info('清除索引...'); + SeekDBTask::clear(); + SeekDBKeyValue::set('sync:seekdbTaskLastId', 0); + $this->info("索引删除成功"); + $this->releaseLock(); + return 0; + } + + // 仅同步任务成员关系 + if ($this->option('u')) { + $this->info('开始同步任务成员关系...'); + $count = SeekDBTask::syncAllTaskUsers(function ($count) { + if ($count % 1000 === 0) { + $this->info(" 已同步 {$count} 条关系..."); + } + }); + $this->info("任务成员关系同步完成,共 {$count} 条"); + $this->releaseLock(); + return 0; + } + + $this->info('开始同步任务数据...'); + $this->syncTasks(); + + // 全量同步时,同步任务成员关系 + if ($this->option('f') || (!$this->option('i') && !$this->option('u'))) { + $this->info("\n同步任务成员关系..."); + $count = SeekDBTask::syncAllTaskUsers(function ($count) { + if ($count % 1000 === 0) { + $this->info(" 已同步 {$count} 条关系..."); + } + }); + $this->info("任务成员关系同步完成,共 {$count} 条"); + } + + $this->info("\n同步完成"); + $this->releaseLock(); + return 0; + } + + private function getLock(): ?array + { + $lockKey = md5($this->signature); + return Cache::has($lockKey) ? Cache::get($lockKey) : null; + } + + private function setLock(): void + { + $lockKey = md5($this->signature); + Cache::put($lockKey, ['started_at' => date('Y-m-d H:i:s')], 600); // 任务可能较多,10分钟 + } + + private function releaseLock(): void + { + Cache::forget(md5($this->signature)); + } + + public function handleSignal(int $signal): void + { + $this->releaseLock(); + exit(0); + } + + private function syncTasks(): void + { + $lastKey = "sync:seekdbTaskLastId"; + $lastId = $this->option('i') ? intval(SeekDBKeyValue::get($lastKey, 0)) : 0; + + if ($lastId > 0) { + $this->info("\n增量同步任务数据(从ID: {$lastId})..."); + } else { + $this->info("\n全量同步任务数据..."); + } + + // 只同步未归档的任务(包括软删除恢复的情况) + $query = ProjectTask::where('id', '>', $lastId) + ->whereNull('archived_at'); + + $num = 0; + $count = $query->count(); + $batchSize = $this->option('batch'); + $total = 0; + + do { + $tasks = ProjectTask::where('id', '>', $lastId) + ->whereNull('archived_at') + ->orderBy('id') + ->limit($batchSize) + ->get(); + + if ($tasks->isEmpty()) { + break; + } + + $num += count($tasks); + $progress = $count > 0 ? round($num / $count * 100, 2) : 100; + $this->info("{$num}/{$count} ({$progress}%) 正在同步任务ID {$tasks->first()->id} ~ {$tasks->last()->id}"); + + $this->setLock(); + + $synced = SeekDBTask::batchSync($tasks); + $total += $synced; + + $lastId = $tasks->last()->id; + SeekDBKeyValue::set($lastKey, $lastId); + } while (count($tasks) == $batchSize); + + $this->info("同步任务结束 - 最后ID {$lastId},共同步 {$total} 个任务"); + $this->info("已索引任务数量: " . SeekDBTask::getIndexedCount()); + } +} + diff --git a/app/Console/Commands/SyncUserToSeekDB.php b/app/Console/Commands/SyncUserToSeekDB.php new file mode 100644 index 000000000..5b5924512 --- /dev/null +++ b/app/Console/Commands/SyncUserToSeekDB.php @@ -0,0 +1,143 @@ +error("应用「SeekDB」未安装"); + return 1; + } + + // 注册信号处理器 + if (extension_loaded('pcntl')) { + pcntl_async_signals(true); + pcntl_signal(SIGINT, [$this, 'handleSignal']); + pcntl_signal(SIGTERM, [$this, 'handleSignal']); + } + + // 检查锁 + $lockInfo = $this->getLock(); + if ($lockInfo) { + $this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}"); + return 1; + } + + $this->setLock(); + + // 清除索引 + if ($this->option('c')) { + $this->info('清除索引...'); + SeekDBUser::clear(); + SeekDBKeyValue::set('sync:seekdbUserLastId', 0); + $this->info("索引删除成功"); + $this->releaseLock(); + return 0; + } + + $this->info('开始同步用户数据...'); + $this->syncUsers(); + + $this->info("\n同步完成"); + $this->releaseLock(); + return 0; + } + + private function getLock(): ?array + { + $lockKey = md5($this->signature); + return Cache::has($lockKey) ? Cache::get($lockKey) : null; + } + + private function setLock(): void + { + $lockKey = md5($this->signature); + Cache::put($lockKey, ['started_at' => date('Y-m-d H:i:s')], 300); + } + + private function releaseLock(): void + { + Cache::forget(md5($this->signature)); + } + + public function handleSignal(int $signal): void + { + $this->releaseLock(); + exit(0); + } + + private function syncUsers(): void + { + $lastKey = "sync:seekdbUserLastId"; + $lastId = $this->option('i') ? intval(SeekDBKeyValue::get($lastKey, 0)) : 0; + + if ($lastId > 0) { + $this->info("\n增量同步用户数据(从ID: {$lastId})..."); + } else { + $this->info("\n全量同步用户数据..."); + } + + // 只同步非机器人且未禁用的用户 + $query = User::where('userid', '>', $lastId) + ->where('bot', 0) + ->whereNull('disable_at'); + + $num = 0; + $count = $query->count(); + $batchSize = $this->option('batch'); + $total = 0; + + do { + $users = User::where('userid', '>', $lastId) + ->where('bot', 0) + ->whereNull('disable_at') + ->orderBy('userid') + ->limit($batchSize) + ->get(); + + if ($users->isEmpty()) { + break; + } + + $num += count($users); + $progress = $count > 0 ? round($num / $count * 100, 2) : 100; + $this->info("{$num}/{$count} ({$progress}%) 正在同步用户ID {$users->first()->userid} ~ {$users->last()->userid}"); + + $this->setLock(); + + $synced = SeekDBUser::batchSync($users); + $total += $synced; + + $lastId = $users->last()->userid; + SeekDBKeyValue::set($lastKey, $lastId); + } while (count($users) == $batchSize); + + $this->info("同步用户结束 - 最后ID {$lastId},共同步 {$total} 个用户"); + $this->info("已索引用户数量: " . SeekDBUser::getIndexedCount()); + } +} + diff --git a/app/Http/Controllers/Api/FileController.php b/app/Http/Controllers/Api/FileController.php index e6e68e68f..e1f8bf501 100755 --- a/app/Http/Controllers/Api/FileController.php +++ b/app/Http/Controllers/Api/FileController.php @@ -12,7 +12,6 @@ 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; @@ -118,7 +117,7 @@ class FileController extends AbstractController /** * @api {get} api/file/search 搜索文件列表 * - * @apiDescription 需要token身份 + * @apiDescription 需要token身份(仅搜索文件名,AI 内容搜索请使用 api/search/file) * @apiVersion 1.0.0 * @apiGroup file * @apiName search @@ -126,8 +125,6 @@ class FileController extends AbstractController * @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 返回信息(错误描述) @@ -139,8 +136,6 @@ 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)) { @@ -151,34 +146,6 @@ class FileController extends AbstractController } } - // 如果需要搜索文件内容且有关键词 - 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/Api/SearchController.php b/app/Http/Controllers/Api/SearchController.php new file mode 100644 index 000000000..1970f5ac4 --- /dev/null +++ b/app/Http/Controllers/Api/SearchController.php @@ -0,0 +1,246 @@ +select(User::$basicField) + ->get() + ->keyBy('userid'); + + foreach ($results as &$item) { + $userData = $users->get($item['userid']); + if ($userData) { + $item = array_merge($userData->toArray(), [ + 'relevance' => $item['relevance'] ?? 0, + 'introduction_preview' => $item['introduction_preview'] ?? null, + ]); + } + } + } + + return Base::retSuccess('success', $results); + } + + /** + * @api {get} api/search/project AI 搜索项目 + * + * @apiDescription 需要token身份,需要安装 SeekDB 应用 + * @apiVersion 1.0.0 + * @apiGroup search + * @apiName project + * + * @apiParam {String} key 搜索关键词 + * @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid) + * @apiParam {Number} [take] 获取数量(默认:20,最大:50) + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + */ + public function project() + { + $user = User::auth(); + + if (!Apps::isInstalled('seekdb')) { + return Base::retError('SeekDB 应用未安装'); + } + + $key = trim(Request::input('key')); + $searchType = Request::input('search_type', 'hybrid'); + $take = min(50, max(1, intval(Request::input('take', 20)))); + + if (empty($key)) { + return Base::retSuccess('success', []); + } + + $results = SeekDBProject::search($user->userid, $key, $searchType, $take); + + // 补充项目完整信息 + $projectIds = array_column($results, 'project_id'); + if (!empty($projectIds)) { + $projects = \App\Models\Project::whereIn('id', $projectIds) + ->get() + ->keyBy('id'); + + foreach ($results as &$item) { + $projectData = $projects->get($item['project_id']); + if ($projectData) { + $item = array_merge($projectData->toArray(), [ + 'relevance' => $item['relevance'] ?? 0, + 'desc_preview' => $item['desc_preview'] ?? null, + ]); + } + } + } + + return Base::retSuccess('success', $results); + } + + /** + * @api {get} api/search/task AI 搜索任务 + * + * @apiDescription 需要token身份,需要安装 SeekDB 应用 + * @apiVersion 1.0.0 + * @apiGroup search + * @apiName task + * + * @apiParam {String} key 搜索关键词 + * @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid) + * @apiParam {Number} [take] 获取数量(默认:20,最大:50) + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + */ + public function task() + { + $user = User::auth(); + + if (!Apps::isInstalled('seekdb')) { + return Base::retError('SeekDB 应用未安装'); + } + + $key = trim(Request::input('key')); + $searchType = Request::input('search_type', 'hybrid'); + $take = min(50, max(1, intval(Request::input('take', 20)))); + + if (empty($key)) { + return Base::retSuccess('success', []); + } + + $results = SeekDBTask::search($user->userid, $key, $searchType, $take); + + // 补充任务完整信息 + $taskIds = array_column($results, 'task_id'); + if (!empty($taskIds)) { + $tasks = \App\Models\ProjectTask::whereIn('id', $taskIds) + ->get() + ->keyBy('id'); + + foreach ($results as &$item) { + $taskData = $tasks->get($item['task_id']); + if ($taskData) { + $item = array_merge($taskData->toArray(), [ + 'relevance' => $item['relevance'] ?? 0, + 'desc_preview' => $item['desc_preview'] ?? null, + 'content_preview' => $item['content_preview'] ?? null, + ]); + } + } + } + + return Base::retSuccess('success', $results); + } + + /** + * @api {get} api/search/file AI 搜索文件 + * + * @apiDescription 需要token身份,需要安装 SeekDB 应用 + * @apiVersion 1.0.0 + * @apiGroup search + * @apiName file + * + * @apiParam {String} key 搜索关键词 + * @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid) + * @apiParam {Number} [take] 获取数量(默认:20,最大:50) + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + */ + public function file() + { + $user = User::auth(); + + if (!Apps::isInstalled('seekdb')) { + return Base::retError('SeekDB 应用未安装'); + } + + $key = trim(Request::input('key')); + $searchType = Request::input('search_type', 'hybrid'); + $take = min(50, max(1, intval(Request::input('take', 20)))); + + if (empty($key)) { + return Base::retSuccess('success', []); + } + + $results = SeekDBFile::search($user->userid, $key, $searchType, 0, $take); + + // 补充文件完整信息 + $fileIds = array_column($results, 'file_id'); + if (!empty($fileIds)) { + $files = File::whereIn('id', $fileIds) + ->get() + ->keyBy('id'); + + $formattedResults = []; + foreach ($results as $item) { + $fileData = $files->get($item['file_id']); + if ($fileData) { + $formattedResults[] = array_merge($fileData->toArray(), [ + 'relevance' => $item['relevance'] ?? 0, + 'content_preview' => $item['content_preview'] ?? null, + ]); + } + } + return Base::retSuccess('success', $formattedResults); + } + + return Base::retSuccess('success', []); + } +} + diff --git a/app/Http/Controllers/IndexController.php b/app/Http/Controllers/IndexController.php index fcf3894d8..5faa284d8 100755 --- a/app/Http/Controllers/IndexController.php +++ b/app/Http/Controllers/IndexController.php @@ -22,7 +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\SeekDBSyncTask; use App\Tasks\UnclaimedTaskRemindTask; use Hhxsv5\LaravelS\Swoole\Task\Task; use Laravolt\Avatar\Avatar; @@ -274,8 +274,8 @@ class IndexController extends InvokeController Task::deliver(new CloseMeetingRoomTask()); // ZincSearch 同步 Task::deliver(new ZincSearchSyncTask()); - // SeekDB 文件同步 - Task::deliver(new SeekDBFileSyncTask()); + // SeekDB 同步(文件/用户/项目/任务) + Task::deliver(new SeekDBSyncTask()); return "success"; } diff --git a/app/Module/SeekDB/SeekDBBase.php b/app/Module/SeekDB/SeekDBBase.php index b86a194d4..e529e5389 100644 --- a/app/Module/SeekDB/SeekDBBase.php +++ b/app/Module/SeekDB/SeekDBBase.php @@ -130,6 +130,95 @@ class SeekDBBase ) "); + // 创建用户向量表(联系人搜索) + $pdo->exec(" + CREATE TABLE IF NOT EXISTS user_vectors ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + userid BIGINT NOT NULL, + nickname VARCHAR(200), + email VARCHAR(200), + tel VARCHAR(50), + profession VARCHAR(200), + introduction TEXT, + content_vector VECTOR(1536), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_userid (userid), + FULLTEXT KEY ft_content (nickname, email, profession, introduction) + ) + "); + + // 创建项目向量表 + $pdo->exec(" + CREATE TABLE IF NOT EXISTS project_vectors ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + project_id BIGINT NOT NULL, + userid BIGINT NOT NULL, + personal TINYINT DEFAULT 0, + project_name VARCHAR(500), + project_desc TEXT, + content_vector VECTOR(1536), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_project_id (project_id), + KEY idx_userid (userid), + FULLTEXT KEY ft_content (project_name, project_desc) + ) + "); + + // 创建项目成员表(用于权限过滤) + $pdo->exec(" + CREATE TABLE IF NOT EXISTS project_users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + project_id BIGINT NOT NULL, + userid BIGINT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_project_user (project_id, userid), + KEY idx_project_id (project_id), + KEY idx_userid (userid) + ) + "); + + // 创建任务向量表 + $pdo->exec(" + CREATE TABLE IF NOT EXISTS task_vectors ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + task_id BIGINT NOT NULL, + project_id BIGINT NOT NULL, + userid BIGINT NOT NULL, + visibility TINYINT DEFAULT 1, + task_name VARCHAR(500), + task_desc TEXT, + task_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_task_id (task_id), + KEY idx_project_id (project_id), + KEY idx_userid (userid), + KEY idx_visibility (visibility), + FULLTEXT KEY ft_content (task_name, task_desc, task_content) + ) + "); + + // 创建任务成员表(用于 visibility=2,3 的权限过滤) + $pdo->exec(" + CREATE TABLE IF NOT EXISTS task_users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + task_id BIGINT NOT NULL, + userid BIGINT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_task_user (task_id, userid), + KEY idx_task_id (task_id), + KEY idx_userid (userid) + ) + "); + Log::info('SeekDB database initialized successfully'); } catch (PDOException $e) { Log::warning('SeekDB initialization warning: ' . $e->getMessage()); @@ -772,5 +861,1043 @@ class SeekDBBase $instance = new self(); return $instance->execute("TRUNCATE TABLE file_users"); } + + // ============================== + // 用户向量方法(联系人搜索) + // ============================== + + /** + * 用户全文搜索 + * + * @param string $keyword 关键词 + * @param int $limit 返回数量 + * @param int $offset 偏移量 + * @return array 搜索结果 + */ + public static function userFullTextSearch(string $keyword, int $limit = 20, int $offset = 0): array + { + if (empty($keyword)) { + return []; + } + + $instance = new self(); + $likeKeyword = "%{$keyword}%"; + + $sql = " + SELECT + userid, + nickname, + email, + tel, + profession, + SUBSTRING(introduction, 1, 200) as introduction_preview, + ( + CASE WHEN nickname LIKE ? THEN 10 ELSE 0 END + + CASE WHEN email LIKE ? THEN 5 ELSE 0 END + + IFNULL(MATCH(nickname, email, profession, introduction) AGAINST(? IN NATURAL LANGUAGE MODE), 0) + ) AS relevance + FROM user_vectors + WHERE nickname LIKE ? OR email LIKE ? OR profession LIKE ? + OR MATCH(nickname, email, profession, introduction) AGAINST(? IN NATURAL LANGUAGE MODE) + ORDER BY relevance DESC + LIMIT " . (int)$limit . " OFFSET " . (int)$offset; + + $params = [$likeKeyword, $likeKeyword, $keyword, $likeKeyword, $likeKeyword, $likeKeyword, $keyword]; + + return $instance->query($sql, $params); + } + + /** + * 用户向量搜索 + * + * @param array $queryVector 查询向量 + * @param int $limit 返回数量 + * @return array 搜索结果 + */ + public static function userVectorSearch(array $queryVector, int $limit = 20): array + { + if (empty($queryVector)) { + return []; + } + + $instance = new self(); + $vectorStr = '[' . implode(',', $queryVector) . ']'; + + $sql = " + SELECT + userid, + nickname, + email, + tel, + profession, + SUBSTRING(introduction, 1, 200) as introduction_preview, + COSINE_SIMILARITY(content_vector, ?) AS similarity + FROM user_vectors + WHERE content_vector IS NOT NULL + ORDER BY similarity DESC + LIMIT " . (int)$limit; + + return $instance->query($sql, [$vectorStr]); + } + + /** + * 用户混合搜索 + * + * @param string $keyword 关键词 + * @param array $queryVector 查询向量 + * @param int $limit 返回数量 + * @return array 搜索结果 + */ + public static function userHybridSearch(string $keyword, array $queryVector, int $limit = 20): array + { + $textResults = self::userFullTextSearch($keyword, 50, 0); + $vectorResults = !empty($queryVector) ? self::userVectorSearch($queryVector, 50) : []; + + // RRF 融合 + $scores = []; + $items = []; + $k = 60; + + foreach ($textResults as $rank => $item) { + $id = $item['userid']; + $scores[$id] = ($scores[$id] ?? 0) + 0.5 / ($k + $rank + 1); + $items[$id] = $item; + } + + foreach ($vectorResults as $rank => $item) { + $id = $item['userid']; + $scores[$id] = ($scores[$id] ?? 0) + 0.5 / ($k + $rank + 1); + if (!isset($items[$id])) { + $items[$id] = $item; + } + } + + arsort($scores); + + $results = []; + $count = 0; + foreach ($scores as $id => $score) { + if ($count >= $limit) break; + $item = $items[$id]; + $item['rrf_score'] = $score; + $results[] = $item; + $count++; + } + + return $results; + } + + /** + * 插入或更新用户向量 + * + * @param array $data 用户数据 + * @return bool 是否成功 + */ + public static function upsertUserVector(array $data): bool + { + $instance = new self(); + + $userid = $data['userid'] ?? 0; + if ($userid <= 0) { + return false; + } + + $existing = $instance->queryOne("SELECT id FROM user_vectors WHERE userid = ?", [$userid]); + + if ($existing) { + $sql = "UPDATE user_vectors SET + nickname = ?, + email = ?, + tel = ?, + profession = ?, + introduction = ?, + content_vector = ?, + updated_at = NOW() + WHERE userid = ?"; + + $params = [ + $data['nickname'] ?? '', + $data['email'] ?? '', + $data['tel'] ?? '', + $data['profession'] ?? '', + $data['introduction'] ?? '', + $data['content_vector'] ?? null, + $userid + ]; + } else { + $sql = "INSERT INTO user_vectors + (userid, nickname, email, tel, profession, introduction, content_vector, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())"; + + $params = [ + $userid, + $data['nickname'] ?? '', + $data['email'] ?? '', + $data['tel'] ?? '', + $data['profession'] ?? '', + $data['introduction'] ?? '', + $data['content_vector'] ?? null + ]; + } + + return $instance->execute($sql, $params); + } + + /** + * 删除用户向量 + * + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function deleteUserVector(int $userid): bool + { + if ($userid <= 0) { + return false; + } + + $instance = new self(); + return $instance->execute("DELETE FROM user_vectors WHERE userid = ?", [$userid]); + } + + /** + * 清空所有用户向量 + * + * @return bool 是否成功 + */ + public static function clearAllUserVectors(): bool + { + $instance = new self(); + return $instance->execute("TRUNCATE TABLE user_vectors"); + } + + /** + * 获取已索引的用户数量 + * + * @return int 用户数量 + */ + public static function getIndexedUserCount(): int + { + $instance = new self(); + $result = $instance->queryOne("SELECT COUNT(*) as cnt FROM user_vectors"); + return $result ? (int) $result['cnt'] : 0; + } + + // ============================== + // 项目向量方法 + // ============================== + + /** + * 项目全文搜索 + * + * @param string $keyword 关键词 + * @param int $userid 用户ID(权限过滤) + * @param int $limit 返回数量 + * @param int $offset 偏移量 + * @return array 搜索结果 + */ + public static function projectFullTextSearch(string $keyword, int $userid = 0, int $limit = 20, int $offset = 0): array + { + if (empty($keyword)) { + return []; + } + + $instance = new self(); + $likeKeyword = "%{$keyword}%"; + + if ($userid > 0) { + // 权限过滤:只搜索用户参与的项目 + $sql = " + SELECT DISTINCT + pv.project_id, + pv.userid, + pv.personal, + pv.project_name, + SUBSTRING(pv.project_desc, 1, 300) as project_desc_preview, + ( + CASE WHEN pv.project_name LIKE ? THEN 10 ELSE 0 END + + IFNULL(MATCH(pv.project_name, pv.project_desc) AGAINST(? IN NATURAL LANGUAGE MODE), 0) + ) AS relevance + FROM project_vectors pv + JOIN project_users pu ON pv.project_id = pu.project_id + WHERE (pv.project_name LIKE ? OR MATCH(pv.project_name, pv.project_desc) AGAINST(? IN NATURAL LANGUAGE MODE)) + AND pu.userid = ? + ORDER BY relevance DESC + LIMIT " . (int)$limit . " OFFSET " . (int)$offset; + + $params = [$likeKeyword, $keyword, $likeKeyword, $keyword, $userid]; + } else { + // 不限制权限 + $sql = " + SELECT + project_id, + userid, + personal, + project_name, + SUBSTRING(project_desc, 1, 300) as project_desc_preview, + ( + CASE WHEN project_name LIKE ? THEN 10 ELSE 0 END + + IFNULL(MATCH(project_name, project_desc) AGAINST(? IN NATURAL LANGUAGE MODE), 0) + ) AS relevance + FROM project_vectors + WHERE project_name LIKE ? OR MATCH(project_name, project_desc) 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(权限过滤) + * @param int $limit 返回数量 + * @return array 搜索结果 + */ + public static function projectVectorSearch(array $queryVector, int $userid = 0, int $limit = 20): array + { + if (empty($queryVector)) { + return []; + } + + $instance = new self(); + $vectorStr = '[' . implode(',', $queryVector) . ']'; + + if ($userid > 0) { + $sql = " + SELECT DISTINCT + pv.project_id, + pv.userid, + pv.personal, + pv.project_name, + SUBSTRING(pv.project_desc, 1, 300) as project_desc_preview, + COSINE_SIMILARITY(pv.content_vector, ?) AS similarity + FROM project_vectors pv + JOIN project_users pu ON pv.project_id = pu.project_id + WHERE pv.content_vector IS NOT NULL AND pu.userid = ? + ORDER BY similarity DESC + LIMIT " . (int)$limit; + + $params = [$vectorStr, $userid]; + } else { + $sql = " + SELECT + project_id, + userid, + personal, + project_name, + SUBSTRING(project_desc, 1, 300) as project_desc_preview, + COSINE_SIMILARITY(content_vector, ?) AS similarity + FROM project_vectors + WHERE content_vector IS NOT NULL + ORDER BY similarity DESC + LIMIT " . (int)$limit; + + $params = [$vectorStr]; + } + + return $instance->query($sql, $params); + } + + /** + * 项目混合搜索 + * + * @param string $keyword 关键词 + * @param array $queryVector 查询向量 + * @param int $userid 用户ID(权限过滤) + * @param int $limit 返回数量 + * @return array 搜索结果 + */ + public static function projectHybridSearch(string $keyword, array $queryVector, int $userid = 0, int $limit = 20): array + { + $textResults = self::projectFullTextSearch($keyword, $userid, 50, 0); + $vectorResults = !empty($queryVector) ? self::projectVectorSearch($queryVector, $userid, 50) : []; + + // RRF 融合 + $scores = []; + $items = []; + $k = 60; + + foreach ($textResults as $rank => $item) { + $id = $item['project_id']; + $scores[$id] = ($scores[$id] ?? 0) + 0.5 / ($k + $rank + 1); + $items[$id] = $item; + } + + foreach ($vectorResults as $rank => $item) { + $id = $item['project_id']; + $scores[$id] = ($scores[$id] ?? 0) + 0.5 / ($k + $rank + 1); + if (!isset($items[$id])) { + $items[$id] = $item; + } + } + + arsort($scores); + + $results = []; + $count = 0; + foreach ($scores as $id => $score) { + if ($count >= $limit) break; + $item = $items[$id]; + $item['rrf_score'] = $score; + $results[] = $item; + $count++; + } + + return $results; + } + + /** + * 插入或更新项目向量 + * + * @param array $data 项目数据 + * @return bool 是否成功 + */ + public static function upsertProjectVector(array $data): bool + { + $instance = new self(); + + $projectId = $data['project_id'] ?? 0; + if ($projectId <= 0) { + return false; + } + + $existing = $instance->queryOne("SELECT id FROM project_vectors WHERE project_id = ?", [$projectId]); + + if ($existing) { + $sql = "UPDATE project_vectors SET + userid = ?, + personal = ?, + project_name = ?, + project_desc = ?, + content_vector = ?, + updated_at = NOW() + WHERE project_id = ?"; + + $params = [ + $data['userid'] ?? 0, + $data['personal'] ?? 0, + $data['project_name'] ?? '', + $data['project_desc'] ?? '', + $data['content_vector'] ?? null, + $projectId + ]; + } else { + $sql = "INSERT INTO project_vectors + (project_id, userid, personal, project_name, project_desc, content_vector, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())"; + + $params = [ + $projectId, + $data['userid'] ?? 0, + $data['personal'] ?? 0, + $data['project_name'] ?? '', + $data['project_desc'] ?? '', + $data['content_vector'] ?? null + ]; + } + + return $instance->execute($sql, $params); + } + + /** + * 删除项目向量 + * + * @param int $projectId 项目ID + * @return bool 是否成功 + */ + public static function deleteProjectVector(int $projectId): bool + { + if ($projectId <= 0) { + return false; + } + + $instance = new self(); + return $instance->execute("DELETE FROM project_vectors WHERE project_id = ?", [$projectId]); + } + + /** + * 清空所有项目向量 + * + * @return bool 是否成功 + */ + public static function clearAllProjectVectors(): bool + { + $instance = new self(); + return $instance->execute("TRUNCATE TABLE project_vectors"); + } + + /** + * 获取已索引的项目数量 + * + * @return int 项目数量 + */ + public static function getIndexedProjectCount(): int + { + $instance = new self(); + $result = $instance->queryOne("SELECT COUNT(*) as cnt FROM project_vectors"); + return $result ? (int) $result['cnt'] : 0; + } + + // ============================== + // 项目成员关系方法 + // ============================== + + /** + * 插入或更新项目成员关系 + * + * @param int $projectId 项目ID + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function upsertProjectUser(int $projectId, int $userid): bool + { + if ($projectId <= 0 || $userid <= 0) { + return false; + } + + $instance = new self(); + + $existing = $instance->queryOne( + "SELECT id FROM project_users WHERE project_id = ? AND userid = ?", + [$projectId, $userid] + ); + + if ($existing) { + return true; // 已存在 + } + + return $instance->execute( + "INSERT INTO project_users (project_id, userid) VALUES (?, ?)", + [$projectId, $userid] + ); + } + + /** + * 删除项目成员关系 + * + * @param int $projectId 项目ID + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function deleteProjectUser(int $projectId, int $userid): bool + { + if ($projectId <= 0 || $userid <= 0) { + return false; + } + + $instance = new self(); + return $instance->execute( + "DELETE FROM project_users WHERE project_id = ? AND userid = ?", + [$projectId, $userid] + ); + } + + /** + * 删除项目的所有成员关系 + * + * @param int $projectId 项目ID + * @return bool 是否成功 + */ + public static function deleteAllProjectUsers(int $projectId): bool + { + if ($projectId <= 0) { + return false; + } + + $instance = new self(); + return $instance->execute("DELETE FROM project_users WHERE project_id = ?", [$projectId]); + } + + /** + * 批量同步项目成员关系 + * + * @param int $projectId 项目ID + * @param array $userids 用户ID列表 + * @return bool 是否成功 + */ + public static function syncProjectUsers(int $projectId, array $userids): bool + { + if ($projectId <= 0) { + return false; + } + + $instance = new self(); + + try { + $instance->execute("DELETE FROM project_users WHERE project_id = ?", [$projectId]); + + foreach ($userids as $userid) { + $instance->execute( + "INSERT INTO project_users (project_id, userid) VALUES (?, ?)", + [$projectId, (int)$userid] + ); + } + + return true; + } catch (\Exception $e) { + Log::error('SeekDB syncProjectUsers error: ' . $e->getMessage()); + return false; + } + } + + /** + * 清空所有项目成员关系 + * + * @return bool 是否成功 + */ + public static function clearAllProjectUsers(): bool + { + $instance = new self(); + return $instance->execute("TRUNCATE TABLE project_users"); + } + + /** + * 获取项目成员关系数量 + * + * @return int 关系数量 + */ + public static function getProjectUserCount(): int + { + $instance = new self(); + $result = $instance->queryOne("SELECT COUNT(*) as cnt FROM project_users"); + return $result ? (int) $result['cnt'] : 0; + } + + // ============================== + // 任务向量方法 + // ============================== + + /** + * 任务全文搜索 + * + * @param string $keyword 关键词 + * @param int $userid 用户ID(权限过滤) + * @param int $limit 返回数量 + * @param int $offset 偏移量 + * @return array 搜索结果 + */ + public static function taskFullTextSearch(string $keyword, int $userid = 0, int $limit = 20, int $offset = 0): array + { + if (empty($keyword)) { + return []; + } + + $instance = new self(); + $likeKeyword = "%{$keyword}%"; + + if ($userid > 0) { + // 复杂权限过滤: + // 1. 自己创建的任务 + // 2. visibility=1 且是项目成员 + // 3. visibility=2,3 且是任务成员 + $sql = " + SELECT DISTINCT + tv.task_id, + tv.project_id, + tv.userid, + tv.visibility, + tv.task_name, + SUBSTRING(tv.task_desc, 1, 300) as task_desc_preview, + SUBSTRING(tv.task_content, 1, 500) as task_content_preview, + ( + CASE WHEN tv.task_name LIKE ? THEN 10 ELSE 0 END + + IFNULL(MATCH(tv.task_name, tv.task_desc, tv.task_content) AGAINST(? IN NATURAL LANGUAGE MODE), 0) + ) AS relevance + FROM task_vectors tv + LEFT JOIN project_users pu ON tv.project_id = pu.project_id AND pu.userid = ? + LEFT JOIN task_users tu ON tv.task_id = tu.task_id AND tu.userid = ? + WHERE (tv.task_name LIKE ? OR MATCH(tv.task_name, tv.task_desc, tv.task_content) AGAINST(? IN NATURAL LANGUAGE MODE)) + AND ( + tv.userid = ? + OR (tv.visibility = 1 AND pu.userid IS NOT NULL) + OR (tv.visibility IN (2, 3) AND tu.userid IS NOT NULL) + ) + ORDER BY relevance DESC + LIMIT " . (int)$limit . " OFFSET " . (int)$offset; + + $params = [$likeKeyword, $keyword, $userid, $userid, $likeKeyword, $keyword, $userid]; + } else { + // 不限制权限 + $sql = " + SELECT + task_id, + project_id, + userid, + visibility, + task_name, + SUBSTRING(task_desc, 1, 300) as task_desc_preview, + SUBSTRING(task_content, 1, 500) as task_content_preview, + ( + CASE WHEN task_name LIKE ? THEN 10 ELSE 0 END + + IFNULL(MATCH(task_name, task_desc, task_content) AGAINST(? IN NATURAL LANGUAGE MODE), 0) + ) AS relevance + FROM task_vectors + WHERE task_name LIKE ? OR MATCH(task_name, task_desc, task_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(权限过滤) + * @param int $limit 返回数量 + * @return array 搜索结果 + */ + public static function taskVectorSearch(array $queryVector, int $userid = 0, int $limit = 20): array + { + if (empty($queryVector)) { + return []; + } + + $instance = new self(); + $vectorStr = '[' . implode(',', $queryVector) . ']'; + + if ($userid > 0) { + $sql = " + SELECT DISTINCT + tv.task_id, + tv.project_id, + tv.userid, + tv.visibility, + tv.task_name, + SUBSTRING(tv.task_desc, 1, 300) as task_desc_preview, + SUBSTRING(tv.task_content, 1, 500) as task_content_preview, + COSINE_SIMILARITY(tv.content_vector, ?) AS similarity + FROM task_vectors tv + LEFT JOIN project_users pu ON tv.project_id = pu.project_id AND pu.userid = ? + LEFT JOIN task_users tu ON tv.task_id = tu.task_id AND tu.userid = ? + WHERE tv.content_vector IS NOT NULL + AND ( + tv.userid = ? + OR (tv.visibility = 1 AND pu.userid IS NOT NULL) + OR (tv.visibility IN (2, 3) AND tu.userid IS NOT NULL) + ) + ORDER BY similarity DESC + LIMIT " . (int)$limit; + + $params = [$vectorStr, $userid, $userid, $userid]; + } else { + $sql = " + SELECT + task_id, + project_id, + userid, + visibility, + task_name, + SUBSTRING(task_desc, 1, 300) as task_desc_preview, + SUBSTRING(task_content, 1, 500) as task_content_preview, + COSINE_SIMILARITY(content_vector, ?) AS similarity + FROM task_vectors + WHERE content_vector IS NOT NULL + ORDER BY similarity DESC + LIMIT " . (int)$limit; + + $params = [$vectorStr]; + } + + return $instance->query($sql, $params); + } + + /** + * 任务混合搜索 + * + * @param string $keyword 关键词 + * @param array $queryVector 查询向量 + * @param int $userid 用户ID(权限过滤) + * @param int $limit 返回数量 + * @return array 搜索结果 + */ + public static function taskHybridSearch(string $keyword, array $queryVector, int $userid = 0, int $limit = 20): array + { + $textResults = self::taskFullTextSearch($keyword, $userid, 50, 0); + $vectorResults = !empty($queryVector) ? self::taskVectorSearch($queryVector, $userid, 50) : []; + + // RRF 融合 + $scores = []; + $items = []; + $k = 60; + + foreach ($textResults as $rank => $item) { + $id = $item['task_id']; + $scores[$id] = ($scores[$id] ?? 0) + 0.5 / ($k + $rank + 1); + $items[$id] = $item; + } + + foreach ($vectorResults as $rank => $item) { + $id = $item['task_id']; + $scores[$id] = ($scores[$id] ?? 0) + 0.5 / ($k + $rank + 1); + if (!isset($items[$id])) { + $items[$id] = $item; + } + } + + arsort($scores); + + $results = []; + $count = 0; + foreach ($scores as $id => $score) { + if ($count >= $limit) break; + $item = $items[$id]; + $item['rrf_score'] = $score; + $results[] = $item; + $count++; + } + + return $results; + } + + /** + * 插入或更新任务向量 + * + * @param array $data 任务数据 + * @return bool 是否成功 + */ + public static function upsertTaskVector(array $data): bool + { + $instance = new self(); + + $taskId = $data['task_id'] ?? 0; + if ($taskId <= 0) { + return false; + } + + $existing = $instance->queryOne("SELECT id FROM task_vectors WHERE task_id = ?", [$taskId]); + + if ($existing) { + $sql = "UPDATE task_vectors SET + project_id = ?, + userid = ?, + visibility = ?, + task_name = ?, + task_desc = ?, + task_content = ?, + content_vector = ?, + updated_at = NOW() + WHERE task_id = ?"; + + $params = [ + $data['project_id'] ?? 0, + $data['userid'] ?? 0, + $data['visibility'] ?? 1, + $data['task_name'] ?? '', + $data['task_desc'] ?? '', + $data['task_content'] ?? '', + $data['content_vector'] ?? null, + $taskId + ]; + } else { + $sql = "INSERT INTO task_vectors + (task_id, project_id, userid, visibility, task_name, task_desc, task_content, content_vector, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())"; + + $params = [ + $taskId, + $data['project_id'] ?? 0, + $data['userid'] ?? 0, + $data['visibility'] ?? 1, + $data['task_name'] ?? '', + $data['task_desc'] ?? '', + $data['task_content'] ?? '', + $data['content_vector'] ?? null + ]; + } + + return $instance->execute($sql, $params); + } + + /** + * 更新任务可见性 + * + * @param int $taskId 任务ID + * @param int $visibility 可见性 + * @return bool 是否成功 + */ + public static function updateTaskVisibility(int $taskId, int $visibility): bool + { + if ($taskId <= 0) { + return false; + } + + $instance = new self(); + return $instance->execute( + "UPDATE task_vectors SET visibility = ?, updated_at = NOW() WHERE task_id = ?", + [$visibility, $taskId] + ); + } + + /** + * 删除任务向量 + * + * @param int $taskId 任务ID + * @return bool 是否成功 + */ + public static function deleteTaskVector(int $taskId): bool + { + if ($taskId <= 0) { + return false; + } + + $instance = new self(); + return $instance->execute("DELETE FROM task_vectors WHERE task_id = ?", [$taskId]); + } + + /** + * 清空所有任务向量 + * + * @return bool 是否成功 + */ + public static function clearAllTaskVectors(): bool + { + $instance = new self(); + return $instance->execute("TRUNCATE TABLE task_vectors"); + } + + /** + * 获取已索引的任务数量 + * + * @return int 任务数量 + */ + public static function getIndexedTaskCount(): int + { + $instance = new self(); + $result = $instance->queryOne("SELECT COUNT(*) as cnt FROM task_vectors"); + return $result ? (int) $result['cnt'] : 0; + } + + // ============================== + // 任务成员关系方法 + // ============================== + + /** + * 插入或更新任务成员关系 + * + * @param int $taskId 任务ID + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function upsertTaskUser(int $taskId, int $userid): bool + { + if ($taskId <= 0 || $userid <= 0) { + return false; + } + + $instance = new self(); + + $existing = $instance->queryOne( + "SELECT id FROM task_users WHERE task_id = ? AND userid = ?", + [$taskId, $userid] + ); + + if ($existing) { + return true; + } + + return $instance->execute( + "INSERT INTO task_users (task_id, userid) VALUES (?, ?)", + [$taskId, $userid] + ); + } + + /** + * 删除任务成员关系 + * + * @param int $taskId 任务ID + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function deleteTaskUser(int $taskId, int $userid): bool + { + if ($taskId <= 0 || $userid <= 0) { + return false; + } + + $instance = new self(); + return $instance->execute( + "DELETE FROM task_users WHERE task_id = ? AND userid = ?", + [$taskId, $userid] + ); + } + + /** + * 删除任务的所有成员关系 + * + * @param int $taskId 任务ID + * @return bool 是否成功 + */ + public static function deleteAllTaskUsers(int $taskId): bool + { + if ($taskId <= 0) { + return false; + } + + $instance = new self(); + return $instance->execute("DELETE FROM task_users WHERE task_id = ?", [$taskId]); + } + + /** + * 批量同步任务成员关系 + * + * @param int $taskId 任务ID + * @param array $userids 用户ID列表 + * @return bool 是否成功 + */ + public static function syncTaskUsers(int $taskId, array $userids): bool + { + if ($taskId <= 0) { + return false; + } + + $instance = new self(); + + try { + $instance->execute("DELETE FROM task_users WHERE task_id = ?", [$taskId]); + + foreach ($userids as $userid) { + $instance->execute( + "INSERT INTO task_users (task_id, userid) VALUES (?, ?)", + [$taskId, (int)$userid] + ); + } + + return true; + } catch (\Exception $e) { + Log::error('SeekDB syncTaskUsers error: ' . $e->getMessage()); + return false; + } + } + + /** + * 清空所有任务成员关系 + * + * @return bool 是否成功 + */ + public static function clearAllTaskUsers(): bool + { + $instance = new self(); + return $instance->execute("TRUNCATE TABLE task_users"); + } + + /** + * 获取任务成员关系数量 + * + * @return int 关系数量 + */ + public static function getTaskUserCount(): int + { + $instance = new self(); + $result = $instance->queryOne("SELECT COUNT(*) as cnt FROM task_users"); + return $result ? (int) $result['cnt'] : 0; + } } diff --git a/app/Module/SeekDB/SeekDBProject.php b/app/Module/SeekDB/SeekDBProject.php new file mode 100644 index 000000000..39230f515 --- /dev/null +++ b/app/Module/SeekDB/SeekDBProject.php @@ -0,0 +1,384 @@ +getMessage()); + return []; + } + } + + /** + * 获取文本的 Embedding 向量 + * + * @param string $text 文本 + * @return array 向量数组(空数组表示失败) + */ + private static function getEmbedding(string $text): array + { + if (empty($text)) { + return []; + } + + try { + $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[] = [ + 'project_id' => $item['project_id'], + 'id' => $item['project_id'], + 'userid' => $item['userid'], + 'personal' => $item['personal'], + 'name' => $item['project_name'], + 'desc_preview' => $item['project_desc_preview'] ?? null, + 'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0, + ]; + } + return $formatted; + } + + // ============================== + // 同步方法 + // ============================== + + /** + * 同步单个项目到 SeekDB + * + * @param Project $project 项目模型 + * @return bool 是否成功 + */ + public static function sync(Project $project): bool + { + if (!Apps::isInstalled("seekdb")) { + return false; + } + + // 已归档的项目不索引 + if ($project->archived_at) { + return self::delete($project->id); + } + + try { + // 构建用于搜索的文本内容 + $searchableContent = self::buildSearchableContent($project); + + // 获取 embedding(如果 AI 可用) + $embedding = null; + if (!empty($searchableContent) && Apps::isInstalled('ai')) { + $embeddingResult = self::getEmbedding($searchableContent); + if (!empty($embeddingResult)) { + $embedding = '[' . implode(',', $embeddingResult) . ']'; + } + } + + // 写入 SeekDB + $result = SeekDBBase::upsertProjectVector([ + 'project_id' => $project->id, + 'userid' => $project->userid ?? 0, + 'personal' => $project->personal ?? 0, + 'project_name' => $project->name ?? '', + 'project_desc' => $project->desc ?? '', + 'content_vector' => $embedding, + ]); + + return $result; + } catch (\Exception $e) { + Log::error('SeekDB project sync error: ' . $e->getMessage(), [ + 'project_id' => $project->id, + 'project_name' => $project->name, + ]); + return false; + } + } + + /** + * 构建可搜索的文本内容 + * + * @param Project $project 项目模型 + * @return string 可搜索的文本 + */ + private static function buildSearchableContent(Project $project): string + { + $parts = []; + + if (!empty($project->name)) { + $parts[] = $project->name; + } + if (!empty($project->desc)) { + $parts[] = $project->desc; + } + + return implode(' ', $parts); + } + + /** + * 批量同步项目 + * + * @param iterable $projects 项目列表 + * @return int 成功同步的数量 + */ + public static function batchSync(iterable $projects): int + { + if (!Apps::isInstalled("seekdb")) { + return 0; + } + + $count = 0; + foreach ($projects as $project) { + if (self::sync($project)) { + $count++; + } + } + return $count; + } + + /** + * 删除项目索引 + * + * @param int $projectId 项目ID + * @return bool 是否成功 + */ + public static function delete(int $projectId): bool + { + if (!Apps::isInstalled("seekdb")) { + return false; + } + + // 删除项目索引 + SeekDBBase::deleteProjectVector($projectId); + // 删除项目成员关系 + SeekDBBase::deleteAllProjectUsers($projectId); + + return true; + } + + /** + * 清空所有索引 + * + * @return bool 是否成功 + */ + public static function clear(): bool + { + if (!Apps::isInstalled("seekdb")) { + return false; + } + + SeekDBBase::clearAllProjectVectors(); + SeekDBBase::clearAllProjectUsers(); + + return true; + } + + /** + * 获取已索引项目数量 + * + * @return int 数量 + */ + public static function getIndexedCount(): int + { + if (!Apps::isInstalled("seekdb")) { + return 0; + } + + return SeekDBBase::getIndexedProjectCount(); + } + + // ============================== + // 成员关系方法 + // ============================== + + /** + * 添加项目成员到 SeekDB + * + * @param int $projectId 项目ID + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function addProjectUser(int $projectId, int $userid): bool + { + if (!Apps::isInstalled("seekdb") || $projectId <= 0 || $userid <= 0) { + return false; + } + + return SeekDBBase::upsertProjectUser($projectId, $userid); + } + + /** + * 删除项目成员 + * + * @param int $projectId 项目ID + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function removeProjectUser(int $projectId, int $userid): bool + { + if (!Apps::isInstalled("seekdb") || $projectId <= 0 || $userid <= 0) { + return false; + } + + return SeekDBBase::deleteProjectUser($projectId, $userid); + } + + /** + * 同步项目的所有成员到 SeekDB + * + * @param int $projectId 项目ID + * @return bool 是否成功 + */ + public static function syncProjectUsers(int $projectId): bool + { + if (!Apps::isInstalled("seekdb") || $projectId <= 0) { + return false; + } + + try { + // 从 MySQL 获取项目成员 + $userids = ProjectUser::where('project_id', $projectId) + ->pluck('userid') + ->toArray(); + + // 同步到 SeekDB + return SeekDBBase::syncProjectUsers($projectId, $userids); + } catch (\Exception $e) { + Log::error('SeekDB syncProjectUsers error: ' . $e->getMessage(), ['project_id' => $projectId]); + return false; + } + } + + /** + * 批量同步所有项目成员关系(全量同步) + * + * @param callable|null $progressCallback 进度回调 + * @return int 同步数量 + */ + public static function syncAllProjectUsers(?callable $progressCallback = null): int + { + if (!Apps::isInstalled("seekdb")) { + return 0; + } + + $count = 0; + $lastId = 0; + $batchSize = 1000; + + // 先清空 SeekDB 中的 project_users 表 + SeekDBBase::clearAllProjectUsers(); + + // 分批同步 + while (true) { + $records = ProjectUser::where('id', '>', $lastId) + ->orderBy('id') + ->limit($batchSize) + ->get(); + + if ($records->isEmpty()) { + break; + } + + foreach ($records as $record) { + SeekDBBase::upsertProjectUser($record->project_id, $record->userid); + $count++; + $lastId = $record->id; + } + + if ($progressCallback) { + $progressCallback($count); + } + } + + return $count; + } +} + diff --git a/app/Module/SeekDB/SeekDBTask.php b/app/Module/SeekDB/SeekDBTask.php new file mode 100644 index 000000000..c06113eb7 --- /dev/null +++ b/app/Module/SeekDB/SeekDBTask.php @@ -0,0 +1,571 @@ +getMessage()); + return []; + } + } + + /** + * 获取文本的 Embedding 向量 + * + * @param string $text 文本 + * @return array 向量数组(空数组表示失败) + */ + private static function getEmbedding(string $text): array + { + if (empty($text)) { + return []; + } + + try { + $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[] = [ + 'task_id' => $item['task_id'], + 'id' => $item['task_id'], + 'project_id' => $item['project_id'], + 'userid' => $item['userid'], + 'visibility' => $item['visibility'], + 'name' => $item['task_name'], + 'desc_preview' => $item['task_desc_preview'] ?? null, + 'content_preview' => $item['task_content_preview'] ?? null, + 'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0, + ]; + } + return $formatted; + } + + // ============================== + // 同步方法 + // ============================== + + /** + * 同步单个任务到 SeekDB + * + * @param ProjectTask $task 任务模型 + * @return bool 是否成功 + */ + public static function sync(ProjectTask $task): bool + { + if (!Apps::isInstalled("seekdb")) { + return false; + } + + // 已归档或已删除的任务不索引 + if ($task->archived_at || $task->deleted_at) { + return self::delete($task->id); + } + + try { + // 获取任务详细内容 + $taskContent = self::getTaskContent($task); + + // 构建用于搜索的文本内容 + $searchableContent = self::buildSearchableContent($task, $taskContent); + + // 获取 embedding(如果 AI 可用) + $embedding = null; + if (!empty($searchableContent) && Apps::isInstalled('ai')) { + $embeddingResult = self::getEmbedding($searchableContent); + if (!empty($embeddingResult)) { + $embedding = '[' . implode(',', $embeddingResult) . ']'; + } + } + + // 写入 SeekDB + $result = SeekDBBase::upsertTaskVector([ + 'task_id' => $task->id, + 'project_id' => $task->project_id ?? 0, + 'userid' => $task->userid ?? 0, + 'visibility' => $task->visibility ?? 1, + 'task_name' => $task->name ?? '', + 'task_desc' => $task->desc ?? '', + 'task_content' => $taskContent, + 'content_vector' => $embedding, + ]); + + return $result; + } catch (\Exception $e) { + Log::error('SeekDB task sync error: ' . $e->getMessage(), [ + 'task_id' => $task->id, + 'task_name' => $task->name, + ]); + return false; + } + } + + /** + * 获取任务详细内容 + * + * @param ProjectTask $task 任务模型 + * @return string 任务内容 + */ + private static function getTaskContent(ProjectTask $task): string + { + try { + $content = ProjectTaskContent::where('task_id', $task->id)->first(); + if (!$content) { + return ''; + } + + // 解析内容 + $contentData = Base::json2array($content->content); + $text = ''; + + // 提取文本内容(内容可能是 blocks 格式) + if (is_array($contentData)) { + $text = self::extractTextFromContent($contentData); + } elseif (is_string($contentData)) { + $text = $contentData; + } + + // 限制内容长度 + return mb_substr($text, 0, self::MAX_CONTENT_LENGTH); + } catch (\Exception $e) { + Log::warning('Get task content error: ' . $e->getMessage(), ['task_id' => $task->id]); + return ''; + } + } + + /** + * 从内容数组中提取文本 + * + * @param array $contentData 内容数据 + * @return string 提取的文本 + */ + private static function extractTextFromContent(array $contentData): string + { + $texts = []; + + // 处理 blocks 格式 + if (isset($contentData['blocks']) && is_array($contentData['blocks'])) { + foreach ($contentData['blocks'] as $block) { + if (isset($block['text'])) { + $texts[] = $block['text']; + } + if (isset($block['data']['text'])) { + $texts[] = $block['data']['text']; + } + } + } + + // 处理其他格式 + if (isset($contentData['text'])) { + $texts[] = $contentData['text']; + } + + return implode(' ', $texts); + } + + /** + * 构建可搜索的文本内容 + * + * @param ProjectTask $task 任务模型 + * @param string $taskContent 任务详细内容 + * @return string 可搜索的文本 + */ + private static function buildSearchableContent(ProjectTask $task, string $taskContent): string + { + $parts = []; + + if (!empty($task->name)) { + $parts[] = $task->name; + } + if (!empty($task->desc)) { + $parts[] = $task->desc; + } + if (!empty($taskContent)) { + $parts[] = $taskContent; + } + + return implode(' ', $parts); + } + + /** + * 批量同步任务 + * + * @param iterable $tasks 任务列表 + * @return int 成功同步的数量 + */ + public static function batchSync(iterable $tasks): int + { + if (!Apps::isInstalled("seekdb")) { + return 0; + } + + $count = 0; + foreach ($tasks as $task) { + if (self::sync($task)) { + $count++; + } + } + return $count; + } + + /** + * 删除任务索引 + * + * @param int $taskId 任务ID + * @return bool 是否成功 + */ + public static function delete(int $taskId): bool + { + if (!Apps::isInstalled("seekdb")) { + return false; + } + + // 删除任务索引 + SeekDBBase::deleteTaskVector($taskId); + // 删除任务成员关系 + SeekDBBase::deleteAllTaskUsers($taskId); + + return true; + } + + /** + * 更新任务可见性 + * + * @param int $taskId 任务ID + * @param int $visibility 可见性 + * @return bool 是否成功 + */ + public static function updateVisibility(int $taskId, int $visibility): bool + { + if (!Apps::isInstalled("seekdb") || $taskId <= 0) { + return false; + } + + return SeekDBBase::updateTaskVisibility($taskId, $visibility); + } + + /** + * 清空所有索引 + * + * @return bool 是否成功 + */ + public static function clear(): bool + { + if (!Apps::isInstalled("seekdb")) { + return false; + } + + SeekDBBase::clearAllTaskVectors(); + SeekDBBase::clearAllTaskUsers(); + + return true; + } + + /** + * 获取已索引任务数量 + * + * @return int 数量 + */ + public static function getIndexedCount(): int + { + if (!Apps::isInstalled("seekdb")) { + return 0; + } + + return SeekDBBase::getIndexedTaskCount(); + } + + // ============================== + // 成员关系方法 + // ============================== + + /** + * 添加任务成员到 SeekDB + * + * @param int $taskId 任务ID + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function addTaskUser(int $taskId, int $userid): bool + { + if (!Apps::isInstalled("seekdb") || $taskId <= 0 || $userid <= 0) { + return false; + } + + return SeekDBBase::upsertTaskUser($taskId, $userid); + } + + /** + * 删除任务成员 + * + * @param int $taskId 任务ID + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function removeTaskUser(int $taskId, int $userid): bool + { + if (!Apps::isInstalled("seekdb") || $taskId <= 0 || $userid <= 0) { + return false; + } + + return SeekDBBase::deleteTaskUser($taskId, $userid); + } + + /** + * 删除指定可见成员(visibility=3 场景) + * + * 特殊处理:需要检查该用户是否仍是任务的负责人/协作人 + * 如果是,则不应该从 task_users 中删除 + * + * @param int $taskId 任务ID + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function removeVisibilityUser(int $taskId, int $userid): bool + { + if (!Apps::isInstalled("seekdb") || $taskId <= 0 || $userid <= 0) { + return false; + } + + try { + // 检查用户是否仍是任务的负责人/协作人 + $isTaskMember = ProjectTaskUser::where('task_id', $taskId) + ->where('userid', $userid) + ->exists(); + + // 检查是否是父任务的成员(子任务场景) + $task = \App\Models\ProjectTask::find($taskId); + $isParentTaskMember = false; + if ($task && $task->parent_id > 0) { + $isParentTaskMember = ProjectTaskUser::where('task_id', $task->parent_id) + ->where('userid', $userid) + ->exists(); + } + + // 如果仍是任务成员,不删除 + if ($isTaskMember || $isParentTaskMember) { + return true; + } + + // 从 SeekDB 删除 + return SeekDBBase::deleteTaskUser($taskId, $userid); + } catch (\Exception $e) { + Log::error('SeekDB removeVisibilityUser error: ' . $e->getMessage(), [ + 'task_id' => $taskId, + 'userid' => $userid, + ]); + return false; + } + } + + /** + * 同步任务的所有成员到 SeekDB + * + * 包括:ProjectTaskUser 和 ProjectTaskVisibilityUser + * + * @param int $taskId 任务ID + * @return bool 是否成功 + */ + public static function syncTaskUsers(int $taskId): bool + { + if (!Apps::isInstalled("seekdb") || $taskId <= 0) { + return false; + } + + try { + // 获取任务成员(负责人/协作人) + $taskUserIds = ProjectTaskUser::where('task_id', $taskId) + ->orWhere('task_pid', $taskId) + ->pluck('userid') + ->toArray(); + + // 获取可见性指定成员 + $visibilityUserIds = ProjectTaskVisibilityUser::where('task_id', $taskId) + ->pluck('userid') + ->toArray(); + + // 合并去重 + $allUserIds = array_unique(array_merge($taskUserIds, $visibilityUserIds)); + + // 同步到 SeekDB + return SeekDBBase::syncTaskUsers($taskId, $allUserIds); + } catch (\Exception $e) { + Log::error('SeekDB syncTaskUsers error: ' . $e->getMessage(), ['task_id' => $taskId]); + return false; + } + } + + /** + * 批量同步所有任务成员关系(全量同步) + * + * @param callable|null $progressCallback 进度回调 + * @return int 同步数量 + */ + public static function syncAllTaskUsers(?callable $progressCallback = null): int + { + if (!Apps::isInstalled("seekdb")) { + return 0; + } + + $count = 0; + $lastId = 0; + $batchSize = 1000; + + // 先清空 SeekDB 中的 task_users 表 + SeekDBBase::clearAllTaskUsers(); + + // 同步 ProjectTaskUser + while (true) { + $records = ProjectTaskUser::where('id', '>', $lastId) + ->orderBy('id') + ->limit($batchSize) + ->get(); + + if ($records->isEmpty()) { + break; + } + + foreach ($records as $record) { + SeekDBBase::upsertTaskUser($record->task_id, $record->userid); + // 如果有父任务,也添加到父任务 + if ($record->task_pid) { + SeekDBBase::upsertTaskUser($record->task_pid, $record->userid); + } + $count++; + $lastId = $record->id; + } + + if ($progressCallback) { + $progressCallback($count); + } + } + + // 同步 ProjectTaskVisibilityUser + $lastId = 0; + while (true) { + $records = ProjectTaskVisibilityUser::where('id', '>', $lastId) + ->orderBy('id') + ->limit($batchSize) + ->get(); + + if ($records->isEmpty()) { + break; + } + + foreach ($records as $record) { + SeekDBBase::upsertTaskUser($record->task_id, $record->userid); + $count++; + $lastId = $record->id; + } + + if ($progressCallback) { + $progressCallback($count); + } + } + + return $count; + } +} + diff --git a/app/Module/SeekDB/SeekDBUser.php b/app/Module/SeekDB/SeekDBUser.php new file mode 100644 index 000000000..62fb4d52c --- /dev/null +++ b/app/Module/SeekDB/SeekDBUser.php @@ -0,0 +1,275 @@ +getMessage()); + return []; + } + } + + /** + * 获取文本的 Embedding 向量 + * + * @param string $text 文本 + * @return array 向量数组(空数组表示失败) + */ + private static function getEmbedding(string $text): array + { + if (empty($text)) { + return []; + } + + try { + $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[] = [ + 'userid' => $item['userid'], + 'nickname' => $item['nickname'], + 'email' => $item['email'], + 'tel' => $item['tel'], + 'profession' => $item['profession'], + 'introduction_preview' => $item['introduction_preview'] ?? null, + 'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0, + ]; + } + return $formatted; + } + + // ============================== + // 同步方法 + // ============================== + + /** + * 同步单个用户到 SeekDB + * + * @param User $user 用户模型 + * @return bool 是否成功 + */ + public static function sync(User $user): bool + { + if (!Apps::isInstalled("seekdb")) { + return false; + } + + // 不处理机器人账号 + if ($user->bot) { + return true; + } + + // 不处理已禁用的账号 + if ($user->disable_at) { + return self::delete($user->userid); + } + + try { + // 构建用于搜索的文本内容 + $searchableContent = self::buildSearchableContent($user); + + // 获取 embedding(如果 AI 可用) + $embedding = null; + if (!empty($searchableContent) && Apps::isInstalled('ai')) { + $embeddingResult = self::getEmbedding($searchableContent); + if (!empty($embeddingResult)) { + $embedding = '[' . implode(',', $embeddingResult) . ']'; + } + } + + // 写入 SeekDB + $result = SeekDBBase::upsertUserVector([ + 'userid' => $user->userid, + 'nickname' => $user->nickname ?? '', + 'email' => $user->email ?? '', + 'tel' => $user->tel ?? '', + 'profession' => $user->profession ?? '', + 'introduction' => $user->introduction ?? '', + 'content_vector' => $embedding, + ]); + + return $result; + } catch (\Exception $e) { + Log::error('SeekDB user sync error: ' . $e->getMessage(), [ + 'userid' => $user->userid, + 'nickname' => $user->nickname, + ]); + return false; + } + } + + /** + * 构建可搜索的文本内容 + * + * @param User $user 用户模型 + * @return string 可搜索的文本 + */ + private static function buildSearchableContent(User $user): string + { + $parts = []; + + if (!empty($user->nickname)) { + $parts[] = $user->nickname; + } + if (!empty($user->email)) { + $parts[] = $user->email; + } + if (!empty($user->profession)) { + $parts[] = $user->profession; + } + if (!empty($user->introduction)) { + $parts[] = $user->introduction; + } + + return implode(' ', $parts); + } + + /** + * 批量同步用户 + * + * @param iterable $users 用户列表 + * @return int 成功同步的数量 + */ + public static function batchSync(iterable $users): int + { + if (!Apps::isInstalled("seekdb")) { + return 0; + } + + $count = 0; + foreach ($users as $user) { + if (self::sync($user)) { + $count++; + } + } + return $count; + } + + /** + * 删除用户索引 + * + * @param int $userid 用户ID + * @return bool 是否成功 + */ + public static function delete(int $userid): bool + { + if (!Apps::isInstalled("seekdb")) { + return false; + } + + return SeekDBBase::deleteUserVector($userid); + } + + /** + * 清空所有索引 + * + * @return bool 是否成功 + */ + public static function clear(): bool + { + if (!Apps::isInstalled("seekdb")) { + return false; + } + + return SeekDBBase::clearAllUserVectors(); + } + + /** + * 获取已索引用户数量 + * + * @return int 数量 + */ + public static function getIndexedCount(): int + { + if (!Apps::isInstalled("seekdb")) { + return 0; + } + + return SeekDBBase::getIndexedUserCount(); + } +} + diff --git a/app/Observers/FileObserver.php b/app/Observers/FileObserver.php index 65a24905e..8290cb925 100644 --- a/app/Observers/FileObserver.php +++ b/app/Observers/FileObserver.php @@ -3,7 +3,7 @@ namespace App\Observers; use App\Models\File; -use App\Tasks\SeekDBFileSyncTask; +use App\Tasks\SeekDBSyncTask; class FileObserver extends AbstractObserver { @@ -19,7 +19,7 @@ class FileObserver extends AbstractObserver if ($file->type === 'folder') { return; } - self::taskDeliver(new SeekDBFileSyncTask('sync', $file->toArray())); + self::taskDeliver(new SeekDBSyncTask('file_sync', $file->toArray())); } /** @@ -41,7 +41,7 @@ class FileObserver extends AbstractObserver ->pluck('id') ->toArray(); if (!empty($childFileIds)) { - self::taskDeliver(new SeekDBFileSyncTask('update_pshare', [ + self::taskDeliver(new SeekDBSyncTask('file_pshare_update', [ 'file_ids' => $childFileIds, 'pshare' => $newPshare, ])); @@ -53,7 +53,7 @@ class FileObserver extends AbstractObserver if ($file->type === 'folder') { return; } - self::taskDeliver(new SeekDBFileSyncTask('sync', $file->toArray())); + self::taskDeliver(new SeekDBSyncTask('file_sync', $file->toArray())); } /** @@ -64,7 +64,7 @@ class FileObserver extends AbstractObserver */ public function deleted(File $file) { - self::taskDeliver(new SeekDBFileSyncTask('delete', $file->toArray())); + self::taskDeliver(new SeekDBSyncTask('file_delete', $file->toArray())); } /** @@ -79,7 +79,7 @@ class FileObserver extends AbstractObserver if ($file->type === 'folder') { return; } - self::taskDeliver(new SeekDBFileSyncTask('sync', $file->toArray())); + self::taskDeliver(new SeekDBSyncTask('file_sync', $file->toArray())); } /** @@ -90,7 +90,7 @@ class FileObserver extends AbstractObserver */ public function forceDeleted(File $file) { - self::taskDeliver(new SeekDBFileSyncTask('delete', $file->toArray())); + self::taskDeliver(new SeekDBSyncTask('file_delete', $file->toArray())); } } diff --git a/app/Observers/FileUserObserver.php b/app/Observers/FileUserObserver.php index 64f8e1837..7e89f246b 100644 --- a/app/Observers/FileUserObserver.php +++ b/app/Observers/FileUserObserver.php @@ -3,7 +3,7 @@ namespace App\Observers; use App\Models\FileUser; -use App\Tasks\SeekDBFileSyncTask; +use App\Tasks\SeekDBSyncTask; class FileUserObserver extends AbstractObserver { @@ -15,7 +15,7 @@ class FileUserObserver extends AbstractObserver */ public function created(FileUser $fileUser) { - self::taskDeliver(new SeekDBFileSyncTask('add_file_user', [ + self::taskDeliver(new SeekDBSyncTask('file_user_add', [ 'file_id' => $fileUser->file_id, 'userid' => $fileUser->userid, 'permission' => $fileUser->permission, @@ -30,7 +30,7 @@ class FileUserObserver extends AbstractObserver */ public function updated(FileUser $fileUser) { - self::taskDeliver(new SeekDBFileSyncTask('add_file_user', [ + self::taskDeliver(new SeekDBSyncTask('file_user_add', [ 'file_id' => $fileUser->file_id, 'userid' => $fileUser->userid, 'permission' => $fileUser->permission, @@ -45,7 +45,7 @@ class FileUserObserver extends AbstractObserver */ public function deleted(FileUser $fileUser) { - self::taskDeliver(new SeekDBFileSyncTask('remove_file_user', [ + self::taskDeliver(new SeekDBSyncTask('file_user_remove', [ 'file_id' => $fileUser->file_id, 'userid' => $fileUser->userid, ])); diff --git a/app/Observers/ProjectObserver.php b/app/Observers/ProjectObserver.php index 0d0b09591..668122518 100644 --- a/app/Observers/ProjectObserver.php +++ b/app/Observers/ProjectObserver.php @@ -5,8 +5,9 @@ namespace App\Observers; use App\Models\Deleted; use App\Models\Project; use App\Models\ProjectUser; +use App\Tasks\SeekDBSyncTask; -class ProjectObserver +class ProjectObserver extends AbstractObserver { /** * Handle the Project "created" event. @@ -16,7 +17,7 @@ class ProjectObserver */ public function created(Project $project) { - // + self::taskDeliver(new SeekDBSyncTask('project_sync', $project->toArray())); } /** @@ -35,6 +36,24 @@ class ProjectObserver Deleted::forget('project', $project->id, $userids); } } + + // 检查是否有搜索相关字段变化 + $searchableFields = ['name', 'desc', 'archived_at']; + $isDirty = false; + foreach ($searchableFields as $field) { + if ($project->isDirty($field)) { + $isDirty = true; + break; + } + } + + if ($isDirty) { + if ($project->archived_at) { + self::taskDeliver(new SeekDBSyncTask('project_delete', ['project_id' => $project->id])); + } else { + self::taskDeliver(new SeekDBSyncTask('project_sync', $project->toArray())); + } + } } /** @@ -46,6 +65,7 @@ class ProjectObserver public function deleted(Project $project) { Deleted::record('project', $project->id, $this->userids($project)); + self::taskDeliver(new SeekDBSyncTask('project_delete', ['project_id' => $project->id])); } /** @@ -57,6 +77,7 @@ class ProjectObserver public function restored(Project $project) { Deleted::forget('project', $project->id, $this->userids($project)); + self::taskDeliver(new SeekDBSyncTask('project_sync', $project->toArray())); } /** @@ -67,7 +88,7 @@ class ProjectObserver */ public function forceDeleted(Project $project) { - // + self::taskDeliver(new SeekDBSyncTask('project_delete', ['project_id' => $project->id])); } /** diff --git a/app/Observers/ProjectTaskObserver.php b/app/Observers/ProjectTaskObserver.php index f9b796335..fa77ac9ce 100644 --- a/app/Observers/ProjectTaskObserver.php +++ b/app/Observers/ProjectTaskObserver.php @@ -7,8 +7,9 @@ use App\Models\ProjectTask; use App\Models\ProjectTaskUser; use App\Models\ProjectTaskVisibilityUser; use App\Models\ProjectUser; +use App\Tasks\SeekDBSyncTask; -class ProjectTaskObserver +class ProjectTaskObserver extends AbstractObserver { /** * Handle the ProjectTask "created" event. @@ -18,7 +19,7 @@ class ProjectTaskObserver */ public function created(ProjectTask $projectTask) { - // + self::taskDeliver(new SeekDBSyncTask('task_sync', $projectTask->toArray())); } /** @@ -31,6 +32,11 @@ class ProjectTaskObserver { if ($projectTask->isDirty('visibility')) { self::visibilityUpdate($projectTask); + // 同步 visibility 变化到 SeekDB + self::taskDeliver(new SeekDBSyncTask('task_visibility_update', [ + 'task_id' => $projectTask->id, + 'visibility' => $projectTask->visibility, + ])); } if ($projectTask->isDirty('archived_at')) { if ($projectTask->archived_at) { @@ -39,6 +45,25 @@ class ProjectTaskObserver Deleted::forget('projectTask', $projectTask->id, self::userids($projectTask)); } } + + // 检查是否有搜索相关字段变化 + // project_id 变化时也需要同步(任务移动到其他项目) + $searchableFields = ['name', 'desc', 'archived_at', 'project_id']; + $isDirty = false; + foreach ($searchableFields as $field) { + if ($projectTask->isDirty($field)) { + $isDirty = true; + break; + } + } + + if ($isDirty) { + if ($projectTask->archived_at) { + self::taskDeliver(new SeekDBSyncTask('task_delete', ['task_id' => $projectTask->id])); + } else { + self::taskDeliver(new SeekDBSyncTask('task_sync', $projectTask->toArray())); + } + } } /** @@ -50,6 +75,7 @@ class ProjectTaskObserver public function deleted(ProjectTask $projectTask) { Deleted::record('projectTask', $projectTask->id, self::userids($projectTask)); + self::taskDeliver(new SeekDBSyncTask('task_delete', ['task_id' => $projectTask->id])); } /** @@ -61,6 +87,7 @@ class ProjectTaskObserver public function restored(ProjectTask $projectTask) { Deleted::forget('projectTask', $projectTask->id, self::userids($projectTask)); + self::taskDeliver(new SeekDBSyncTask('task_sync', $projectTask->toArray())); } /** @@ -71,7 +98,7 @@ class ProjectTaskObserver */ public function forceDeleted(ProjectTask $projectTask) { - // + self::taskDeliver(new SeekDBSyncTask('task_delete', ['task_id' => $projectTask->id])); } /** diff --git a/app/Observers/ProjectTaskUserObserver.php b/app/Observers/ProjectTaskUserObserver.php index 33288a78c..d80455cc7 100644 --- a/app/Observers/ProjectTaskUserObserver.php +++ b/app/Observers/ProjectTaskUserObserver.php @@ -5,8 +5,9 @@ namespace App\Observers; use App\Models\Deleted; use App\Models\ProjectTaskUser; use App\Models\ProjectUser; +use App\Tasks\SeekDBSyncTask; -class ProjectTaskUserObserver +class ProjectTaskUserObserver extends AbstractObserver { /** * Handle the ProjectTaskUser "created" event. @@ -20,6 +21,19 @@ class ProjectTaskUserObserver if ($projectTaskUser->task_pid) { Deleted::forget('projectTask', $projectTaskUser->task_pid, $projectTaskUser->userid); } + + // 同步任务成员到 SeekDB + self::taskDeliver(new SeekDBSyncTask('task_user_add', [ + 'task_id' => $projectTaskUser->task_id, + 'userid' => $projectTaskUser->userid, + ])); + // 如果是子任务,同时添加到父任务 + if ($projectTaskUser->task_pid) { + self::taskDeliver(new SeekDBSyncTask('task_user_add', [ + 'task_id' => $projectTaskUser->task_pid, + 'userid' => $projectTaskUser->userid, + ])); + } } /** @@ -44,6 +58,12 @@ class ProjectTaskUserObserver if (!ProjectUser::whereProjectId($projectTaskUser->project_id)->whereUserid($projectTaskUser->userid)->exists()) { Deleted::record('projectTask', $projectTaskUser->task_id, $projectTaskUser->userid); } + + // 从 SeekDB 删除任务成员关系 + self::taskDeliver(new SeekDBSyncTask('task_user_remove', [ + 'task_id' => $projectTaskUser->task_id, + 'userid' => $projectTaskUser->userid, + ])); } /** diff --git a/app/Observers/ProjectTaskVisibilityUserObserver.php b/app/Observers/ProjectTaskVisibilityUserObserver.php new file mode 100644 index 000000000..a8ade67b3 --- /dev/null +++ b/app/Observers/ProjectTaskVisibilityUserObserver.php @@ -0,0 +1,84 @@ + $visibilityUser->task_id, + 'userid' => $visibilityUser->userid, + ])); + } + + /** + * Handle the ProjectTaskVisibilityUser "updated" event. + * + * @param \App\Models\ProjectTaskVisibilityUser $visibilityUser + * @return void + */ + public function updated(ProjectTaskVisibilityUser $visibilityUser) + { + // 通常不会更新,但如果更新了也同步 + self::taskDeliver(new SeekDBSyncTask('task_user_add', [ + 'task_id' => $visibilityUser->task_id, + 'userid' => $visibilityUser->userid, + ])); + } + + /** + * Handle the ProjectTaskVisibilityUser "deleted" event. + * + * @param \App\Models\ProjectTaskVisibilityUser $visibilityUser + * @return void + */ + public function deleted(ProjectTaskVisibilityUser $visibilityUser) + { + // 从 SeekDB 的 task_users 表删除该成员 + // 注意:需要检查该用户是否仍是任务的负责人/协作人 + // 如果是,则不应该删除(因为 ProjectTaskUser 仍存在) + self::taskDeliver(new SeekDBSyncTask('task_visibility_user_remove', [ + 'task_id' => $visibilityUser->task_id, + 'userid' => $visibilityUser->userid, + ])); + } + + /** + * Handle the ProjectTaskVisibilityUser "restored" event. + * + * @param \App\Models\ProjectTaskVisibilityUser $visibilityUser + * @return void + */ + public function restored(ProjectTaskVisibilityUser $visibilityUser) + { + // + } + + /** + * Handle the ProjectTaskVisibilityUser "force deleted" event. + * + * @param \App\Models\ProjectTaskVisibilityUser $visibilityUser + * @return void + */ + public function forceDeleted(ProjectTaskVisibilityUser $visibilityUser) + { + // + } +} + diff --git a/app/Observers/ProjectUserObserver.php b/app/Observers/ProjectUserObserver.php index cda58232d..2ddb561bd 100644 --- a/app/Observers/ProjectUserObserver.php +++ b/app/Observers/ProjectUserObserver.php @@ -4,8 +4,9 @@ namespace App\Observers; use App\Models\Deleted; use App\Models\ProjectUser; +use App\Tasks\SeekDBSyncTask; -class ProjectUserObserver +class ProjectUserObserver extends AbstractObserver { /** * Handle the ProjectUser "created" event. @@ -16,6 +17,10 @@ class ProjectUserObserver public function created(ProjectUser $projectUser) { Deleted::forget('project', $projectUser->project_id, $projectUser->userid); + self::taskDeliver(new SeekDBSyncTask('project_user_add', [ + 'project_id' => $projectUser->project_id, + 'userid' => $projectUser->userid, + ])); } /** @@ -38,6 +43,10 @@ class ProjectUserObserver public function deleted(ProjectUser $projectUser) { Deleted::record('project', $projectUser->project_id, $projectUser->userid); + self::taskDeliver(new SeekDBSyncTask('project_user_remove', [ + 'project_id' => $projectUser->project_id, + 'userid' => $projectUser->userid, + ])); } /** diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php new file mode 100644 index 000000000..17a8d1b37 --- /dev/null +++ b/app/Observers/UserObserver.php @@ -0,0 +1,69 @@ +bot) { + return; + } + self::taskDeliver(new SeekDBSyncTask('user_sync', $user->toArray())); + } + + /** + * Handle the User "updated" event. + * + * @param \App\Models\User $user + * @return void + */ + public function updated(User $user) + { + // 机器人账号不同步 + if ($user->bot) { + return; + } + + // 检查是否有搜索相关字段变化 + $searchableFields = ['nickname', 'email', 'tel', 'profession', 'introduction', 'disable_at']; + $isDirty = false; + foreach ($searchableFields as $field) { + if ($user->isDirty($field)) { + $isDirty = true; + break; + } + } + + if ($isDirty) { + // 如果用户被禁用,删除索引;否则更新索引 + if ($user->disable_at) { + self::taskDeliver(new SeekDBSyncTask('user_delete', ['userid' => $user->userid])); + } else { + self::taskDeliver(new SeekDBSyncTask('user_sync', $user->toArray())); + } + } + } + + /** + * Handle the User "deleted" event. + * + * @param \App\Models\User $user + * @return void + */ + public function deleted(User $user) + { + self::taskDeliver(new SeekDBSyncTask('user_delete', ['userid' => $user->userid])); + } +} + diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 9675b5085..d006122f9 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -7,7 +7,9 @@ use App\Models\FileUser; use App\Models\Project; use App\Models\ProjectTask; use App\Models\ProjectTaskUser; +use App\Models\ProjectTaskVisibilityUser; use App\Models\ProjectUser; +use App\Models\User; use App\Models\WebSocketDialog; use App\Models\WebSocketDialogMsg; use App\Models\WebSocketDialogUser; @@ -16,7 +18,9 @@ use App\Observers\FileUserObserver; use App\Observers\ProjectObserver; use App\Observers\ProjectTaskObserver; use App\Observers\ProjectTaskUserObserver; +use App\Observers\ProjectTaskVisibilityUserObserver; use App\Observers\ProjectUserObserver; +use App\Observers\UserObserver; use App\Observers\WebSocketDialogMsgObserver; use App\Observers\WebSocketDialogObserver; use App\Observers\WebSocketDialogUserObserver; @@ -49,7 +53,9 @@ class EventServiceProvider extends ServiceProvider Project::observe(ProjectObserver::class); ProjectTask::observe(ProjectTaskObserver::class); ProjectTaskUser::observe(ProjectTaskUserObserver::class); + ProjectTaskVisibilityUser::observe(ProjectTaskVisibilityUserObserver::class); ProjectUser::observe(ProjectUserObserver::class); + User::observe(UserObserver::class); WebSocketDialog::observe(WebSocketDialogObserver::class); WebSocketDialogMsg::observe(WebSocketDialogMsgObserver::class); WebSocketDialogUser::observe(WebSocketDialogUserObserver::class); diff --git a/app/Tasks/SeekDBFileSyncTask.php b/app/Tasks/SeekDBFileSyncTask.php deleted file mode 100644 index e9414c138..000000000 --- a/app/Tasks/SeekDBFileSyncTask.php +++ /dev/null @@ -1,120 +0,0 @@ -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/app/Tasks/SeekDBSyncTask.php b/app/Tasks/SeekDBSyncTask.php new file mode 100644 index 000000000..b3e43b9f5 --- /dev/null +++ b/app/Tasks/SeekDBSyncTask.php @@ -0,0 +1,242 @@ +action = $action; + $this->data = $data; + } + + public function start() + { + if (!Apps::isInstalled("seekdb")) { + return; + } + + switch ($this->action) { + // ============================== + // 文件同步动作 + // ============================== + case 'file_sync': + $file = File::find($this->data['id'] ?? 0); + if ($file) { + SeekDBFile::sync($file); + } + break; + + case 'file_delete': + $fileId = $this->data['id'] ?? 0; + if ($fileId > 0) { + SeekDBFile::delete($fileId); + } + break; + + case 'file_user_sync': + $fileId = $this->data['file_id'] ?? 0; + if ($fileId > 0) { + SeekDBFile::syncFileUsers($fileId); + } + break; + + case 'file_user_add': + $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 'file_user_remove': + $fileId = $this->data['file_id'] ?? 0; + $userid = $this->data['userid'] ?? null; + if ($fileId > 0) { + SeekDBFile::removeFileUser($fileId, $userid); + } + break; + + case 'file_pshare_update': + $fileIds = $this->data['file_ids'] ?? []; + $pshare = $this->data['pshare'] ?? 0; + if (!empty($fileIds)) { + SeekDBBase::batchUpdatePshare($fileIds, $pshare); + } + break; + + // ============================== + // 用户同步动作 + // ============================== + case 'user_sync': + $user = User::find($this->data['userid'] ?? 0); + if ($user) { + SeekDBUser::sync($user); + } + break; + + case 'user_delete': + $userid = $this->data['userid'] ?? 0; + if ($userid > 0) { + SeekDBUser::delete($userid); + } + break; + + // ============================== + // 项目同步动作 + // ============================== + case 'project_sync': + $project = Project::find($this->data['id'] ?? 0); + if ($project) { + SeekDBProject::sync($project); + } + break; + + case 'project_delete': + $projectId = $this->data['project_id'] ?? 0; + if ($projectId > 0) { + SeekDBProject::delete($projectId); + } + break; + + case 'project_user_add': + $projectId = $this->data['project_id'] ?? 0; + $userid = $this->data['userid'] ?? 0; + if ($projectId > 0 && $userid > 0) { + SeekDBProject::addProjectUser($projectId, $userid); + } + break; + + case 'project_user_remove': + $projectId = $this->data['project_id'] ?? 0; + $userid = $this->data['userid'] ?? 0; + if ($projectId > 0 && $userid > 0) { + SeekDBProject::removeProjectUser($projectId, $userid); + } + break; + + case 'project_users_sync': + $projectId = $this->data['project_id'] ?? 0; + if ($projectId > 0) { + SeekDBProject::syncProjectUsers($projectId); + } + break; + + // ============================== + // 任务同步动作 + // ============================== + case 'task_sync': + $task = ProjectTask::find($this->data['id'] ?? 0); + if ($task) { + SeekDBTask::sync($task); + } + break; + + case 'task_delete': + $taskId = $this->data['task_id'] ?? 0; + if ($taskId > 0) { + SeekDBTask::delete($taskId); + } + break; + + case 'task_visibility_update': + $taskId = $this->data['task_id'] ?? 0; + $visibility = $this->data['visibility'] ?? 1; + if ($taskId > 0) { + SeekDBTask::updateVisibility($taskId, $visibility); + } + break; + + case 'task_user_add': + $taskId = $this->data['task_id'] ?? 0; + $userid = $this->data['userid'] ?? 0; + if ($taskId > 0 && $userid > 0) { + SeekDBTask::addTaskUser($taskId, $userid); + } + break; + + case 'task_user_remove': + $taskId = $this->data['task_id'] ?? 0; + $userid = $this->data['userid'] ?? 0; + if ($taskId > 0 && $userid > 0) { + SeekDBTask::removeTaskUser($taskId, $userid); + } + break; + + case 'task_visibility_user_remove': + // 特殊处理:删除 visibility user 时需要检查是否仍是任务成员 + $taskId = $this->data['task_id'] ?? 0; + $userid = $this->data['userid'] ?? 0; + if ($taskId > 0 && $userid > 0) { + SeekDBTask::removeVisibilityUser($taskId, $userid); + } + break; + + case 'task_users_sync': + $taskId = $this->data['task_id'] ?? 0; + if ($taskId > 0) { + SeekDBTask::syncTaskUsers($taskId); + } + break; + + default: + // 增量更新(定时任务调用) + $this->incrementalUpdate(); + break; + } + } + + /** + * 增量更新(定时执行) + * @return void + */ + private function incrementalUpdate() + { + // 60分钟执行一次 + $time = intval(Cache::get("SeekDBSyncTask:Time")); + if (time() - $time < 60 * 60) { + return; + } + + // 执行开始 + Cache::put("SeekDBSyncTask:Time", time(), Carbon::now()->addMinutes(60)); + + // 执行同步命令(后台运行) + @shell_exec("php /var/www/artisan seekdb:sync-files --i 2>&1 &"); + @shell_exec("php /var/www/artisan seekdb:sync-users --i 2>&1 &"); + @shell_exec("php /var/www/artisan seekdb:sync-projects --i 2>&1 &"); + @shell_exec("php /var/www/artisan seekdb:sync-tasks --i 2>&1 &"); + + // 执行完成 + Cache::put("SeekDBSyncTask: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 4e634763d..e7e188e3c 100755 --- a/resources/assets/js/components/SearchBox.vue +++ b/resources/assets/js/components/SearchBox.vue @@ -347,7 +347,16 @@ export default { searchTask(key) { this.loadIng++; - this.$store.dispatch("call", { + // 如果开启了 AI 搜索,使用新的 AI 搜索接口 + const useAiSearch = this.aiSearchAvailable && this.aiSearch; + const requestConfig = useAiSearch ? { + url: 'search/task', + data: { + key, + search_type: 'hybrid', + take: this.action ? 50 : 10, + }, + } : { url: 'project/task/lists', data: { keys: {name: key}, @@ -355,10 +364,19 @@ export default { scope: 'all_project', pagesize: this.action ? 50 : 10, }, - }).then(({data}) => { + }; + this.$store.dispatch("call", requestConfig).then(({data}) => { const nowTime = $A.dayjs().unix() - const items = data.data.map(item => { + const rawData = useAiSearch ? data : data.data; + const items = rawData.map(item => { const tags = []; + // AI 搜索标记 + if (useAiSearch && item.content_preview) { + tags.push({ + name: 'AI', + style: 'background-color:#4F46E5', + }) + } if (item.complete_at) { tags.push({ name: this.$L('已完成'), @@ -389,7 +407,7 @@ export default { id: item.id, title: item.name, - desc: item.desc, + desc: item.content_preview ? this.truncateContent(item.content_preview) : item.desc, activity: item.end_at, rawData: item, @@ -403,7 +421,16 @@ export default { searchProject(key) { this.loadIng++; - this.$store.dispatch("call", { + // 如果开启了 AI 搜索,使用新的 AI 搜索接口 + const useAiSearch = this.aiSearchAvailable && this.aiSearch; + const requestConfig = useAiSearch ? { + url: 'search/project', + data: { + key, + search_type: 'hybrid', + take: this.action ? 50 : 10, + }, + } : { url: 'project/lists', data: { keys: { @@ -412,9 +439,18 @@ export default { archived: 'all', pagesize: this.action ? 50 : 10, }, - }).then(({data}) => { - const items = data.data.map(item => { + }; + this.$store.dispatch("call", requestConfig).then(({data}) => { + const rawData = useAiSearch ? data : data.data; + const items = rawData.map(item => { const tags = []; + // AI 搜索标记 + if (useAiSearch && item.desc_preview) { + tags.push({ + name: 'AI', + style: 'background-color:#4F46E5', + }) + } if (item.owner) { tags.push({ name: this.$L('负责人'), @@ -435,7 +471,7 @@ export default { id: item.id, title: item.name, - desc: item.desc || '', + desc: item.desc_preview ? this.truncateContent(item.desc_preview) : (item.desc || ''), activity: item.updated_at, rawData: item, @@ -499,23 +535,43 @@ export default { searchContact(key) { this.loadIng++; - this.$store.dispatch("call", { + // 如果开启了 AI 搜索,使用新的 AI 搜索接口 + const useAiSearch = this.aiSearchAvailable && this.aiSearch; + const requestConfig = useAiSearch ? { + url: 'search/contact', + data: { + key, + search_type: 'hybrid', + take: this.action ? 50 : 10, + }, + } : { url: 'users/search', data: { keys: {key}, pagesize: this.action ? 50 : 10, }, - }).then(({data}) => { + }; + this.$store.dispatch("call", requestConfig).then(({data}) => { const items = data.map(item => { + const tags = []; + // AI 搜索标记 + if (useAiSearch && item.introduction_preview) { + tags.push({ + name: 'AI', + style: 'background-color:#4F46E5', + }) + } return { key, type: 'contact', icons: ['user', item.userid], - tags: [], + tags, id: item.userid, title: item.nickname, - desc: item.profession || '', + desc: item.introduction_preview + ? this.truncateContent(item.introduction_preview) + : (item.profession || ''), activity: item.line_at, rawData: item, @@ -529,21 +585,23 @@ export default { searchFile(key) { this.loadIng++; - const requestData = { - key, - 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", { + // 如果开启了 AI 搜索,使用统一的 AI 搜索接口 + const useAiSearch = this.aiSearchAvailable && this.aiSearch; + const requestConfig = useAiSearch ? { + url: 'search/file', + data: { + key, + search_type: 'hybrid', + take: this.action ? 50 : 10, + }, + } : { url: 'file/search', - data: requestData, - }).then(({data}) => { + data: { + key, + take: this.action ? 50 : 10, + }, + }; + this.$store.dispatch("call", requestConfig).then(({data}) => { const items = data.map(item => { const tags = []; if (item.share) { @@ -552,8 +610,8 @@ export default { style: 'background-color:#0bc037', }) } - // 如果有内容预览,显示 AI 搜索标记 - if (item.content_preview) { + // AI 搜索标记 + if (useAiSearch && item.content_preview) { tags.push({ name: 'AI', style: 'background-color:#4F46E5', @@ -592,9 +650,16 @@ export default { 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); + // 清除所有支持 AI 搜索的类型的结果 + const aiSearchTypes = ['file', 'task', 'project', 'contact']; + this.searchResults = this.searchResults.filter(item => !aiSearchTypes.includes(item.type)); + // 重新搜索 + if (this.action) { + if (aiSearchTypes.includes(this.action)) { + this.distSearch(this.action); + } + } else { + aiSearchTypes.forEach(type => this.distSearch(type)); } } } diff --git a/routes/web.php b/routes/web.php index 6314b8a79..9ebb48aca 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\ApproveController; use App\Http\Controllers\Api\AssistantController; use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\ComplaintController; +use App\Http\Controllers\Api\SearchController; /* |-------------------------------------------------------------------------- @@ -60,6 +61,9 @@ Route::prefix('api')->middleware(['webapi'])->group(function () { // 投诉 Route::any('complaint/{method}', ComplaintController::class); Route::any('complaint/{method}/{action}', ComplaintController::class); + // 智能搜索 + Route::any('search/{method}', SearchController::class); + Route::any('search/{method}/{action}', SearchController::class); // 测试 Route::any('test/{method}', TestController::class); Route::any('test/{method}/{action}', TestController::class);