mirror of
https://github.com/kuaifan/dootask.git
synced 2026-03-05 08:57:04 +00:00
feat: 添加用户标签功能,更新用户索引以支持标签创建、更新和删除事件
This commit is contained in:
parent
f42250b8b7
commit
90a5624877
@ -7,6 +7,7 @@ use App\Models\File;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTag;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Base;
|
||||
@ -67,9 +68,14 @@ class SearchController extends AbstractController
|
||||
foreach ($results as &$item) {
|
||||
$userData = $users->get($item['userid']);
|
||||
if ($userData) {
|
||||
// 标签直接从 Manticore 搜索结果获取(空格分隔的字符串转数组)
|
||||
$tagsStr = $item['tags'] ?? '';
|
||||
$searchTags = !empty($tagsStr) ? preg_split('/\s+/', trim($tagsStr)) : [];
|
||||
|
||||
$item = array_merge($userData->toArray(), [
|
||||
'relevance' => $item['relevance'] ?? 0,
|
||||
'introduction_preview' => $item['introduction_preview'] ?? null,
|
||||
'search_tags' => $searchTags,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -99,10 +105,15 @@ class SearchController extends AbstractController
|
||||
->take($take)
|
||||
->get();
|
||||
|
||||
return $users->map(function ($user) {
|
||||
// 获取用户标签
|
||||
$userids = $users->pluck('userid')->toArray();
|
||||
$userTags = $this->getUserTagsMap($userids);
|
||||
|
||||
return $users->map(function ($user) use ($userTags) {
|
||||
return array_merge($user->toArray(), [
|
||||
'relevance' => 0,
|
||||
'introduction_preview' => null,
|
||||
'search_tags' => $userTags[$user->userid] ?? [],
|
||||
]);
|
||||
})->toArray();
|
||||
}
|
||||
@ -569,4 +580,40 @@ class SearchController extends AbstractController
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取用户标签映射
|
||||
*
|
||||
* @param array $userids 用户ID数组
|
||||
* @return array 用户ID => 标签名称数组的映射
|
||||
*/
|
||||
private function getUserTagsMap(array $userids): array
|
||||
{
|
||||
if (empty($userids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取所有用户的标签(带认可数)
|
||||
$tags = UserTag::whereIn('user_id', $userids)
|
||||
->withCount('recognitions')
|
||||
->get();
|
||||
|
||||
// 按用户分组,每个用户取 Top 10 标签
|
||||
$result = [];
|
||||
foreach ($userids as $userid) {
|
||||
$result[$userid] = [];
|
||||
}
|
||||
|
||||
$userTags = $tags->groupBy('user_id');
|
||||
foreach ($userTags as $userid => $tagCollection) {
|
||||
$result[$userid] = $tagCollection
|
||||
->sortByDesc('recognitions_count')
|
||||
->take(10)
|
||||
->pluck('name')
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,8 +107,8 @@ class ManticoreBase
|
||||
userid BIGINT,
|
||||
nickname TEXT,
|
||||
email STRING,
|
||||
tel STRING,
|
||||
profession TEXT,
|
||||
tags TEXT,
|
||||
introduction TEXT,
|
||||
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
|
||||
) charset_table='chinese' morphology='icu_chinese'
|
||||
@ -764,17 +764,17 @@ class ManticoreBase
|
||||
$escapedKeyword = self::escapeMatch($keyword);
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
SELECT
|
||||
id,
|
||||
userid,
|
||||
nickname,
|
||||
email,
|
||||
tel,
|
||||
profession,
|
||||
tags,
|
||||
introduction,
|
||||
WEIGHT() as relevance
|
||||
FROM user_vectors
|
||||
WHERE MATCH('@(nickname,profession,introduction) {$escapedKeyword}')
|
||||
WHERE MATCH('@(nickname,profession,tags,introduction) {$escapedKeyword}')
|
||||
ORDER BY relevance DESC
|
||||
LIMIT " . (int)$limit . " OFFSET " . (int)$offset;
|
||||
|
||||
@ -798,13 +798,13 @@ class ManticoreBase
|
||||
$vectorStr = '(' . implode(',', $queryVector) . ')';
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
SELECT
|
||||
id,
|
||||
userid,
|
||||
nickname,
|
||||
email,
|
||||
tel,
|
||||
profession,
|
||||
tags,
|
||||
introduction,
|
||||
KNN_DIST() as distance
|
||||
FROM user_vectors
|
||||
@ -891,8 +891,8 @@ class ManticoreBase
|
||||
$vectorValue = $data['content_vector'] ?? null;
|
||||
if ($vectorValue) {
|
||||
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
|
||||
$sql = "INSERT INTO user_vectors
|
||||
(id, userid, nickname, email, tel, profession, introduction, content_vector)
|
||||
$sql = "INSERT INTO user_vectors
|
||||
(id, userid, nickname, email, profession, tags, introduction, content_vector)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, {$vectorValue})";
|
||||
|
||||
$params = [
|
||||
@ -900,13 +900,13 @@ class ManticoreBase
|
||||
$userid,
|
||||
$data['nickname'] ?? '',
|
||||
$data['email'] ?? '',
|
||||
$data['tel'] ?? '',
|
||||
$data['profession'] ?? '',
|
||||
$data['tags'] ?? '',
|
||||
$data['introduction'] ?? ''
|
||||
];
|
||||
} else {
|
||||
$sql = "INSERT INTO user_vectors
|
||||
(id, userid, nickname, email, tel, profession, introduction)
|
||||
$sql = "INSERT INTO user_vectors
|
||||
(id, userid, nickname, email, profession, tags, introduction)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$params = [
|
||||
@ -914,8 +914,8 @@ class ManticoreBase
|
||||
$userid,
|
||||
$data['nickname'] ?? '',
|
||||
$data['email'] ?? '',
|
||||
$data['tel'] ?? '',
|
||||
$data['profession'] ?? '',
|
||||
$data['tags'] ?? '',
|
||||
$data['introduction'] ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
@ -250,10 +250,14 @@ class ManticoreFile
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($content) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($content);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
if ($withVector && Apps::isInstalled('ai')) {
|
||||
// 向量内容包含文件名和文件内容
|
||||
$vectorContent = self::buildVectorContent($file->name, $content);
|
||||
if (!empty($vectorContent)) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($vectorContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -399,6 +403,28 @@ class ManticoreFile
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用于生成向量的内容
|
||||
* 包含文件名和文件内容,确保语义搜索能匹配文件名
|
||||
*
|
||||
* @param string $fileName 文件名
|
||||
* @param string $content 文件内容
|
||||
* @return string 用于生成向量的文本
|
||||
*/
|
||||
private static function buildVectorContent(string $fileName, string $content): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($fileName)) {
|
||||
$parts[] = $fileName;
|
||||
}
|
||||
if (!empty($content)) {
|
||||
$parts[] = $content;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
@ -486,7 +512,7 @@ class ManticoreFile
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个文件的内容
|
||||
// 2. 提取每个文件的内容(包含文件名)
|
||||
$fileContents = [];
|
||||
foreach ($files as $file) {
|
||||
// 检查文件大小限制
|
||||
@ -496,10 +522,12 @@ class ManticoreFile
|
||||
}
|
||||
|
||||
$content = self::extractFileContent($file);
|
||||
if (!empty($content)) {
|
||||
// 向量内容包含文件名和文件内容
|
||||
$vectorContent = self::buildVectorContent($file->name, $content);
|
||||
if (!empty($vectorContent)) {
|
||||
// 限制内容长度
|
||||
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
|
||||
$fileContents[$file->id] = $content;
|
||||
$vectorContent = mb_substr($vectorContent, 0, self::MAX_CONTENT_LENGTH);
|
||||
$fileContents[$file->id] = $vectorContent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserTag;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
@ -90,8 +91,8 @@ class ManticoreUser
|
||||
'userid' => $item['userid'],
|
||||
'nickname' => $item['nickname'],
|
||||
'email' => $item['email'],
|
||||
'tel' => $item['tel'],
|
||||
'profession' => $item['profession'],
|
||||
'tags' => $item['tags'] ?? '',
|
||||
'introduction_preview' => isset($item['introduction']) ? mb_substr($item['introduction'], 0, 200) : null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
@ -103,6 +104,24 @@ class ManticoreUser
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取用户的标签(按认可数排序,最多10个)
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return string 标签名称,空格分隔
|
||||
*/
|
||||
public static function getUserTags(int $userid): string
|
||||
{
|
||||
$tags = UserTag::where('user_id', $userid)
|
||||
->withCount('recognitions')
|
||||
->orderByDesc('recognitions_count')
|
||||
->limit(10)
|
||||
->pluck('name')
|
||||
->toArray();
|
||||
|
||||
return implode(' ', $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个用户到 Manticore
|
||||
*
|
||||
@ -127,8 +146,11 @@ class ManticoreUser
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取用户标签(Top 10)
|
||||
$tags = self::getUserTags($user->userid);
|
||||
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($user);
|
||||
$searchableContent = self::buildSearchableContent($user, $tags);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
@ -144,8 +166,8 @@ class ManticoreUser
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname ?? '',
|
||||
'email' => $user->email ?? '',
|
||||
'tel' => $user->tel ?? '',
|
||||
'profession' => $user->profession ?? '',
|
||||
'tags' => $tags,
|
||||
'introduction' => $user->introduction ?? '',
|
||||
'content_vector' => $embedding,
|
||||
]);
|
||||
@ -164,9 +186,10 @@ class ManticoreUser
|
||||
* 构建可搜索的文本内容
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @param string $tags 用户标签(空格分隔)
|
||||
* @return string 可搜索的文本
|
||||
*/
|
||||
private static function buildSearchableContent(User $user): string
|
||||
private static function buildSearchableContent(User $user, string $tags = ''): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
@ -179,6 +202,9 @@ class ManticoreUser
|
||||
if (!empty($user->profession)) {
|
||||
$parts[] = $user->profession;
|
||||
}
|
||||
if (!empty($tags)) {
|
||||
$parts[] = $tags;
|
||||
}
|
||||
if (!empty($user->introduction)) {
|
||||
$parts[] = $user->introduction;
|
||||
}
|
||||
@ -280,10 +306,11 @@ class ManticoreUser
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个用户的内容
|
||||
// 2. 提取每个用户的内容(包含标签)
|
||||
$userContents = [];
|
||||
foreach ($users as $user) {
|
||||
$searchableContent = self::buildSearchableContent($user);
|
||||
$tags = self::getUserTags($user->userid);
|
||||
$searchableContent = self::buildSearchableContent($user, $tags);
|
||||
if (!empty($searchableContent)) {
|
||||
$userContents[$user->userid] = $searchableContent;
|
||||
}
|
||||
|
||||
@ -35,8 +35,8 @@ class UserObserver extends AbstractObserver
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有搜索相关字段变化
|
||||
$searchableFields = ['nickname', 'email', 'tel', 'profession', 'introduction', 'disable_at'];
|
||||
// 检查是否有搜索相关字段变化(不含 tel,因为 Manticore 不索引电话)
|
||||
$searchableFields = ['nickname', 'email', 'profession', 'introduction', 'disable_at'];
|
||||
$isDirty = false;
|
||||
foreach ($searchableFields as $field) {
|
||||
if ($user->isDirty($field)) {
|
||||
|
||||
69
app/Observers/UserTagObserver.php
Normal file
69
app/Observers/UserTagObserver.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserTag;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
class UserTagObserver extends AbstractObserver
|
||||
{
|
||||
/**
|
||||
* Handle the UserTag "created" event.
|
||||
* 标签创建时,触发用户索引更新
|
||||
*
|
||||
* @param \App\Models\UserTag $userTag
|
||||
* @return void
|
||||
*/
|
||||
public function created(UserTag $userTag)
|
||||
{
|
||||
$this->syncUserToManticore($userTag->user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the UserTag "updated" event.
|
||||
* 标签更新时,触发用户索引更新
|
||||
*
|
||||
* @param \App\Models\UserTag $userTag
|
||||
* @return void
|
||||
*/
|
||||
public function updated(UserTag $userTag)
|
||||
{
|
||||
// 只有标签名称变化时才需要更新
|
||||
if ($userTag->isDirty('name')) {
|
||||
$this->syncUserToManticore($userTag->user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the UserTag "deleted" event.
|
||||
* 标签删除时,触发用户索引更新
|
||||
*
|
||||
* @param \App\Models\UserTag $userTag
|
||||
* @return void
|
||||
*/
|
||||
public function deleted(UserTag $userTag)
|
||||
{
|
||||
$this->syncUserToManticore($userTag->user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发用户同步到 Manticore
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return void
|
||||
*/
|
||||
private function syncUserToManticore(int $userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::find($userid);
|
||||
if (!$user || $user->bot || $user->disable_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::taskDeliver(new ManticoreSyncTask('user_sync', $user->toArray()));
|
||||
}
|
||||
}
|
||||
60
app/Observers/UserTagRecognitionObserver.php
Normal file
60
app/Observers/UserTagRecognitionObserver.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserTag;
|
||||
use App\Models\UserTagRecognition;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
class UserTagRecognitionObserver extends AbstractObserver
|
||||
{
|
||||
/**
|
||||
* Handle the UserTagRecognition "created" event.
|
||||
* 认可创建时,标签排序可能变化,触发用户索引更新
|
||||
*
|
||||
* @param \App\Models\UserTagRecognition $recognition
|
||||
* @return void
|
||||
*/
|
||||
public function created(UserTagRecognition $recognition)
|
||||
{
|
||||
$this->syncUserByTagId($recognition->tag_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the UserTagRecognition "deleted" event.
|
||||
* 认可删除时,标签排序可能变化,触发用户索引更新
|
||||
*
|
||||
* @param \App\Models\UserTagRecognition $recognition
|
||||
* @return void
|
||||
*/
|
||||
public function deleted(UserTagRecognition $recognition)
|
||||
{
|
||||
$this->syncUserByTagId($recognition->tag_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据标签ID触发用户同步
|
||||
*
|
||||
* @param int $tagId 标签ID
|
||||
* @return void
|
||||
*/
|
||||
private function syncUserByTagId(int $tagId)
|
||||
{
|
||||
if ($tagId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tag = UserTag::find($tagId);
|
||||
if (!$tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::find($tag->user_id);
|
||||
if (!$user || $user->bot || $user->disable_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::taskDeliver(new ManticoreSyncTask('user_sync', $user->toArray()));
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,8 @@ use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTag;
|
||||
use App\Models\UserTagRecognition;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
@ -21,6 +23,8 @@ use App\Observers\ProjectTaskUserObserver;
|
||||
use App\Observers\ProjectTaskVisibilityUserObserver;
|
||||
use App\Observers\ProjectUserObserver;
|
||||
use App\Observers\UserObserver;
|
||||
use App\Observers\UserTagObserver;
|
||||
use App\Observers\UserTagRecognitionObserver;
|
||||
use App\Observers\WebSocketDialogMsgObserver;
|
||||
use App\Observers\WebSocketDialogObserver;
|
||||
use App\Observers\WebSocketDialogUserObserver;
|
||||
@ -56,6 +60,8 @@ class EventServiceProvider extends ServiceProvider
|
||||
ProjectTaskVisibilityUser::observe(ProjectTaskVisibilityUserObserver::class);
|
||||
ProjectUser::observe(ProjectUserObserver::class);
|
||||
User::observe(UserObserver::class);
|
||||
UserTag::observe(UserTagObserver::class);
|
||||
UserTagRecognition::observe(UserTagRecognitionObserver::class);
|
||||
WebSocketDialog::observe(WebSocketDialogObserver::class);
|
||||
WebSocketDialogMsg::observe(WebSocketDialogMsgObserver::class);
|
||||
WebSocketDialogUser::observe(WebSocketDialogUserObserver::class);
|
||||
|
||||
@ -505,11 +505,24 @@ export default {
|
||||
},
|
||||
}).then(({data}) => {
|
||||
const items = data.map(item => {
|
||||
// 构建标签:显示匹配搜索关键词的标签
|
||||
const tags = [];
|
||||
if (item.search_tags && item.search_tags.length > 0) {
|
||||
const keyLower = key.toLowerCase();
|
||||
item.search_tags.forEach(tagName => {
|
||||
if (tagName.toLowerCase().includes(keyLower)) {
|
||||
tags.push({
|
||||
name: tagName,
|
||||
style: 'background-color:#2d8cf0',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
key,
|
||||
type: 'contact',
|
||||
icons: ['user', item.userid],
|
||||
tags: [],
|
||||
tags,
|
||||
|
||||
id: item.userid,
|
||||
title: item.nickname,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user