Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07a2e6df29 | ||
|
|
f521f0df65 | ||
|
|
a67fcd6f02 | ||
|
|
d17f404853 | ||
|
|
8def4addc4 | ||
|
|
0ecaf9740f | ||
|
|
bc75680ee9 | ||
|
|
6a71964592 | ||
|
|
00a2ea3d2f | ||
|
|
95e97333b4 | ||
|
|
9e65500748 | ||
|
|
a2acd6f6e4 | ||
|
|
ee96730268 | ||
|
|
f925f238dd | ||
|
|
39c6ca3e8c | ||
|
|
c798faa8db | ||
|
|
ed2f843815 | ||
|
|
984b98e4fc | ||
|
|
4b32472d64 | ||
|
|
fc171bc71f | ||
|
|
cc80fa83e0 | ||
|
|
782ba4a151 | ||
|
|
04708cedb6 | ||
|
|
4068966700 | ||
|
|
3ce8cf381a | ||
|
|
f78d3f3aff | ||
|
|
c60dff0950 | ||
|
|
f2d49ee104 | ||
|
|
a248d81230 | ||
|
|
1ac6bad2bb | ||
|
|
37de721df9 | ||
|
|
773eead827 | ||
|
|
c4dd04ccb6 | ||
|
|
2cdde37069 | ||
|
|
f68f759418 | ||
|
|
801d0b24ab | ||
|
|
29be29b9cf | ||
|
|
c253044f61 | ||
|
|
9acf7d2046 | ||
|
|
3911af7b51 | ||
|
|
6b722b7ed7 | ||
|
|
6a00b87f72 | ||
|
|
0a97039d75 | ||
|
|
cb56a01622 | ||
|
|
452af4bd2f | ||
|
|
75073d4320 | ||
|
|
d4d7a0d69f | ||
|
|
165ad03024 | ||
|
|
3603cf9889 | ||
|
|
027662ebab | ||
|
|
106465b932 | ||
|
|
eef4c6fbe5 | ||
|
|
916ae97ca7 | ||
|
|
841405505d | ||
|
|
22a653bb0f | ||
|
|
3482e4b1a8 | ||
|
|
9097369b0c | ||
|
|
95c6b53f10 | ||
|
|
f7d5040b02 | ||
|
|
26b7f83d35 | ||
|
|
07b99c6e75 | ||
|
|
cb5e7e2cc7 | ||
|
|
2180998e81 | ||
|
|
478876ddc1 | ||
|
|
ae021fd148 | ||
|
|
f36317b081 | ||
|
|
066a5a619c | ||
|
|
654793156d | ||
|
|
ba65378c6b | ||
|
|
cb6c50b071 | ||
|
|
2cb67fafe7 | ||
|
|
8eaba6f364 | ||
|
|
c4f0fb5a3d | ||
|
|
59ad79fa58 | ||
|
|
c65f0276bd | ||
|
|
f8b335a003 | ||
|
|
0ac4b546ba | ||
|
|
07a41ca0ac | ||
|
|
347465fc4d | ||
|
|
acb9cd317c | ||
|
|
b7213f8c47 | ||
|
|
a3caf5ebdf | ||
|
|
87dd07ef23 | ||
|
|
0cefb7eaff | ||
|
|
ff87de9f44 | ||
|
|
22de7de87c | ||
|
|
53dd9dca0f | ||
|
|
12d6bbea19 | ||
|
|
23b06327d6 | ||
|
|
6c22e373f7 | ||
|
|
4ebbb387ee | ||
|
|
9234fe3ed1 | ||
|
|
70be6619e9 | ||
|
|
c8c27e808f | ||
|
|
9cb8c92492 | ||
|
|
f4f9ee1d3d | ||
|
|
138336711f | ||
|
|
2163bb0bff | ||
|
|
bc460f0da8 | ||
|
|
ad66811f49 | ||
|
|
70ad8c394a | ||
|
|
32ffecb905 | ||
|
|
b794ba7a6b | ||
|
|
07360a8d2c | ||
|
|
fb7731ddcd | ||
|
|
13a25e3011 | ||
|
|
055cf53738 | ||
|
|
cb414b48f6 | ||
|
|
1c27719ac4 | ||
|
|
ec33327408 | ||
|
|
c2c27a684b | ||
|
|
224703a6d0 | ||
|
|
dd20711c04 | ||
|
|
3a2b7b1400 | ||
|
|
792989a504 | ||
|
|
c0183e62fb | ||
|
|
ce5bb5f187 | ||
|
|
a34b0c88d5 | ||
|
|
9c7ec58bb6 | ||
|
|
067a736b57 | ||
|
|
f8f08c9d0d | ||
|
|
4f2d382fd6 | ||
|
|
42e4ddbd17 | ||
|
|
3026cd698f | ||
|
|
47c53a18fa | ||
|
|
22926e19cd | ||
|
|
495b25e2b1 | ||
|
|
01908b7c48 | ||
|
|
b138dc580d | ||
|
|
78b14f4aad | ||
|
|
60387aa521 | ||
|
|
633826cb89 | ||
|
|
cf6d180fc5 | ||
|
|
0d85174250 | ||
|
|
925449c66a | ||
|
|
cd58b418af | ||
|
|
4cfc5e6024 | ||
|
|
7321ab06f0 | ||
|
|
790f5d4838 | ||
|
|
731dbc5507 | ||
|
|
3b1dce6d67 | ||
|
|
4929d44ce7 | ||
|
|
ce42c2a660 | ||
|
|
16d5ffd4f9 | ||
|
|
fc74e0d952 | ||
|
|
089f219280 | ||
|
|
9d62ec1ec1 | ||
|
|
5a4e51d1e0 | ||
|
|
f0982d7d9a | ||
|
|
1ac3a4cc96 | ||
|
|
7f9c42d3d8 | ||
|
|
4e99e398d6 | ||
|
|
395fc155ce | ||
|
|
6bdefc4f03 | ||
|
|
d4547cbe97 | ||
|
|
c9a0b7481a | ||
|
|
f496bc5fca | ||
|
|
4ba02b9dce | ||
|
|
f821e5ad28 | ||
|
|
425f7b6f79 | ||
|
|
61d7970b6a | ||
|
|
1aa9984535 | ||
|
|
8ab810c670 | ||
|
|
5cc3d60e15 | ||
|
|
42a2eb56c7 | ||
|
|
4b0f4e388c | ||
|
|
31045b3808 | ||
|
|
a95f22bf42 | ||
|
|
fa84f92577 | ||
|
|
90a5624877 | ||
|
|
f42250b8b7 | ||
|
|
b9809d207d | ||
|
|
0d8e10b60e | ||
|
|
501ff21e55 | ||
|
|
4759e28a56 | ||
|
|
bd7841ac05 | ||
|
|
ea0d27fdea | ||
|
|
610979f30b | ||
|
|
9a8304d595 | ||
|
|
e020a80020 | ||
|
|
7a21a2d800 | ||
|
|
ec0db3a76c | ||
|
|
67fc0781e5 | ||
|
|
79c2ba140c | ||
|
|
908171a977 | ||
|
|
a52dc14369 | ||
|
|
1e94ce501e | ||
|
|
7a5ef3a491 | ||
|
|
c08323e1ea | ||
|
|
fdf5ceeaab | ||
|
|
48ef4cfdef | ||
|
|
10c6177a9f | ||
|
|
0362c83e77 | ||
|
|
1af29837e2 | ||
|
|
986c4871df | ||
|
|
fe7a2a0e73 | ||
|
|
23faf28f7f | ||
|
|
a8d4f261a4 | ||
|
|
a336fd4a1a | ||
|
|
8759e6fd7e | ||
|
|
92d23014a7 | ||
|
|
7c3f33ea0d | ||
|
|
16a55de6f1 | ||
|
|
869ac7d316 | ||
|
|
55303689ea | ||
|
|
c69123ac92 | ||
|
|
7bce5f1c1f | ||
|
|
989660969c | ||
|
|
862acd0776 | ||
|
|
3b3ffd494f | ||
|
|
6cf8290565 | ||
|
|
230ebbcfb9 |
53
.claude/commands/release.md
Normal file
53
.claude/commands/release.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 发布流程
|
||||
|
||||
刚性流程——严格按顺序执行,每步确认,任何一步失败则停止。
|
||||
|
||||
## 前置检查
|
||||
|
||||
在执行任何发布步骤之前,必须依次检查:
|
||||
|
||||
1. **分支检查**:当前分支必须是 `pro`,否则停止并提示用户切换
|
||||
2. **工作区检查**:`git status` 必须干净(无未提交变更),否则停止并提示用户先处理
|
||||
3. **Node.js 版本**:必须 >= 20,否则停止并提示
|
||||
|
||||
所有检查通过后,向用户报告检查结果,确认后再开始执行。
|
||||
|
||||
## 发布步骤
|
||||
|
||||
逐步执行以下命令,**每步执行前**向用户确认,**每步执行后**报告结果:
|
||||
|
||||
### Step 1: 翻译
|
||||
|
||||
```shell
|
||||
npm run translate
|
||||
```
|
||||
|
||||
更新多语言翻译文件。
|
||||
|
||||
### Step 2: 版本号
|
||||
|
||||
```shell
|
||||
npm run version
|
||||
```
|
||||
|
||||
更新版本号。
|
||||
|
||||
### Step 3: 构建前端
|
||||
|
||||
```shell
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建前端生产版本。
|
||||
|
||||
### 最终确认:提交并推送
|
||||
|
||||
所有步骤完成后,通过 `git diff` 和 `git status` 获取所有变更,向用户汇报摘要。
|
||||
|
||||
汇报完毕后询问用户是否提交并推送。用户确认后才执行 git add、commit 和 push,否则不执行。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败后立即停止,报告错误信息
|
||||
- 不要自动重试或跳过失败步骤
|
||||
- 由用户决定如何处理
|
||||
76
.github/workflows/publish.yml
vendored
76
.github/workflows/publish.yml
vendored
@@ -52,53 +52,18 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// 获取最新的 tag
|
||||
const { data: tags } = await github.rest.repos.listTags({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 1
|
||||
});
|
||||
const fs = require('fs');
|
||||
const version = '${{ needs.check-version.outputs.version }}';
|
||||
|
||||
// 获取提交日志
|
||||
// 从 CHANGELOG.md 提取当前版本段落
|
||||
let changelog = '';
|
||||
if (tags.length > 0) {
|
||||
const { data: commits } = await github.rest.repos.compareCommits({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
base: tags[0].name,
|
||||
head: 'HEAD'
|
||||
});
|
||||
|
||||
// 按类型分组提交
|
||||
const groups = {
|
||||
'feat:': { title: '## Features', commits: new Set() },
|
||||
'fix:': { title: '## Bug Fixes', commits: new Set() },
|
||||
'perf:': { title: '## Performance Improvements', commits: new Set() }
|
||||
};
|
||||
|
||||
// 分类收集提交,使用 Set 去重
|
||||
commits.commits.forEach(commit => {
|
||||
const message = commit.commit.message.split('\n')[0].trim();
|
||||
for (const [prefix, group] of Object.entries(groups)) {
|
||||
if (message.startsWith(prefix)) {
|
||||
// 移除前缀后添加到对应分组
|
||||
const cleanMessage = message.slice(prefix.length).trim();
|
||||
group.commits.add(cleanMessage); // 使用 Set.add 自动去重
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 生成更新日志
|
||||
const sections = [];
|
||||
for (const group of Object.values(groups)) {
|
||||
if (group.commits.size > 0) {
|
||||
sections.push(`${group.title}\n\n${Array.from(group.commits).map(msg => `- ${msg}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length > 0) {
|
||||
changelog = '# Changelog\n\n' + sections.join('\n\n');
|
||||
const changelogPath = 'CHANGELOG.md';
|
||||
if (fs.existsSync(changelogPath)) {
|
||||
const content = fs.readFileSync(changelogPath, 'utf-8');
|
||||
const regex = new RegExp(`## \\[${version.replace(/\./g, '\\.')}\\][\\s\\S]*?(?=\\n## \\[|$)`);
|
||||
const match = content.match(regex);
|
||||
if (match) {
|
||||
changelog = match[0].trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +71,8 @@ jobs:
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `v${{ needs.check-version.outputs.version }}`,
|
||||
name: `${{ needs.check-version.outputs.version }}`,
|
||||
tag_name: `v${version}`,
|
||||
name: version,
|
||||
body: changelog || 'No significant changes in this release.',
|
||||
draft: true,
|
||||
prerelease: false
|
||||
@@ -215,7 +180,8 @@ jobs:
|
||||
- name: (Android) Upload File
|
||||
if: matrix.build_type == 'android'
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
|
||||
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
|
||||
run: |
|
||||
node ./electron/build.js android-upload
|
||||
|
||||
@@ -253,7 +219,8 @@ jobs:
|
||||
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
|
||||
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
@@ -263,7 +230,8 @@ jobs:
|
||||
- name: (Windows) Build Client
|
||||
if: matrix.build_type == 'windows'
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
|
||||
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
shell: bash
|
||||
@@ -294,11 +262,13 @@ jobs:
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
- name: Publish Official
|
||||
- name: Upload Changelog & Publish to Website
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
|
||||
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
|
||||
run: |
|
||||
pushd electron || exit
|
||||
npm install
|
||||
popd || exit
|
||||
node ./electron/build.js published
|
||||
node ./electron/build.js upload-changelog
|
||||
node ./electron/build.js release
|
||||
|
||||
127
AGENTS.md
127
AGENTS.md
@@ -1,127 +0,0 @@
|
||||
# DooTask 项目说明
|
||||
|
||||
## 一、项目总览
|
||||
|
||||
- **项目定位**:DooTask 是一套开源的任务 / 项目管理系统,支持看板、任务、子任务、评论、对话、文件、报表等协作能力。
|
||||
- **后端技术栈**
|
||||
- 基于 Laravel(运行在 LaravelS / Swoole 常驻进程上),代码集中在 `app/`、`routes/`、`config/` 等目录。
|
||||
- 数据库通过 Laravel Eloquent 模型访问,所有表结构变更必须通过 migration 完成,禁止直接手工改库。
|
||||
- **前端技术栈**
|
||||
- 主 Web 前端基于 Vue2 + Vite,代码集中在 `resources/assets/js`。
|
||||
- 打包与开发通过根目录的 `./cmd` 脚本间接调用 Vite。
|
||||
- **桌面端**
|
||||
- 使用 Electron 作为桌面壳,核心业务逻辑仍在 Web 前端与 Laravel 后端中。
|
||||
|
||||
更多安装、升级、迁移说明见根目录 `README.md`。
|
||||
|
||||
## 二、开发与运行(命令约定)
|
||||
|
||||
- 开发 / 构建命令统一通过根目录的 `./cmd` 脚本执行,以保证与 Docker / 容器环境一致:
|
||||
- 启动服务:`./cmd up`
|
||||
- 停止服务:`./cmd down`
|
||||
- 重启服务:`./cmd reup` 或 `./cmd restart`
|
||||
- Laravel 工具:`./cmd artisan ...`
|
||||
- 前端开发:`./cmd dev`
|
||||
- 前端构建:`./cmd prod` 或 `./cmd build`
|
||||
- 其他工具:`./cmd composer ...`、`./cmd php ...`、`./cmd doc` 等
|
||||
- 在示例、脚本与回答中,优先使用 `./cmd ...` 形式,而不是直接调用 `php`、`composer`、`npm` 等命令。
|
||||
|
||||
## 三、代码结构(后端 + 前端)
|
||||
|
||||
- **Controller(`app/Http/Controllers`)**
|
||||
- 负责路由入口、参数接收与基础校验,编排调用模型 / 模块,并组装 API 响应。
|
||||
- 原则:控制器尽量保持「薄」,复杂业务逻辑不要堆积在控制器中。
|
||||
- 业务异常优先使用 `App\Exceptions\ApiException` 抛出,由全局 Handler 统一转换为标准 JSON 响应。
|
||||
|
||||
- **Model(`app/Models`)**
|
||||
- 负责数据表结构映射、关系(relations)、访问器 / 修改器、自定义查询 Scope 等「数据层」逻辑。
|
||||
- 避免在模型方法中塞入大量跨业务的流程控制逻辑,复杂业务应下沉到模块中。
|
||||
|
||||
- **Module(`app/Module`)**
|
||||
- 承载跨控制器 / 跨模型的业务逻辑与独立功能子域,例如:
|
||||
- 外部服务集成:`AgoraIO/*`、`ZincSearch/*` 等;
|
||||
- 通用工具:`Lock.php`、`TextExtractor.php`、`Image.php` 等;
|
||||
- 项目 / 任务 / 对话等领域里的复杂协作逻辑。
|
||||
- 原则:
|
||||
- 新增较复杂的业务功能时,优先考虑创建 / 扩展 Module,而不是在 Controller 或 Model 中堆砌流程。
|
||||
- Module 尽量保持单一职责与可复用,命名能直接反映其业务或能力作用。
|
||||
|
||||
- **运行环境注意事项(LaravelS / Swoole)**
|
||||
- 避免在静态属性、单例、全局变量中存储请求级状态或可变数据,防止请求间数据串联和内存泄漏。
|
||||
- 不要假设构造函数、服务提供者或 `boot()` 方法会在每个请求重新执行;涉及配置、路由等改动时,通常需要通过 `./cmd php restart` 或容器重启后才能生效。
|
||||
- 编写长连接、定时任务、WebSocket 等长生命周期逻辑时,优先复用现有模式,并避免长时间阻塞协程 / 事件循环的操作。
|
||||
|
||||
- **前端(`resources/assets/js`,Vue2 + Vite)**
|
||||
- 结构大致包括:
|
||||
- `app.js`、`App.vue`:应用入口与根组件;
|
||||
- `components/`:通用与业务组件(任务看板、文件预览、聊天等);
|
||||
- `pages/`:页面级组件(登录、项目、任务视图、消息、报表等);
|
||||
- `store/`:Vuex 全局状态管理;
|
||||
- `routes.js`:前端路由配置。
|
||||
- 构建与开发:
|
||||
- 开发模式:使用 `./cmd dev` 或类似子命令,内部通过 Vite 启动开发服务器。
|
||||
- 生产构建:使用 `./cmd prod` 或 `./cmd build`,内部通过 Vite 产出前端静态资源。
|
||||
- 与后端接口协作:
|
||||
- 接口调用默认通过已有的 Vuex 封装发起请求,新增接口时优先扩展集中封装,而不是在组件中直接散落 `axios/fetch`。
|
||||
|
||||
- **Electron**
|
||||
- Electron 主要作为桌面入口壳,核心业务逻辑仍在 Web/Vue2 前端与 PHP/Laravel 后端。
|
||||
- 日常开发与调试优先使用 `./cmd electron ...`;需要构建 App 端资源时使用 `./cmd appbuild`。
|
||||
- 原则:优先保证 Web 端行为正确,再通过 Electron 壳复用 Web 逻辑;桌面专有能力(本地文件、托盘等)需在代码中明确边界。
|
||||
|
||||
## 四、在本项目中使用 Graphiti 作为长期记忆
|
||||
|
||||
- **角色与 group_id**
|
||||
- Graphiti 作为本项目的「长期记忆层」,用于持久化:
|
||||
- 用户偏好(Preferences)、工作流程 / 习惯(Procedures)、重要约束(Requirements)、关键事实 / 关系(Facts)。
|
||||
- 目标是:跨对话、跨任务保持一致的行为和决策,而不是简单堆积信息。
|
||||
- 本项目统一使用的 `group_id`:`dootask-main`。
|
||||
|
||||
- **任务开始前(读)**
|
||||
- 在进行实质性工作(写代码、设计方案、做大改动)前,应先通过 Graphiti 查询已有记忆:
|
||||
- 使用节点搜索(如 `search_nodes`)在 `group_id = "dootask-main"` 下查找与当前任务相关的 Preference / Procedure / Requirement;
|
||||
- 使用事实搜索(如 `search_facts`)查找相关事实与实体关系;
|
||||
- 查询语句中可包含:任务类型(Bug 修复 / 重构 / 新功能等)、涉及模块(任务、项目、对话、WebSocket、报表等)以及关键字 `dootask`。
|
||||
- 发现与当前任务高度相关的偏好 / 流程 / 约束时,应优先遵守;如存在冲突,应在回答中说明并做合理选择。
|
||||
|
||||
- **什么时候写入 Graphiti(写)**
|
||||
- **偏好(Preferences)**:用户表达持续性偏好时(语言、输出格式、技术选型等),应尽快写入;
|
||||
- **流程 / 习惯(Procedures)**:形成「以后都按这个流程来」的稳定开发 / 发布 / 调试流程时,应记录为可复用步骤;
|
||||
- **约束 / 决策(Requirements)**:项目长期有效的决策,如不再支持某版本、某模块的架构约定等;
|
||||
- **事实 / 关系(Facts)**:模块边界约定、服务之间的调用关系、与外部系统(如 AgoraIO、ZincSearch)集成方式等。
|
||||
- 写入建议:
|
||||
- 默认使用 `source: "text"`,在 `episode_body` 中用简洁结构化自然语言描述背景、类型、范围、具体内容;
|
||||
- 需要结构化数据时可用 `source: "json"`,保证 `episode_body` 是合法 JSON 字符串;
|
||||
- 所有写入默认使用 `group_id: "dootask-main"`。
|
||||
|
||||
- **更新与更正**
|
||||
- 偏好 / 流程发生变化时,新增一条 episode 说明新约定,并标明这是对旧习惯的更新,后续以最新、最明确的为准;
|
||||
- 用户要求「忘记」某些记忆时,可通过删除或更正相关 episode / 关系的方式处理;
|
||||
- 尽量通过新增 episode 记录「更正 / 废弃说明」,而不是直接改写历史事实。
|
||||
|
||||
- **在工作中的使用方式**
|
||||
- 尊重已存偏好:编码风格、回答结构、工具选择等应对齐已知偏好;
|
||||
- 遵循已有流程:若图谱中已有与当前任务匹配的 Procedure,应尽量按步骤执行;
|
||||
- 利用事实:理解系统行为、模块边界、历史决策时优先查已存 Facts,减少重新摸索;
|
||||
- 如 Graphiti 与当前代码实际冲突,应以代码实际为准,并视情况新增 episode 更新事实。
|
||||
|
||||
- **不要写入 Graphiti 的内容**
|
||||
- 含敏感信息(密钥、密码、隐私数据等);
|
||||
- 只与当前一次任务相关、未来不会复用的临时信息(调试日志、一次性命令输出等);
|
||||
- 体量巨大的原始数据(完整日志、长脚本全文等),应只存摘要和关键结论。
|
||||
|
||||
- **最佳实践小结**
|
||||
- 先查再做:在提出方案或改动架构前,优先查阅 Graphiti 中已有的设计、偏好和约束;
|
||||
- 能复用就沉淀:只要发现某个偏好 / 流程 / 约束未来会反复用到,就尽快写入 Graphiti,而不是只放在当前对话里;
|
||||
- 保持项目内外一致:确保 Graphiti 中的记忆与实际代码长期保持一致,避免「记忆漂移」。
|
||||
|
||||
## 五、前端弹窗文案
|
||||
|
||||
- 在前端 Vue 代码中调用 `$A.modalXXX`、`$A.messageXXX`、`$A.noticeXXX` 时,这些方法内部会统一处理 `$L` 翻译,调用方默认不要再额外包一层 `$L`。
|
||||
- 仅当 `modalXXX` 特殊场景显式传入 `language: false`(关闭内部自动翻译)时,才由调用方在传入前自行决定是否使用 `$L` 处理文案。
|
||||
|
||||
## 六、AI 回复风格与语言偏好
|
||||
|
||||
- 总体说明与重要总结(尤其是最终回答的 recap 部分),在不影响技术表达准确性的前提下,应优先使用简体中文进行回复。
|
||||
- 如用户在对话中明确要求使用其他语言(例如英文),则以用户的显式指令为最高优先级。
|
||||
- 当本次协作的改动已经较为完整且自然形成一个提交单元时,应在最终回答中附带一条或数条推荐的 Git 提交 message,方便用户直接复制使用。
|
||||
120
CHANGELOG.md
120
CHANGELOG.md
@@ -2,6 +2,126 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.7.14]
|
||||
|
||||
### Features
|
||||
|
||||
- 新增消息合并转发,支持批量选择后一次转发,分享聊天内容更方便。
|
||||
- 现在可以按项目负责人筛选任务,查找和整理任务更省时。
|
||||
- 支持解除任务关联,调整任务关系更灵活。
|
||||
- 新增 AI 自动分析开关,可按需开启或关闭,使用起来更可控。
|
||||
- 安装和修改设置时会自动检查应用编号与端口是否冲突,减少配置出错和无法启动的情况。
|
||||
- 支持自定义 AI 服务地址,连接和接入方式更灵活。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复了 AI 助手在部分页面显示异常的问题,查看和使用时更稳定。
|
||||
|
||||
## [1.6.89]
|
||||
|
||||
### Features
|
||||
|
||||
- AI 助手支持拖放、粘贴上传图片,并可直接发送图片参与对话,交流更直观
|
||||
- AI 任务建议支持多语言输出,跨语言使用更顺畅
|
||||
- 工作流配置新增规则摘要展示,规则一眼看懂,减少来回查看
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复工作流在切换、完成/取消完成任务时状态不同步的问题,避免状态错乱
|
||||
- 修复 AI 建议触发条件与头像显示异常,展示更稳定、体验更一致
|
||||
- 修复部分提示文案显示不正确的问题,信息更清晰
|
||||
- 修复描述格式与负责人重复显示的问题,页面更整洁
|
||||
- 修复点击 AI 相关链接时解析失败的问题,打开更可靠
|
||||
- 修复因日期格式导致文件名被误处理而创建失败的问题,上传更稳定
|
||||
- 修复网络连接异常时状态未正确更新的问题,避免卡住
|
||||
- 修复延迟推送的已读检查偶发失效的问题,提醒更准确
|
||||
|
||||
## [1.6.51]
|
||||
|
||||
### AI 助手更新
|
||||
|
||||
- 新增全屏模式,支持拖动边缘调整聊天窗口大小
|
||||
- 新增可拖拽浮动按钮,支持自动贴边收起
|
||||
- 支持 ↑ / ↓ 快速切换历史输入,并可编辑后重新发送
|
||||
- 按使用场景保存并恢复会话,切换不串内容
|
||||
- 连续操作过程展示更清晰
|
||||
- 新增更多提示词与随机推荐,获取灵感更方便
|
||||
- 文件能力增强:支持读取文本内容与大文件分段读取
|
||||
- 搜索能力提升:支持更灵活的匹配与关键词查找
|
||||
- 新增 AI 协助部分前端操作
|
||||
- 新建菜单优化,新增 AI 助手快捷入口
|
||||
- 优化内容逐步输出时的加载提示显示
|
||||
|
||||
### 其他更新
|
||||
|
||||
- 修复弹窗、下拉菜单可能被遮挡的问题
|
||||
- 优化快捷键响应与事件处理,操作更流畅稳定
|
||||
- 优化其他已知问题
|
||||
|
||||
## [1.6.27]
|
||||
|
||||
### Features
|
||||
|
||||
- 新增全局悬浮AI入口,随时更快打开常用功能
|
||||
- 新增AI助手窗口模式,并能根据当前页面自动更贴合你的使用场景
|
||||
- 支持为不同场景设置AI自定义标题,界面展示更清晰
|
||||
- 同步失败时会自动重试,减少手动操作和中断
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复下载功能偶尔无法启动的问题
|
||||
- 修复深色模式下部分显示异常,并优化相关操作体验
|
||||
- 修复「@ 提及」下拉列表偶尔被遮挡的问题
|
||||
- 修复部分情况下连接判断不准确导致的问题
|
||||
|
||||
## [1.6.10]
|
||||
|
||||
### Features
|
||||
|
||||
- 新增“消息搜索”,找聊天记录更方便、更快
|
||||
- 搜索支持按对话筛选,快速定位到某个会话里的内容
|
||||
- 文件内容也能参与搜索,查资料更省事
|
||||
- AI 助手体验升级:支持回车快捷发送,并优化链接处理与界面使用感
|
||||
- 标签页功能增强:支持拖拽排序、拖拽合并插入到指定位置,且新增“更多”菜单入口
|
||||
- 文件管理界面改版:操作区域更清晰,使用更顺手
|
||||
- 语音转文字升级:识别更稳定,转写更顺畅
|
||||
- 复制任务/周期任务时可同时复制子任务,并自动重置状态,便于快速复用
|
||||
- 打卡支持跨天,并增加时间重叠提醒,记录更准确
|
||||
- 审批详情支持更清晰的换行显示,阅读更轻松
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复上传文件夹时可能卡死的问题
|
||||
- 修复关闭窗口/标签页时未保存内容提示失效的问题,避免误丢内容
|
||||
- 修复多标签页关闭后可能引发崩溃的问题
|
||||
- 修复跨项目移动任务后,子任务状态未同步更新的问题
|
||||
- 修复权限同步不完整导致的异常问题
|
||||
- 优化会话列表待办完成提示,显示实际最后完成的人
|
||||
|
||||
### Performance
|
||||
|
||||
- 标签页加载与预加载优化,打开网页/切换标签更顺畅
|
||||
- 常用图标加载优化,显示更快、更稳定
|
||||
- 搜索与 AI 相关流程优化,整体响应更快
|
||||
|
||||
## [1.5.18]
|
||||
|
||||
### Features
|
||||
|
||||
- 搜索更好用:可通过 ID、名称等信息快速找到任务、文件和报告
|
||||
- 可设置默认优先级,新建任务更省心
|
||||
- 斜杠命令体验提升:输入更规范,支持更多指令,并在输入时提供更准确的触发与提示
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复部分情况下会弹出空白窗口的问题
|
||||
- 修复提及列表在某些场景下显示异常的问题
|
||||
- 修复行前缀识别不准导致空行判断错误的问题
|
||||
|
||||
### Performance
|
||||
|
||||
- 提升数据处理效率,任务与消息相关操作更流畅
|
||||
|
||||
## [1.5.5]
|
||||
|
||||
### Features
|
||||
|
||||
50
CLAUDE.md
Normal file
50
CLAUDE.md
Normal file
@@ -0,0 +1,50 @@
|
||||
## 项目概述
|
||||
|
||||
Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
|
||||
|
||||
## 开发命令
|
||||
|
||||
所有命令通过 `./cmd` 脚本执行(不要直接运行 `php artisan` 等):
|
||||
|
||||
- `./cmd dev` — 前端开发服务器(Node.js 20+)
|
||||
- `./cmd prod` — 构建前端生产版本
|
||||
- `./cmd artisan ...` / `./cmd composer ...` / `./cmd php ...` — PHP 相关命令
|
||||
|
||||
## Gotchas
|
||||
|
||||
### LaravelS/Swoole
|
||||
|
||||
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
|
||||
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
|
||||
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
|
||||
- 长生命周期逻辑(WebSocket、定时器)应复用现有模式,避免阻塞协程/事件循环
|
||||
|
||||
### 后端
|
||||
|
||||
- **非 REST 路由**:所有 API 通过 `Route::any('api/{resource}/{method}')` 路由到 `InvokeController`,URL 段映射为控制器方法(如 `api/project/lists` → `lists()`,带 action 则用双下划线:`api/project/invite/join` → `invite__join()`)
|
||||
- **响应格式**:统一使用 `Base::retSuccess($msg, $data)` / `Base::retError($msg)`,返回 `{"ret": 1, "msg": "...", "data": {...}}`——不要用 `response()->json()`
|
||||
- 业务异常通过 `App\Exceptions\ApiException` 抛出,不要用通用 Exception
|
||||
- 模型继承 `AbstractModel`,使用 `Model::createInstance($params)` 创建——不要用 `new Model()` 或 `Model::create()`
|
||||
- 认证使用 `Doo::userId()`——不要用 `auth()->user()`
|
||||
- 参数校验在控制器方法中手动进行——不要创建 FormRequest 类
|
||||
- 异步任务使用 Swoole Task(`app/Tasks/`)——不要用 Laravel Queue
|
||||
- `app/Module/` 存放跨控制器/跨模型的业务逻辑(非标准 Laravel 目录)
|
||||
- 所有表结构变更必须通过 Laravel migration,禁止直接改库
|
||||
|
||||
### 前端
|
||||
|
||||
- API 调用使用 `store.dispatch("call", params)`,不要在组件中直接 axios/fetch
|
||||
- `$A.modalXXX`、`$A.messageXXX`、`$A.noticeXXX` 内部自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当传入 `language: false` 时由调用方自行处理翻译
|
||||
|
||||
### 国际化
|
||||
|
||||
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
|
||||
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
|
||||
|
||||
## 交互规范
|
||||
|
||||
- **提问时附带建议**:当需要向用户提问或请求澄清时,应同时提供具体的建议选项或推荐方案,帮助用户快速决策,而非仅抛出开放式问题
|
||||
|
||||
## 语言偏好
|
||||
|
||||
- 技术总结和关键结论优先使用简体中文,除非用户明确要求其他语言
|
||||
205
app/Console/Commands/GenerateManticoreVectors.php
Normal file
205
app/Console/Commands/GenerateManticoreVectors.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\File;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
use App\Module\Manticore\ManticoreProject;
|
||||
use App\Module\Manticore\ManticoreTask;
|
||||
use App\Module\Manticore\ManticoreUser;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 异步向量生成命令
|
||||
*
|
||||
* 用于后台批量生成已索引数据的向量,与全文索引解耦
|
||||
* 使用双指针追踪:sync:xxxLastId(全文已同步)和 vector:xxxLastId(向量已生成)
|
||||
*
|
||||
* 运行模式:
|
||||
* - 持续处理直到所有待处理数据完成
|
||||
* - 每批处理完成后休眠几秒,避免 API 过载
|
||||
* - 定时器只作为兜底触发机制
|
||||
*/
|
||||
class GenerateManticoreVectors extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
protected $signature = 'manticore:generate-vectors
|
||||
{--type=all : 类型 (msg/file/task/project/user/all)}
|
||||
{--batch=50 : 每批 embedding 数量}
|
||||
{--sleep=3 : 每批处理后休眠秒数}
|
||||
{--reset : 重置向量进度指针}';
|
||||
|
||||
protected $description = '批量生成 Manticore 已索引数据的向量';
|
||||
|
||||
/**
|
||||
* 类型配置
|
||||
*/
|
||||
private const TYPE_CONFIG = [
|
||||
'msg' => [
|
||||
'syncKey' => 'sync:manticoreMsgLastId',
|
||||
'vectorKey' => 'vector:manticoreMsgLastId',
|
||||
'class' => ManticoreMsg::class,
|
||||
'model' => WebSocketDialogMsg::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'file' => [
|
||||
'syncKey' => 'sync:manticoreFileLastId',
|
||||
'vectorKey' => 'vector:manticoreFileLastId',
|
||||
'class' => ManticoreFile::class,
|
||||
'model' => File::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'task' => [
|
||||
'syncKey' => 'sync:manticoreTaskLastId',
|
||||
'vectorKey' => 'vector:manticoreTaskLastId',
|
||||
'class' => ManticoreTask::class,
|
||||
'model' => ProjectTask::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'project' => [
|
||||
'syncKey' => 'sync:manticoreProjectLastId',
|
||||
'vectorKey' => 'vector:manticoreProjectLastId',
|
||||
'class' => ManticoreProject::class,
|
||||
'model' => Project::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'user' => [
|
||||
'syncKey' => 'sync:manticoreUserLastId',
|
||||
'vectorKey' => 'vector:manticoreUserLastId',
|
||||
'class' => ManticoreUser::class,
|
||||
'model' => User::class,
|
||||
'idField' => 'userid',
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("ai")) {
|
||||
$this->error("应用「AI」未安装,无法生成向量");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$type = $this->option('type');
|
||||
$batchSize = intval($this->option('batch'));
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$reset = $this->option('reset');
|
||||
|
||||
if ($type === 'all') {
|
||||
$types = array_keys(self::TYPE_CONFIG);
|
||||
} else {
|
||||
if (!isset(self::TYPE_CONFIG[$type])) {
|
||||
$this->error("未知类型: {$type}。可用类型: msg, file, task, project, user, all");
|
||||
$this->releaseLock();
|
||||
return 1;
|
||||
}
|
||||
$types = [$type];
|
||||
}
|
||||
|
||||
// 持续处理直到所有类型都没有待处理数据
|
||||
$round = 0;
|
||||
do {
|
||||
$round++;
|
||||
$totalPending = 0;
|
||||
|
||||
foreach ($types as $t) {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
$pending = $this->processType($t, $batchSize, $reset && $round === 1);
|
||||
$totalPending += $pending;
|
||||
}
|
||||
|
||||
// 如果还有待处理数据,休眠后继续
|
||||
if ($totalPending > 0 && !$this->shouldStop) {
|
||||
$this->info("\n--- 第 {$round} 轮完成,剩余 {$totalPending} 条待处理,{$sleepSeconds} 秒后继续 ---\n");
|
||||
sleep($sleepSeconds);
|
||||
$this->setLock(); // 刷新锁
|
||||
}
|
||||
} while ($totalPending > 0 && !$this->shouldStop);
|
||||
|
||||
$this->info("\n向量生成完成(共 {$round} 轮)");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个类型的向量生成(每次处理一批)
|
||||
*
|
||||
* @param string $type 类型
|
||||
* @param int $batchSize 每批数量
|
||||
* @param bool $reset 是否重置进度
|
||||
* @return int 剩余待处理数量
|
||||
*/
|
||||
private function processType(string $type, int $batchSize, bool $reset): int
|
||||
{
|
||||
$config = self::TYPE_CONFIG[$type];
|
||||
|
||||
// 获取进度指针
|
||||
$syncLastId = intval(ManticoreKeyValue::get($config['syncKey'], 0));
|
||||
$vectorLastId = $reset ? 0 : intval(ManticoreKeyValue::get($config['vectorKey'], 0));
|
||||
|
||||
if ($reset) {
|
||||
ManticoreKeyValue::set($config['vectorKey'], 0);
|
||||
$this->info("[{$type}] 已重置向量进度指针");
|
||||
}
|
||||
|
||||
// 计算待处理范围
|
||||
$pendingCount = $syncLastId - $vectorLastId;
|
||||
if ($pendingCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 获取待处理的 ID 列表(每次处理 batchSize * 5 条,让 generateVectorsBatch 内部再分批调用 API)
|
||||
$modelClass = $config['model'];
|
||||
$idField = $config['idField'];
|
||||
$fetchCount = $batchSize * 5;
|
||||
|
||||
$ids = $modelClass::where($idField, '>', $vectorLastId)
|
||||
->where($idField, '<=', $syncLastId)
|
||||
->orderBy($idField)
|
||||
->limit($fetchCount)
|
||||
->pluck($idField)
|
||||
->toArray();
|
||||
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 批量生成向量
|
||||
$manticoreClass = $config['class'];
|
||||
$successCount = $manticoreClass::generateVectorsBatch($ids, $batchSize);
|
||||
|
||||
$currentLastId = end($ids);
|
||||
|
||||
// 更新向量进度指针
|
||||
ManticoreKeyValue::set($config['vectorKey'], $currentLastId);
|
||||
|
||||
$remaining = $pendingCount - count($ids);
|
||||
$this->info("[{$type}] 处理 " . count($ids) . " 条,成功 {$successCount},ID: {$vectorLastId} -> {$currentLastId},剩余 {$remaining}");
|
||||
|
||||
// 刷新锁
|
||||
$this->setLock();
|
||||
|
||||
return max(0, $remaining);
|
||||
}
|
||||
}
|
||||
188
app/Console/Commands/RetryManticoreSync.php
Normal file
188
app/Console/Commands/RetryManticoreSync.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\File;
|
||||
use App\Models\ManticoreSyncFailure;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreBase;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
use App\Module\Manticore\ManticoreProject;
|
||||
use App\Module\Manticore\ManticoreTask;
|
||||
use App\Module\Manticore\ManticoreUser;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RetryManticoreSync extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
protected $signature = 'manticore:retry-failures {--limit=100 : 每次处理的最大数量} {--stats : 显示统计信息}';
|
||||
protected $description = '重试 Manticore 同步失败的记录';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 显示统计信息
|
||||
if ($this->option('stats')) {
|
||||
$this->showStats();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info('开始重试失败的同步任务...');
|
||||
|
||||
$limit = intval($this->option('limit'));
|
||||
$failures = ManticoreSyncFailure::getPendingRetries($limit);
|
||||
|
||||
if ($failures->isEmpty()) {
|
||||
$this->info('无待重试的记录');
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("找到 {$failures->count()} 条待重试记录");
|
||||
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
|
||||
foreach ($failures as $failure) {
|
||||
if ($this->shouldStop) {
|
||||
$this->info('收到停止信号,退出处理');
|
||||
break;
|
||||
}
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$result = $this->retryOne($failure);
|
||||
|
||||
if ($result) {
|
||||
$successCount++;
|
||||
$this->info(" [成功] {$failure->data_type}:{$failure->data_id} ({$failure->action})");
|
||||
} else {
|
||||
$failCount++;
|
||||
$this->warn(" [失败] {$failure->data_type}:{$failure->data_id} ({$failure->action}) - 第 {$failure->retry_count} 次");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("\n重试完成: 成功 {$successCount}, 失败 {$failCount}");
|
||||
$this->releaseLock();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试单条失败记录
|
||||
*/
|
||||
private function retryOne(ManticoreSyncFailure $failure): bool
|
||||
{
|
||||
$type = $failure->data_type;
|
||||
$id = $failure->data_id;
|
||||
$action = $failure->action;
|
||||
|
||||
try {
|
||||
if ($action === 'delete') {
|
||||
// 删除操作直接调用通用删除方法
|
||||
return ManticoreBase::deleteVector($type, $id);
|
||||
}
|
||||
|
||||
// sync 操作需要根据类型获取模型并同步
|
||||
return $this->retrySyncByType($type, $id);
|
||||
} catch (\Throwable $e) {
|
||||
// 记录失败(会自动更新重试次数和时间)
|
||||
ManticoreSyncFailure::recordFailure($type, $id, $action, $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型重试同步
|
||||
*/
|
||||
private function retrySyncByType(string $type, int $id): bool
|
||||
{
|
||||
switch ($type) {
|
||||
case 'msg':
|
||||
$model = WebSocketDialogMsg::find($id);
|
||||
if (!$model) {
|
||||
// 数据已删除,移除失败记录
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
|
||||
return true;
|
||||
}
|
||||
return ManticoreMsg::sync($model);
|
||||
|
||||
case 'file':
|
||||
$model = File::find($id);
|
||||
if (!$model) {
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
|
||||
return true;
|
||||
}
|
||||
return ManticoreFile::sync($model);
|
||||
|
||||
case 'task':
|
||||
$model = ProjectTask::find($id);
|
||||
if (!$model) {
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
|
||||
return true;
|
||||
}
|
||||
return ManticoreTask::sync($model);
|
||||
|
||||
case 'project':
|
||||
$model = Project::find($id);
|
||||
if (!$model) {
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
|
||||
return true;
|
||||
}
|
||||
return ManticoreProject::sync($model);
|
||||
|
||||
case 'user':
|
||||
$model = User::find($id);
|
||||
if (!$model) {
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
|
||||
return true;
|
||||
}
|
||||
return ManticoreUser::sync($model);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示统计信息
|
||||
*/
|
||||
private function showStats(): void
|
||||
{
|
||||
$stats = ManticoreSyncFailure::getStats();
|
||||
|
||||
$this->info('Manticore 同步失败统计:');
|
||||
$this->info(" 总数: {$stats['total']}");
|
||||
|
||||
if (!empty($stats['by_type'])) {
|
||||
$this->info(' 按类型:');
|
||||
foreach ($stats['by_type'] as $type => $count) {
|
||||
$this->info(" - {$type}: {$count}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($stats['by_action'])) {
|
||||
$this->info(' 按操作:');
|
||||
foreach ($stats['by_action'] as $action => $count) {
|
||||
$this->info(" - {$action}: {$count}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
155
app/Console/Commands/SyncFileToManticore.php
Normal file
155
app/Console/Commands/SyncFileToManticore.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\File;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncFileToManticore extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*
|
||||
* 其他选项
|
||||
* --sleep: 每批处理完成后休眠秒数
|
||||
*/
|
||||
|
||||
protected $signature = 'manticore:sync-files {--f} {--i} {--c} {--batch=100} {--sleep=3}';
|
||||
protected $description = '同步文件数据到 Manticore Search';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 清除索引
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ManticoreKeyValue::clear();
|
||||
ManticoreFile::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('开始同步文件数据...');
|
||||
$this->syncFiles();
|
||||
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步文件数据
|
||||
*/
|
||||
private function syncFiles(): void
|
||||
{
|
||||
$lastKey = "sync:manticoreFileLastId";
|
||||
$isIncremental = $this->option('i');
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$batchSize = $this->option('batch');
|
||||
$maxFileSize = ManticoreFile::getMaxFileSize();
|
||||
|
||||
$round = 0;
|
||||
|
||||
do {
|
||||
$round++;
|
||||
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($round === 1) {
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n增量同步文件数据(从ID {$lastId} 开始)...");
|
||||
} else {
|
||||
$this->info("\n全量同步文件数据...");
|
||||
}
|
||||
}
|
||||
|
||||
$count = File::where('id', '>', $lastId)
|
||||
->where('type', '!=', 'folder')
|
||||
->where('size', '<=', $maxFileSize)
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
if ($round === 1) {
|
||||
$this->info("无待同步数据");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->info("[第 {$round} 轮] 待同步 {$count} 个文件");
|
||||
|
||||
$num = 0;
|
||||
$total = 0;
|
||||
|
||||
do {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
$files = File::where('id', '>', $lastId)
|
||||
->where('type', '!=', 'folder')
|
||||
->where('size', '<=', $maxFileSize)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($files);
|
||||
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
|
||||
$this->info("{$num}/{$count} ({$progress}%) 文件ID {$files->first()->id} ~ {$files->last()->id}");
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$syncCount = ManticoreFile::batchSync($files);
|
||||
$total += $syncCount;
|
||||
|
||||
$lastId = $files->last()->id;
|
||||
ManticoreKeyValue::set($lastKey, $lastId);
|
||||
} while (count($files) == $batchSize && !$this->shouldStop);
|
||||
|
||||
$this->info("[第 {$round} 轮] 完成,同步 {$total} 个,最后ID {$lastId}");
|
||||
|
||||
if ($isIncremental && !$this->shouldStop) {
|
||||
$newCount = File::where('id', '>', $lastId)
|
||||
->where('type', '!=', 'folder')
|
||||
->where('size', '<=', $maxFileSize)
|
||||
->count();
|
||||
|
||||
if ($newCount > 0) {
|
||||
$this->info("发现 {$newCount} 个新文件,{$sleepSeconds} 秒后继续...");
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
} while (!$this->shouldStop);
|
||||
|
||||
$this->info("同步文件结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
|
||||
$this->info("已索引文件数量: " . ManticoreFile::getIndexedCount());
|
||||
}
|
||||
}
|
||||
232
app/Console/Commands/SyncMsgToManticore.php
Normal file
232
app/Console/Commands/SyncMsgToManticore.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncMsgToManticore extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*
|
||||
* 其他选项
|
||||
* --dialog: 指定对话ID
|
||||
* --sleep: 每批处理完成后休眠秒数
|
||||
*/
|
||||
|
||||
protected $signature = 'manticore:sync-msgs {--f} {--i} {--c} {--batch=100} {--dialog=} {--sleep=3}';
|
||||
protected $description = '同步消息数据到 Manticore Search';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 清除索引
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ManticoreMsg::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$dialogId = $this->option('dialog') ? intval($this->option('dialog')) : 0;
|
||||
|
||||
if ($dialogId > 0) {
|
||||
$this->info("开始同步对话 {$dialogId} 的消息数据...");
|
||||
$this->syncDialogMsgs($dialogId);
|
||||
} else {
|
||||
$this->info('开始同步消息数据...');
|
||||
$this->syncMsgs();
|
||||
}
|
||||
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步所有消息
|
||||
*/
|
||||
private function syncMsgs(): void
|
||||
{
|
||||
$lastKey = "sync:manticoreMsgLastId";
|
||||
$isIncremental = $this->option('i');
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$batchSize = $this->option('batch');
|
||||
|
||||
$round = 0;
|
||||
|
||||
// 持续处理循环(增量模式下)
|
||||
do {
|
||||
$round++;
|
||||
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($round === 1) {
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n增量同步消息数据(从ID {$lastId} 开始)...");
|
||||
} else {
|
||||
$this->info("\n全量同步消息数据...");
|
||||
}
|
||||
}
|
||||
|
||||
// 构建基础查询条件
|
||||
$count = WebSocketDialogMsg::where('id', '>', $lastId)
|
||||
->whereNull('deleted_at')
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
if ($round === 1) {
|
||||
$this->info("无待同步数据");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->info("[第 {$round} 轮] 待同步 {$count} 条消息");
|
||||
|
||||
$num = 0;
|
||||
$total = 0;
|
||||
|
||||
do {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
$msgs = WebSocketDialogMsg::where('id', '>', $lastId)
|
||||
->whereNull('deleted_at')
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($msgs->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($msgs);
|
||||
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
|
||||
$this->info("{$num}/{$count} ({$progress}%) 消息ID {$msgs->first()->id} ~ {$msgs->last()->id}");
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$syncCount = ManticoreMsg::batchSync($msgs);
|
||||
$total += $syncCount;
|
||||
|
||||
$lastId = $msgs->last()->id;
|
||||
ManticoreKeyValue::set($lastKey, $lastId);
|
||||
} while (count($msgs) == $batchSize && !$this->shouldStop);
|
||||
|
||||
$this->info("[第 {$round} 轮] 完成,同步 {$total} 条,最后ID {$lastId}");
|
||||
|
||||
// 增量模式下,检查是否有新数据,有则继续
|
||||
if ($isIncremental && !$this->shouldStop) {
|
||||
$newCount = WebSocketDialogMsg::where('id', '>', $lastId)
|
||||
->whereNull('deleted_at')
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
|
||||
->count();
|
||||
|
||||
if ($newCount > 0) {
|
||||
$this->info("发现 {$newCount} 条新数据,{$sleepSeconds} 秒后继续...");
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break; // 非增量模式或无新数据,退出循环
|
||||
|
||||
} while (!$this->shouldStop);
|
||||
|
||||
$this->info("同步消息结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
|
||||
$this->info("已索引消息数量: " . ManticoreMsg::getIndexedCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步指定对话的消息
|
||||
*
|
||||
* @param int $dialogId 对话ID
|
||||
*/
|
||||
private function syncDialogMsgs(int $dialogId): void
|
||||
{
|
||||
$this->info("\n同步对话 {$dialogId} 的消息数据...");
|
||||
|
||||
$baseQuery = WebSocketDialogMsg::where('dialog_id', $dialogId)
|
||||
->whereNull('deleted_at')
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES);
|
||||
|
||||
$num = 0;
|
||||
$count = $baseQuery->count();
|
||||
$batchSize = $this->option('batch');
|
||||
$lastId = 0;
|
||||
|
||||
$total = 0;
|
||||
$lastNum = 0;
|
||||
|
||||
do {
|
||||
$msgs = WebSocketDialogMsg::where('dialog_id', $dialogId)
|
||||
->where('id', '>', $lastId)
|
||||
->whereNull('deleted_at')
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($msgs->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($msgs);
|
||||
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
|
||||
if ($progress < 100) {
|
||||
$progress = number_format($progress, 2);
|
||||
}
|
||||
$this->info("{$num}/{$count} ({$progress}%) 正在同步消息ID {$msgs->first()->id} ~ {$msgs->last()->id} ({$total}|{$lastNum})");
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$lastNum = ManticoreMsg::batchSync($msgs);
|
||||
$total += $lastNum;
|
||||
|
||||
$lastId = $msgs->last()->id;
|
||||
} while (count($msgs) == $batchSize);
|
||||
|
||||
$this->info("同步对话 {$dialogId} 消息结束");
|
||||
$this->info("该对话已索引消息数量: " . \App\Module\Manticore\ManticoreBase::getDialogIndexedMsgCount($dialogId));
|
||||
}
|
||||
}
|
||||
146
app/Console/Commands/SyncProjectToManticore.php
Normal file
146
app/Console/Commands/SyncProjectToManticore.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\Project;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreProject;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncProjectToManticore extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*
|
||||
* 其他选项
|
||||
* --sleep: 每批处理完成后休眠秒数
|
||||
*/
|
||||
|
||||
protected $signature = 'manticore:sync-projects {--f} {--i} {--c} {--batch=100} {--sleep=3}';
|
||||
protected $description = '同步项目数据到 Manticore Search';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ManticoreProject::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('开始同步项目数据...');
|
||||
$this->syncProjects();
|
||||
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function syncProjects(): void
|
||||
{
|
||||
$lastKey = "sync:manticoreProjectLastId";
|
||||
$isIncremental = $this->option('i');
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$batchSize = $this->option('batch');
|
||||
|
||||
$round = 0;
|
||||
|
||||
do {
|
||||
$round++;
|
||||
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($round === 1) {
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n增量同步项目数据(从ID {$lastId} 开始)...");
|
||||
} else {
|
||||
$this->info("\n全量同步项目数据...");
|
||||
}
|
||||
}
|
||||
|
||||
$count = Project::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
if ($round === 1) {
|
||||
$this->info("无待同步数据");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->info("[第 {$round} 轮] 待同步 {$count} 个项目");
|
||||
|
||||
$num = 0;
|
||||
$total = 0;
|
||||
|
||||
do {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
$syncCount = ManticoreProject::batchSync($projects);
|
||||
$total += $syncCount;
|
||||
|
||||
$lastId = $projects->last()->id;
|
||||
ManticoreKeyValue::set($lastKey, $lastId);
|
||||
} while (count($projects) == $batchSize && !$this->shouldStop);
|
||||
|
||||
$this->info("[第 {$round} 轮] 完成,同步 {$total} 个,最后ID {$lastId}");
|
||||
|
||||
if ($isIncremental && !$this->shouldStop) {
|
||||
$newCount = Project::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->count();
|
||||
|
||||
if ($newCount > 0) {
|
||||
$this->info("发现 {$newCount} 个新项目,{$sleepSeconds} 秒后继续...");
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
} while (!$this->shouldStop);
|
||||
|
||||
$this->info("同步项目结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
|
||||
$this->info("已索引项目数量: " . ManticoreProject::getIndexedCount());
|
||||
}
|
||||
}
|
||||
149
app/Console/Commands/SyncTaskToManticore.php
Normal file
149
app/Console/Commands/SyncTaskToManticore.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreTask;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncTaskToManticore extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*
|
||||
* 其他选项
|
||||
* --sleep: 每批处理完成后休眠秒数
|
||||
*/
|
||||
|
||||
protected $signature = 'manticore:sync-tasks {--f} {--i} {--c} {--batch=100} {--sleep=3}';
|
||||
protected $description = '同步任务数据到 Manticore Search';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ManticoreTask::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('开始同步任务数据...');
|
||||
$this->syncTasks();
|
||||
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function syncTasks(): void
|
||||
{
|
||||
$lastKey = "sync:manticoreTaskLastId";
|
||||
$isIncremental = $this->option('i');
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$batchSize = $this->option('batch');
|
||||
|
||||
$round = 0;
|
||||
|
||||
do {
|
||||
$round++;
|
||||
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($round === 1) {
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n增量同步任务数据(从ID {$lastId} 开始)...");
|
||||
} else {
|
||||
$this->info("\n全量同步任务数据...");
|
||||
}
|
||||
}
|
||||
|
||||
$count = ProjectTask::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
if ($round === 1) {
|
||||
$this->info("无待同步数据");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->info("[第 {$round} 轮] 待同步 {$count} 个任务");
|
||||
|
||||
$num = 0;
|
||||
$total = 0;
|
||||
|
||||
do {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
$tasks = ProjectTask::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->whereNull('deleted_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();
|
||||
|
||||
$syncCount = ManticoreTask::batchSync($tasks);
|
||||
$total += $syncCount;
|
||||
|
||||
$lastId = $tasks->last()->id;
|
||||
ManticoreKeyValue::set($lastKey, $lastId);
|
||||
} while (count($tasks) == $batchSize && !$this->shouldStop);
|
||||
|
||||
$this->info("[第 {$round} 轮] 完成,同步 {$total} 个,最后ID {$lastId}");
|
||||
|
||||
if ($isIncremental && !$this->shouldStop) {
|
||||
$newCount = ProjectTask::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
if ($newCount > 0) {
|
||||
$this->info("发现 {$newCount} 个新任务,{$sleepSeconds} 秒后继续...");
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
} while (!$this->shouldStop);
|
||||
|
||||
$this->info("同步任务结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
|
||||
$this->info("已索引任务数量: " . ManticoreTask::getIndexedCount());
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\ZincSearch\ZincSearchKeyValue;
|
||||
use App\Module\ZincSearch\ZincSearchDialogMsg;
|
||||
use Cache;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncUserMsgToZincSearch extends Command
|
||||
{
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新(从上次更新的最后一个ID接上)
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*/
|
||||
|
||||
protected $signature = 'zinc:sync-user-msg {--f} {--i} {--c} {--batch=1000}';
|
||||
protected $description = '同步聊天会话用户和消息到 ZincSearch';
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「ZincSearch」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 注册信号处理器(仅在支持pcntl扩展的环境下)
|
||||
if (extension_loaded('pcntl')) {
|
||||
pcntl_async_signals(true); // 启用异步信号处理
|
||||
pcntl_signal(SIGINT, [$this, 'handleSignal']); // Ctrl+C
|
||||
pcntl_signal(SIGTERM, [$this, 'handleSignal']); // kill
|
||||
}
|
||||
|
||||
// 检查锁,如果已被占用则退出
|
||||
$lockInfo = $this->getLock();
|
||||
if ($lockInfo) {
|
||||
$this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 设置锁
|
||||
$this->setLock();
|
||||
|
||||
// 清除索引
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ZincSearchKeyValue::clear();
|
||||
ZincSearchDialogMsg::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('开始同步聊天数据...');
|
||||
|
||||
// 同步消息数据
|
||||
$this->syncDialogMsgs();
|
||||
|
||||
// 完成
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取锁信息
|
||||
*
|
||||
* @return array|null 如果锁存在返回锁信息,否则返回null
|
||||
*/
|
||||
private function getLock(): ?array
|
||||
{
|
||||
$lockKey = md5($this->signature);
|
||||
return Cache::has($lockKey) ? Cache::get($lockKey) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置锁
|
||||
*/
|
||||
private function setLock(): void
|
||||
{
|
||||
$lockKey = md5($this->signature);
|
||||
$lockInfo = [
|
||||
'started_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
Cache::put($lockKey, $lockInfo, 300); // 5分钟
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放锁
|
||||
*/
|
||||
private function releaseLock(): void
|
||||
{
|
||||
$lockKey = md5($this->signature);
|
||||
Cache::forget($lockKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理终端信号
|
||||
*
|
||||
* @param int $signal
|
||||
* @return void
|
||||
*/
|
||||
public function handleSignal(int $signal): void
|
||||
{
|
||||
// 释放锁
|
||||
$this->releaseLock();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步消息数据
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function syncDialogMsgs(): void
|
||||
{
|
||||
// 获取上次同步的最后ID
|
||||
$lastKey = "sync:dialogUserMsgLastId";
|
||||
$lastId = $this->option('i') ? intval(ZincSearchKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n同步消息数据({$lastId})...");
|
||||
} else {
|
||||
$this->info("\n同步消息数据...");
|
||||
}
|
||||
|
||||
$num = 0;
|
||||
$count = WebSocketDialogMsg::where('id', '>', $lastId)->count();
|
||||
$batchSize = $this->option('batch');
|
||||
|
||||
$total = 0;
|
||||
$lastNum = 0;
|
||||
|
||||
do {
|
||||
// 获取一批
|
||||
$dialogMsgs = WebSocketDialogMsg::where('id', '>', $lastId)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($dialogMsgs->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($dialogMsgs);
|
||||
$progress = round($num / $count * 100, 2);
|
||||
if ($progress < 100) {
|
||||
$progress = number_format($progress, 2);
|
||||
}
|
||||
$this->info("{$num}/{$count} ({$progress}%) 正在同步消息ID {$dialogMsgs->first()->id} ~ {$dialogMsgs->last()->id} ({$total}|{$lastNum})");
|
||||
|
||||
// 刷新锁
|
||||
$this->setLock();
|
||||
|
||||
// 同步数据
|
||||
$lastNum = ZincSearchDialogMsg::batchSync($dialogMsgs);
|
||||
$total += $lastNum;
|
||||
|
||||
// 更新最后ID
|
||||
$lastId = $dialogMsgs->last()->id;
|
||||
ZincSearchKeyValue::set($lastKey, $lastId);
|
||||
} while (count($dialogMsgs) == $batchSize);
|
||||
|
||||
$this->info("同步消息结束 - 最后ID {$lastId}");
|
||||
}
|
||||
}
|
||||
149
app/Console/Commands/SyncUserToManticore.php
Normal file
149
app/Console/Commands/SyncUserToManticore.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\User;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreUser;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncUserToManticore extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*
|
||||
* 其他选项
|
||||
* --sleep: 每批处理完成后休眠秒数
|
||||
*/
|
||||
|
||||
protected $signature = 'manticore:sync-users {--f} {--i} {--c} {--batch=100} {--sleep=3}';
|
||||
protected $description = '同步用户数据到 Manticore Search';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ManticoreUser::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('开始同步用户数据...');
|
||||
$this->syncUsers();
|
||||
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function syncUsers(): void
|
||||
{
|
||||
$lastKey = "sync:manticoreUserLastId";
|
||||
$isIncremental = $this->option('i');
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$batchSize = $this->option('batch');
|
||||
|
||||
$round = 0;
|
||||
|
||||
do {
|
||||
$round++;
|
||||
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($round === 1) {
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n增量同步用户数据(从ID {$lastId} 开始)...");
|
||||
} else {
|
||||
$this->info("\n全量同步用户数据...");
|
||||
}
|
||||
}
|
||||
|
||||
$count = User::where('userid', '>', $lastId)
|
||||
->where('bot', 0)
|
||||
->whereNull('disable_at')
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
if ($round === 1) {
|
||||
$this->info("无待同步数据");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->info("[第 {$round} 轮] 待同步 {$count} 个用户");
|
||||
|
||||
$num = 0;
|
||||
$total = 0;
|
||||
|
||||
do {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
$syncCount = ManticoreUser::batchSync($users);
|
||||
$total += $syncCount;
|
||||
|
||||
$lastId = $users->last()->userid;
|
||||
ManticoreKeyValue::set($lastKey, $lastId);
|
||||
} while (count($users) == $batchSize && !$this->shouldStop);
|
||||
|
||||
$this->info("[第 {$round} 轮] 完成,同步 {$total} 个,最后ID {$lastId}");
|
||||
|
||||
if ($isIncremental && !$this->shouldStop) {
|
||||
$newCount = User::where('userid', '>', $lastId)
|
||||
->where('bot', 0)
|
||||
->whereNull('disable_at')
|
||||
->count();
|
||||
|
||||
if ($newCount > 0) {
|
||||
$this->info("发现 {$newCount} 个新用户,{$sleepSeconds} 秒后继续...");
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
} while (!$this->shouldStop);
|
||||
|
||||
$this->info("同步用户结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
|
||||
$this->info("已索引用户数量: " . ManticoreUser::getIndexedCount());
|
||||
}
|
||||
}
|
||||
90
app/Console/Commands/Traits/ManticoreSyncLock.php
Normal file
90
app/Console/Commands/Traits/ManticoreSyncLock.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Traits;
|
||||
|
||||
use Cache;
|
||||
|
||||
/**
|
||||
* Manticore 同步命令通用锁机制
|
||||
*
|
||||
* 提供:
|
||||
* - 锁的获取、设置、释放
|
||||
* - 信号处理(优雅退出)
|
||||
* - 通用的命令初始化检查
|
||||
*/
|
||||
trait ManticoreSyncLock
|
||||
{
|
||||
private bool $shouldStop = false;
|
||||
|
||||
/**
|
||||
* 获取锁信息
|
||||
*/
|
||||
private function getLock(): ?array
|
||||
{
|
||||
$lockKey = $this->getLockKey();
|
||||
return Cache::has($lockKey) ? Cache::get($lockKey) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置锁(30分钟有效期,持续处理时需不断刷新)
|
||||
*/
|
||||
private function setLock(): void
|
||||
{
|
||||
$lockKey = $this->getLockKey();
|
||||
Cache::put($lockKey, ['started_at' => date('Y-m-d H:i:s')], 1800);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放锁
|
||||
*/
|
||||
private function releaseLock(): void
|
||||
{
|
||||
$lockKey = $this->getLockKey();
|
||||
Cache::forget($lockKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取锁的缓存键
|
||||
*/
|
||||
private function getLockKey(): string
|
||||
{
|
||||
return md5($this->signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 信号处理器(SIGINT/SIGTERM)
|
||||
*/
|
||||
public function handleSignal(int $signal): void
|
||||
{
|
||||
$this->info("\n收到信号,将在当前批次完成后退出...");
|
||||
$this->shouldStop = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册信号处理器
|
||||
*/
|
||||
private function registerSignalHandlers(): void
|
||||
{
|
||||
if (extension_loaded('pcntl')) {
|
||||
pcntl_async_signals(true);
|
||||
pcntl_signal(SIGINT, [$this, 'handleSignal']);
|
||||
pcntl_signal(SIGTERM, [$this, 'handleSignal']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查命令是否可以启动(锁检查)
|
||||
*
|
||||
* @return bool 返回 true 表示可以启动,false 表示已被占用
|
||||
*/
|
||||
private function acquireLock(): bool
|
||||
{
|
||||
$lockInfo = $this->getLock();
|
||||
if ($lockInfo) {
|
||||
$this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}");
|
||||
return false;
|
||||
}
|
||||
$this->setLock();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -70,4 +70,89 @@ class AssistantController extends AbstractController
|
||||
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/assistant/match-elements 元素向量匹配
|
||||
*
|
||||
* @apiDescription 通过向量相似度匹配页面元素,用于智能查找与查询语义相关的元素
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup assistant
|
||||
* @apiName match_elements
|
||||
*
|
||||
* @apiParam {String} query 搜索关键词
|
||||
* @apiParam {Array} elements 元素列表,每个元素包含 ref 和 name 字段
|
||||
* @apiParam {Number} [top_k=10] 返回的匹配数量,最大50
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {Array} data.matches 匹配结果数组,按相似度降序排列
|
||||
*/
|
||||
public function match_elements()
|
||||
{
|
||||
User::auth();
|
||||
|
||||
$query = trim(Request::input('query', ''));
|
||||
$elements = Request::input('elements', []);
|
||||
$topK = min(intval(Request::input('top_k', 10)), 50);
|
||||
|
||||
if (empty($query) || empty($elements)) {
|
||||
return Base::retError('参数不能为空');
|
||||
}
|
||||
|
||||
// 获取查询向量
|
||||
$queryResult = AI::getEmbedding($query);
|
||||
if (Base::isError($queryResult)) {
|
||||
return $queryResult;
|
||||
}
|
||||
$queryVector = $queryResult['data'];
|
||||
|
||||
// 计算相似度并排序
|
||||
$scored = [];
|
||||
foreach ($elements as $el) {
|
||||
$name = $el['name'] ?? '';
|
||||
if (empty($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$elResult = AI::getEmbedding($name);
|
||||
if (Base::isError($elResult)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$similarity = $this->cosineSimilarity($queryVector, $elResult['data']);
|
||||
$scored[] = [
|
||||
'element' => $el,
|
||||
'similarity' => $similarity,
|
||||
];
|
||||
}
|
||||
|
||||
// 按相似度降序排序
|
||||
usort($scored, fn($a, $b) => $b['similarity'] <=> $a['similarity']);
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'matches' => array_slice($scored, 0, $topK),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个向量的余弦相似度
|
||||
*/
|
||||
private function cosineSimilarity(array $a, array $b): float
|
||||
{
|
||||
$dotProduct = 0;
|
||||
$normA = 0;
|
||||
$normB = 0;
|
||||
$count = count($a);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$dotProduct += $a[$i] * $b[$i];
|
||||
$normA += $a[$i] * $a[$i];
|
||||
$normB += $b[$i] * $b[$i];
|
||||
}
|
||||
$denominator = sqrt($normA) * sqrt($normB);
|
||||
if ($denominator == 0) {
|
||||
return 0;
|
||||
}
|
||||
return $dotProduct / $denominator;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ use App\Module\TimeRange;
|
||||
use App\Module\MsgTool;
|
||||
use App\Models\FileContent;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\AbstractModel;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
@@ -32,7 +35,7 @@ use App\Models\WebSocketDialogMsgTranslate;
|
||||
use App\Models\WebSocketDialogSession;
|
||||
use App\Models\UserRecentItem;
|
||||
use App\Module\Table\OnlineData;
|
||||
use App\Module\ZincSearch\ZincSearchDialogMsg;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
|
||||
/**
|
||||
@@ -109,7 +112,8 @@ class DialogController extends AbstractController
|
||||
* @apiGroup dialog
|
||||
* @apiName search
|
||||
*
|
||||
* @apiParam {String} key 消息关键词
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [dialog_only] 仅搜索会话和联系人,不搜索消息内容(可选,传任意值启用)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -123,12 +127,17 @@ class DialogController extends AbstractController
|
||||
if (empty($key)) {
|
||||
return Base::retError('请输入搜索关键词');
|
||||
}
|
||||
$dialogOnly = Request::exists('dialog_only');
|
||||
// 搜索会话
|
||||
$take = 20;
|
||||
$list = WebSocketDialog::searchDialog($user->userid, $key, $take);
|
||||
// 搜索联系人
|
||||
if (count($list) < $take && Base::judgeClientVersion("0.21.60")) {
|
||||
$users = User::searchUser($key, $take - count($list));
|
||||
$users = User::select(User::$basicField)
|
||||
->searchByKeyword($key)
|
||||
->orderBy('userid')
|
||||
->take($take - count($list))
|
||||
->get();
|
||||
$users->transform(function (User $item) use ($user) {
|
||||
$id = 'u:' . $item->userid;
|
||||
$lastAt = null;
|
||||
@@ -153,9 +162,9 @@ class DialogController extends AbstractController
|
||||
});
|
||||
$list = array_merge($list, $users->toArray());
|
||||
}
|
||||
// 搜索消息会话
|
||||
if (count($list) < $take) {
|
||||
$searchResults = ZincSearchDialogMsg::search($user->userid, $key, 0, $take - count($list));
|
||||
// 搜索消息会话(仅当 dialog_only 未设置时)
|
||||
if (!$dialogOnly && count($list) < $take) {
|
||||
$searchResults = ManticoreMsg::searchDialogs($user->userid, $key, 0, $take - count($list));
|
||||
if ($searchResults) {
|
||||
foreach ($searchResults as $item) {
|
||||
if ($dialog = WebSocketDialog::find($item['id'])) {
|
||||
@@ -685,60 +694,6 @@ class DialogController extends AbstractController
|
||||
return Base::retSuccess('success', compact('data'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/search 搜索消息
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__search
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {Number} [dialog_id] 对话ID(存在则搜索消息在对话的位置)
|
||||
* @apiParam {Number} [take] 搜索数量
|
||||
* - dialog_id > 0, 默认:200,最大:200
|
||||
* - dialog_id <= 0, 默认:20,最大:50
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__search()
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$key = trim(Request::input('key'));
|
||||
$dialogId = intval(Request::input('dialog_id'));
|
||||
//
|
||||
if (empty($key)) {
|
||||
return Base::retError('关键词不能为空');
|
||||
}
|
||||
//
|
||||
if ($dialogId > 0) {
|
||||
// 搜索位置
|
||||
WebSocketDialog::checkDialog($dialogId);
|
||||
//
|
||||
$data = WebSocketDialogMsg::whereDialogId($dialogId)
|
||||
->where('key', 'LIKE', "%{$key}%")
|
||||
->take(Base::getPaginate(200, 200, 'take'))
|
||||
->pluck('id');
|
||||
return Base::retSuccess('success', compact('data'));
|
||||
} else {
|
||||
// 搜索消息
|
||||
$list = [];
|
||||
$searchResults = ZincSearchDialogMsg::search($user->userid, $key, 0, Base::getPaginate(50, 20, 'take'));
|
||||
if ($searchResults) {
|
||||
foreach ($searchResults as $item) {
|
||||
if ($dialog = WebSocketDialog::find($item['id'])) {
|
||||
$dialog = array_merge($dialog->toArray(), $item);
|
||||
$list[] = WebSocketDialog::synthesizeData($dialog, $user->userid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', ['data' => $list]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/one 获取单条消息
|
||||
*
|
||||
@@ -1360,11 +1315,7 @@ class DialogController extends AbstractController
|
||||
*
|
||||
* @apiParam {String} base64 语音base64
|
||||
* @apiParam {Number} duration 语音时长(毫秒)
|
||||
* @apiParam {String} [language] 识别语言
|
||||
* - 比如:zh
|
||||
* - 默认:自动识别
|
||||
* - 格式:符合 ISO_639 标准
|
||||
* - 此参数不一定起效果,AI会根据语音和language参考翻译识别结果
|
||||
* @apiParam {Number} [dialog_id] 会话ID,用于获取上下文提高识别准确率
|
||||
* @apiParam {String} [translate] 翻译识别结果
|
||||
* - 比如:zh
|
||||
* - 默认:不翻译结果
|
||||
@@ -1381,9 +1332,9 @@ class DialogController extends AbstractController
|
||||
//
|
||||
$path = "uploads/tmp/chat/" . date("Ym") . "/" . $user->userid . "/";
|
||||
$base64 = Request::input('base64');
|
||||
$language = Request::input('language');
|
||||
$translate = Request::input('translate');
|
||||
$duration = intval(Request::input('duration'));
|
||||
$dialogId = intval(Request::input('dialog_id'));
|
||||
if ($duration < 600) {
|
||||
return Base::retError('说话时间太短');
|
||||
}
|
||||
@@ -1396,17 +1347,35 @@ class DialogController extends AbstractController
|
||||
return Base::retError($data['msg']);
|
||||
}
|
||||
$recordData = $data['data'];
|
||||
// 构建上下文提示词
|
||||
$promptParts = [];
|
||||
if ($user->lang === 'zh') {
|
||||
$promptParts[] = "如果识别到中文,优先使用简体中文输出";
|
||||
} elseif ($user->lang === 'zh-CHT') {
|
||||
$promptParts[] = "如果識別到中文,優先使用繁體中文輸出";
|
||||
}
|
||||
// 获取最近的聊天上下文
|
||||
if ($dialogId > 0) {
|
||||
$contextTexts = WebSocketDialogMsg::whereDialogId($dialogId)
|
||||
->whereIn('type', ['text'])
|
||||
->orderByDesc('id')
|
||||
->limit(5)
|
||||
->get()
|
||||
->reverse()
|
||||
->map(fn($msg) => $msg->extractMessageContent(100))
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
if (!empty($contextTexts)) {
|
||||
$promptParts[] = "对话上下文:" . implode(";", $contextTexts) . "。";
|
||||
}
|
||||
}
|
||||
// 转文字
|
||||
$extParams = [];
|
||||
if ($language) {
|
||||
$extParams = [
|
||||
'language' => $language === 'zh-CHT' ? 'zh' : $language,
|
||||
'prompt' => "将此语音识别为“" . Doo::getLanguages($language) . "”。",
|
||||
];
|
||||
if (!empty($promptParts)) {
|
||||
$extParams['prompt'] = implode("\n\n", $promptParts);
|
||||
}
|
||||
$result = AI::transcriptions($recordData['file'], $extParams, [
|
||||
'accept-language' => Request::header('Accept-Language', 'zh')
|
||||
]);
|
||||
$result = AI::transcriptions($recordData['file'], $extParams);
|
||||
if (Base::isError($result)) {
|
||||
return $result;
|
||||
}
|
||||
@@ -1730,6 +1699,106 @@ class DialogController extends AbstractController
|
||||
return WebSocketDialogMsg::sendMsg(null, $dialog->id, 'text', $msgData, $botUser->userid, false, false, $silence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/send_ai_assistant 以AI助手身份发送消息到对话
|
||||
*
|
||||
* @apiDescription 需要token身份,以AI助手身份(userid=-1)发送消息到对话。支持两种方式:
|
||||
* 1. 通过 dialog_id 直接发送到指定对话
|
||||
* 2. 通过 task_id 发送到任务对话(自动创建对话如不存在)
|
||||
* 两个参数至少提供一个,同时提供时优先使用 dialog_id
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__send_ai_assistant
|
||||
*
|
||||
* @apiParam {Number} [dialog_id] 对话ID(与task_id二选一)
|
||||
* @apiParam {Number} [task_id] 任务ID(与dialog_id二选一,自动创建对话)
|
||||
* @apiParam {String} text 消息内容
|
||||
* @apiParam {String} [text_type=md] 消息格式:md 或 html
|
||||
* @apiParam {String} [silence=no] 是否静默发送:yes/no
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__send_ai_assistant()
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$task_id = intval(Request::input('task_id'));
|
||||
$text = trim(Request::input('text'));
|
||||
$text_type = strtolower(trim(Request::input('text_type'))) ?: 'md';
|
||||
$silence = in_array(strtolower(trim(Request::input('silence'))), ['yes', 'true', '1']);
|
||||
$markdown = in_array($text_type, ['md', 'markdown']);
|
||||
//
|
||||
if (empty($dialog_id) && empty($task_id)) {
|
||||
return Base::retError('dialog_id 或 task_id 至少提供一个');
|
||||
}
|
||||
if (empty($text)) {
|
||||
return Base::retError('消息内容不能为空');
|
||||
}
|
||||
if (mb_strlen($text) > 200000) {
|
||||
return Base::retError('消息内容最大不能超过200000字');
|
||||
}
|
||||
//
|
||||
if ($dialog_id) {
|
||||
// Direct dialog mode: verify user is a member
|
||||
WebSocketDialog::checkDialog($dialog_id);
|
||||
} else {
|
||||
// Task mode: resolve task -> dialog_id (auto-create if needed)
|
||||
$task = ProjectTask::find($task_id);
|
||||
if (!$task) {
|
||||
return Base::retError('任务不存在');
|
||||
}
|
||||
if (!ProjectUser::whereProjectId($task->project_id)->whereUserid($user->userid)->exists()) {
|
||||
return Base::retError('没有权限操作此任务');
|
||||
}
|
||||
// 任务可见性校验(与 task__one 一致)
|
||||
if ($task->visibility != 1) {
|
||||
$project_userid = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid');
|
||||
if ($user->userid != $project_userid) {
|
||||
$visibleUserids = array_merge(
|
||||
ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(),
|
||||
ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(),
|
||||
ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid')->toArray()
|
||||
);
|
||||
if (!in_array($user->userid, $visibleUserids)) {
|
||||
return Base::retError('没有权限操作此任务');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$task->dialog_id) {
|
||||
$dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task');
|
||||
if ($dialog) {
|
||||
$task->dialog_id = $dialog->id;
|
||||
$task->save();
|
||||
$task->pushMsg('dialog');
|
||||
} else {
|
||||
return Base::retError('无法创建任务对话');
|
||||
}
|
||||
}
|
||||
$dialog_id = $task->dialog_id;
|
||||
}
|
||||
//
|
||||
$msgData = ['text' => $text];
|
||||
if ($markdown) {
|
||||
$msgData['type'] = 'md';
|
||||
}
|
||||
//
|
||||
$result = WebSocketDialogMsg::sendMsg(
|
||||
null,
|
||||
$dialog_id,
|
||||
'text',
|
||||
$msgData,
|
||||
\App\Module\AiTaskSuggestion::AI_ASSISTANT_USERID,
|
||||
true, // push_self
|
||||
false, // push_retry
|
||||
$silence
|
||||
);
|
||||
//
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/sendlocation 发送位置消息
|
||||
*
|
||||
@@ -1998,10 +2067,15 @@ class DialogController extends AbstractController
|
||||
return Base::retSuccess("success", $msg);
|
||||
}
|
||||
WebSocketDialog::checkDialog($msg->dialog_id);
|
||||
// 根据用户语言构建提示词
|
||||
$extParams = [];
|
||||
if ($user->lang === 'zh') {
|
||||
$extParams['prompt'] = "如果识别到中文,优先使用简体中文输出";
|
||||
} elseif ($user->lang === 'zh-CHT') {
|
||||
$extParams['prompt'] = "如果識別到中文,優先使用繁體中文輸出";
|
||||
}
|
||||
//
|
||||
$result = AI::transcriptions(public_path($msgData['path']), [], [
|
||||
'accept-language' => Request::header('Accept-Language', 'zh')
|
||||
]);
|
||||
$result = AI::transcriptions(public_path($msgData['path']), $extParams);
|
||||
if (Base::isError($result)) {
|
||||
return $result;
|
||||
}
|
||||
@@ -2237,6 +2311,7 @@ class DialogController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$msg_ids = Request::input('msg_ids');
|
||||
$msg_id = intval(Request::input("msg_id"));
|
||||
$dialogids = Request::input('dialogids');
|
||||
$userids = Request::input('userids');
|
||||
@@ -2247,6 +2322,33 @@ class DialogController extends AbstractController
|
||||
return Base::retError("请选择对话或成员");
|
||||
}
|
||||
//
|
||||
// 支持批量逐条转发
|
||||
if (!empty($msg_ids) && is_array($msg_ids)) {
|
||||
if (count($msg_ids) > 100) {
|
||||
return Base::retError("最多转发100条消息");
|
||||
}
|
||||
$allMsgs = [];
|
||||
$msgs = WebSocketDialogMsg::whereIn('id', $msg_ids)->orderBy('created_at')->get();
|
||||
if ($msgs->isEmpty()) {
|
||||
return Base::retError("消息不存在或已被删除");
|
||||
}
|
||||
WebSocketDialog::checkDialog($msgs->first()->dialog_id);
|
||||
foreach ($msgs as $msg) {
|
||||
if (in_array($msg->type, WebSocketDialogMsg::$unforwardableTypes)) {
|
||||
continue;
|
||||
}
|
||||
$res = $msg->forwardMsg($dialogids, $userids, $user, $show_source, $leave_message);
|
||||
if (Base::isSuccess($res)) {
|
||||
$allMsgs = array_merge($allMsgs, $res['data']['msgs']);
|
||||
}
|
||||
// 留言只在第一条时发送,后续不再重复
|
||||
$leave_message = '';
|
||||
}
|
||||
return Base::retSuccess('转发成功', [
|
||||
'msgs' => $allMsgs
|
||||
]);
|
||||
}
|
||||
//
|
||||
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
|
||||
if (empty($msg)) {
|
||||
return Base::retError("消息不存在或已被删除");
|
||||
@@ -2256,6 +2358,98 @@ class DialogController extends AbstractController
|
||||
return $msg->forwardMsg($dialogids, $userids, $user, $show_source, $leave_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/mergeforward 合并转发消息
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__mergeforward
|
||||
*
|
||||
* @apiParam {Array} msg_ids 消息ID数组(最多100条)
|
||||
* @apiParam {Array} dialogids 转发给的对话ID
|
||||
* @apiParam {Array} userids 转发给的成员ID
|
||||
* @apiParam {Number} show_source 是否显示原发送者信息
|
||||
* @apiParam {String} leave_message 转发留言
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__mergeforward()
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$msg_ids = Request::input('msg_ids');
|
||||
$dialogids = Request::input('dialogids');
|
||||
$userids = Request::input('userids');
|
||||
$show_source = intval(Request::input("show_source"));
|
||||
$leave_message = Request::input('leave_message');
|
||||
//
|
||||
if (empty($dialogids) && empty($userids)) {
|
||||
return Base::retError("请选择对话或成员");
|
||||
}
|
||||
if (empty($msg_ids) || !is_array($msg_ids)) {
|
||||
return Base::retError("请选择要转发的消息");
|
||||
}
|
||||
if (count($msg_ids) > 100) {
|
||||
return Base::retError("最多转发100条消息");
|
||||
}
|
||||
//
|
||||
return WebSocketDialogMsg::mergeForwardMsg($msg_ids, $dialogids, $userids, $user, $show_source, $leave_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/mergedetail 合并转发消息详情
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__mergedetail
|
||||
*
|
||||
* @apiParam {Number} msg_id 合并转发消息ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__mergedetail()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
$msg_id = intval(Request::input('msg_id'));
|
||||
if ($msg_id <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
$dialogMsg = WebSocketDialogMsg::find($msg_id);
|
||||
if (!$dialogMsg || $dialogMsg->type !== 'merge-forward') {
|
||||
return Base::retError('消息不存在或已被删除');
|
||||
}
|
||||
WebSocketDialog::checkDialog($dialogMsg->dialog_id);
|
||||
//
|
||||
$msgData = Base::json2array($dialogMsg->getRawOriginal('msg'));
|
||||
$msgIds = $msgData['msg_ids'] ?? [];
|
||||
if (empty($msgIds)) {
|
||||
return Base::retError('消息不存在或已被删除');
|
||||
}
|
||||
$msgs = WebSocketDialogMsg::withTrashed()
|
||||
->whereIn('id', $msgIds)
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(function ($msg) {
|
||||
return [
|
||||
'id' => $msg->id,
|
||||
'userid' => $msg->userid,
|
||||
'type' => $msg->type,
|
||||
'msg' => $msg->msg,
|
||||
'created_at' => $msg->created_at->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
return Base::retSuccess('success', [
|
||||
'msgs' => $msgs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/emoji emoji回复
|
||||
*
|
||||
@@ -2439,8 +2633,8 @@ class DialogController extends AbstractController
|
||||
if ($msg) {
|
||||
$doneUserIds = WebSocketDialogMsgTodo::whereMsgId($msg->id)
|
||||
->whereNotNull('done_at')
|
||||
->orderBy('done_at')
|
||||
->orderBy('id')
|
||||
->orderByDesc('done_at')
|
||||
->orderByDesc('id')
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
//
|
||||
|
||||
@@ -14,8 +14,10 @@ use App\Models\User;
|
||||
use App\Models\UserRecentItem;
|
||||
use App\Module\Base;
|
||||
use App\Module\Down;
|
||||
use App\Module\Lock;
|
||||
use App\Module\Timer;
|
||||
use App\Module\Ihttp;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use Response;
|
||||
use Swoole\Coroutine;
|
||||
use Carbon\Carbon;
|
||||
@@ -67,6 +69,11 @@ class FileController extends AbstractController
|
||||
* @apiParam {String} [with_url] 是否返回文件访问URL
|
||||
* - no: 不返回(默认)
|
||||
* - yes: 返回content_url字段
|
||||
* @apiParam {String} [with_text] 是否提取文件文本内容(用于AI阅读,支持分页)
|
||||
* - no: 不提取(默认)
|
||||
* - yes: 提取文本内容,支持 docx/xlsx/pptx/pdf/txt 等格式
|
||||
* @apiParam {Number} [text_offset] with_text=yes时有效,文本起始位置(字符数),默认0
|
||||
* @apiParam {Number} [text_limit] with_text=yes时有效,文本获取长度(字符数),默认50000,最大200000
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -76,6 +83,9 @@ class FileController extends AbstractController
|
||||
{
|
||||
$id = Request::input('id');
|
||||
$with_url = Request::input('with_url', 'no');
|
||||
$with_text = Request::input('with_text', 'no');
|
||||
$text_offset = intval(Request::input('text_offset', 0));
|
||||
$text_limit = intval(Request::input('text_limit', 50000));
|
||||
//
|
||||
$permission = 0;
|
||||
if (Base::isNumber($id)) {
|
||||
@@ -111,20 +121,68 @@ class FileController extends AbstractController
|
||||
$array['content_url'] = FileContent::getFileUrl($file->id);
|
||||
}
|
||||
|
||||
// 如果请求提取文本内容
|
||||
if ($with_text === 'yes') {
|
||||
$array['text_content'] = ManticoreFile::extractFileContentPaginated($file, $text_offset, $text_limit);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $array);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/fetch 通过路径获取文件文本内容
|
||||
*
|
||||
* @apiDescription 用于 MCP/AI 工具通过文件路径获取内容,支持分页获取大文件
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName fetch
|
||||
*
|
||||
* @apiParam {String} path 文件路径(相对于系统根目录,如 uploads/file/...)
|
||||
* @apiParam {Number} [offset] 起始位置(字符数),默认0
|
||||
* @apiParam {Number} [limit] 获取长度(字符数),默认50000,最大200000
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* - content: 文本内容
|
||||
* - total_length: 完整内容总长度
|
||||
* - offset: 当前起始位置
|
||||
* - limit: 本次获取长度
|
||||
* - has_more: 是否还有更多内容
|
||||
*/
|
||||
public function fetch()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
$path = trim(Request::input('path'));
|
||||
$offset = intval(Request::input('offset', 0));
|
||||
$limit = intval(Request::input('limit', 50000));
|
||||
|
||||
if (empty($path)) {
|
||||
return Base::retError('参数错误:path 不能为空');
|
||||
}
|
||||
|
||||
// 直接传入路径,ManticoreFile 内部处理 URL 解析
|
||||
$result = ManticoreFile::extractFileContentPaginated($path, $offset, $limit);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return Base::retError($result['error']);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/search 搜索文件列表
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiDescription 需要token身份(仅搜索文件名,AI 内容搜索请使用 api/search/file)
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName search
|
||||
*
|
||||
* @apiParam {String} [link] 通过分享地址搜索(如:https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==)
|
||||
* @apiParam {String} [key] 关键词
|
||||
* @apiParam {Number} [take] 获取数量(默认:50,最大:100)
|
||||
* @apiParam {String} [link] 通过分享地址搜索(如:https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==)
|
||||
* @apiParam {String} [key] 关键词
|
||||
* @apiParam {Number} [take] 获取数量(默认:50,最大:100)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -145,6 +203,7 @@ class FileController extends AbstractController
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索自己的
|
||||
$builder = File::whereUserid($user->userid);
|
||||
if ($id) {
|
||||
@@ -152,7 +211,9 @@ class FileController extends AbstractController
|
||||
}
|
||||
if ($key) {
|
||||
if (!$id && Base::isNumber($key)) {
|
||||
$builder->where("id", $key);
|
||||
$builder->where(function ($query) use ($key) {
|
||||
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
}
|
||||
@@ -174,7 +235,13 @@ class FileController extends AbstractController
|
||||
$builder->where("id", $id);
|
||||
}
|
||||
if ($key) {
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
if (Base::isNumber($key)) {
|
||||
$builder->where(function ($query) use ($key) {
|
||||
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
}
|
||||
}
|
||||
$list = $builder->take($take)->get();
|
||||
if ($list->isNotEmpty()) {
|
||||
@@ -409,7 +476,7 @@ class FileController extends AbstractController
|
||||
throw new ApiException("{$file->name} 内含有共享文件,无法移动到另一个共享文件夹内");
|
||||
}
|
||||
$file->userid = $toShareFile->userid;
|
||||
File::where('pids', 'LIKE', "%,{$file->id},%")->update(['userid' => $toShareFile->userid]);
|
||||
$file->updateChildFilesUserid($toShareFile->userid);
|
||||
}
|
||||
//
|
||||
$tmpId = $pid;
|
||||
@@ -421,7 +488,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
} else {
|
||||
$file->userid = $user->userid;
|
||||
File::where('pids', 'LIKE', "%,{$file->id},%")->update(['userid' => $user->userid]);
|
||||
$file->updateChildFilesUserid($user->userid);
|
||||
}
|
||||
//
|
||||
$file->pid = $pid;
|
||||
@@ -754,10 +821,20 @@ class FileController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
$pid = intval(Request::input('pid'));
|
||||
$overwrite = intval(Request::input('cover'));
|
||||
$webkitRelativePath = Request::input('webkitRelativePath');
|
||||
$data = (new File)->contentUpload($user, $pid, $webkitRelativePath, $overwrite);
|
||||
return Base::retSuccess($data['data']['name'] . ' 上传成功', $data['addItem']);
|
||||
// 同一用户往相同父目录上传时排队,避免并发导致数据库死锁
|
||||
try {
|
||||
return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid) {
|
||||
$overwrite = intval(Request::input('cover'));
|
||||
$webkitRelativePath = Request::input('webkitRelativePath');
|
||||
$data = (new File)->contentUpload($user, $pid, $webkitRelativePath, $overwrite);
|
||||
return Base::retSuccess($data['data']['name'] . ' 上传成功', $data['addItem']);
|
||||
}, 120000, 120000);
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
|
||||
throw new ApiException('上传繁忙,请稍后再试');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,6 +45,8 @@ use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Models\ProjectTaskTemplate;
|
||||
use App\Models\ProjectTag;
|
||||
use App\Models\ProjectTaskRelation;
|
||||
use App\Models\ProjectTaskAiEvent;
|
||||
use App\Module\AiTaskSuggestion;
|
||||
use App\Observers\ProjectTaskObserver;
|
||||
|
||||
/**
|
||||
@@ -299,6 +301,7 @@ class ProjectController extends AbstractController
|
||||
* @apiParam {String} [desc] 项目介绍
|
||||
* @apiParam {String} [archive_method] 归档方式
|
||||
* @apiParam {Number} [archive_days] 自动归档天数
|
||||
* @apiParam {String} [ai_auto_analyze] AI自动分析(open|close)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -313,6 +316,7 @@ class ProjectController extends AbstractController
|
||||
$desc = trim(Request::input('desc', ''));
|
||||
$archive_method = Request::input('archive_method');
|
||||
$archive_days = intval(Request::input('archive_days'));
|
||||
$ai_auto_analyze = Request::input('ai_auto_analyze');
|
||||
if (mb_strlen($name) < 2) {
|
||||
return Base::retError('项目名称不可以少于2个字');
|
||||
} elseif (mb_strlen($name) > 32) {
|
||||
@@ -328,7 +332,7 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
//
|
||||
$project = Project::userProject($project_id, true, true);
|
||||
AbstractModel::transaction(function () use ($archive_days, $archive_method, $desc, $name, $project) {
|
||||
AbstractModel::transaction(function () use ($archive_days, $archive_method, $ai_auto_analyze, $desc, $name, $project) {
|
||||
if ($project->name != $name) {
|
||||
$project->addLog("修改项目名称", [
|
||||
'change' => [$project->name, $name]
|
||||
@@ -354,6 +358,12 @@ class ProjectController extends AbstractController
|
||||
]);
|
||||
$project->archive_days = $archive_days;
|
||||
}
|
||||
if (in_array($ai_auto_analyze, ['open', 'close']) && $project->ai_auto_analyze != $ai_auto_analyze) {
|
||||
$project->addLog("修改AI自动分析", [
|
||||
'change' => [$project->ai_auto_analyze, $ai_auto_analyze]
|
||||
]);
|
||||
$project->ai_auto_analyze = $ai_auto_analyze;
|
||||
}
|
||||
$project->save();
|
||||
});
|
||||
$project->pushMsg('update');
|
||||
@@ -991,10 +1001,16 @@ class ProjectController extends AbstractController
|
||||
* - keys.tag: 标签名称
|
||||
* - keys.status: 任务状态 (completed: 已完成、uncompleted: 未完成、flow-xx: 流程状态ID)
|
||||
*
|
||||
* @apiParam {Number} [project_id] 项目ID
|
||||
* @apiParam {Number} [parent_id] 主任务ID(project_id && parent_id ≤ 0 时 仅查询自己参与的任务)
|
||||
* - 大于0:指定主任务下的子任务
|
||||
* - 等于-1:表示仅主任务
|
||||
* @apiParam {Number} [project_id] 项目ID(传入后只查询该项目内任务)
|
||||
* @apiParam {Number} [parent_id] 主任务ID(查询优先级最高)
|
||||
* - 大于0:只查该主任务下的子任务(此时 archived 强制 all,忽略 project_id/scope)
|
||||
* - 等于-1:仅主任务(可与 project_id 组合)
|
||||
* @apiParam {String} [scope] 查询范围(仅在未指定 project_id 且 parent_id ≤ 0 时生效)
|
||||
* - all_project:查询“我参与的项目”下的所有任务(仍受可见性限制)
|
||||
* @apiParam {Number} [owner] 任务身份筛选(按当前登录用户在任务中的身份)
|
||||
* - 1:我负责的任务
|
||||
* - 0:我协助的任务
|
||||
* - 不传:不过滤(默认)
|
||||
*
|
||||
* @apiParam {String} [time] 指定时间范围,如:today, week, month, year, 2020-12-12,2020-12-30
|
||||
* - today: 今天
|
||||
@@ -1038,14 +1054,29 @@ class ProjectController extends AbstractController
|
||||
$deleted = Request::input('deleted', 'no');
|
||||
$keys = Request::input('keys');
|
||||
$sorts = Request::input('sorts');
|
||||
$scope = Request::input('scope');
|
||||
$owner = Request::input('owner');
|
||||
$owner = is_numeric($owner) ? intval($owner) : null;
|
||||
$keys = is_array($keys) ? $keys : [];
|
||||
$sorts = is_array($sorts) ? $sorts : [];
|
||||
$with_extend = array_filter(explode(',', Request::input('with_extend', '')));
|
||||
|
||||
$builder = ProjectTask::with(['taskUser', 'taskTag']);
|
||||
$withs = ['taskUser', 'taskTag'];
|
||||
if (in_array('project_name', $with_extend)) {
|
||||
$withs[] = 'project:id,name';
|
||||
}
|
||||
if (in_array('column_name', $with_extend)) {
|
||||
$withs[] = 'projectColumn:id,name';
|
||||
}
|
||||
$builder = ProjectTask::with($withs);
|
||||
//
|
||||
if ($keys['name']) {
|
||||
if (Base::isNumber($keys['name'])) {
|
||||
$builder->where("project_tasks.id", intval($keys['name']));
|
||||
$builder->where(function ($query) use ($keys) {
|
||||
$query->where("project_tasks.id", intval($keys['name']))
|
||||
->orWhere("project_tasks.name", "like", "%{$keys['name']}%")
|
||||
->orWhere("project_tasks.desc", "like", "%{$keys['name']}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where(function ($query) use ($keys) {
|
||||
$query->where("project_tasks.name", "like", "%{$keys['name']}%");
|
||||
@@ -1089,10 +1120,21 @@ class ProjectController extends AbstractController
|
||||
$scopeAll = true;
|
||||
$builder->where('project_tasks.project_id', $project_id);
|
||||
}
|
||||
if (!$scopeAll && $scope === 'all_project') {
|
||||
$scopeAll = true;
|
||||
$builder->whereIn('project_tasks.project_id', function ($query) use ($userid) {
|
||||
$query->select('project_id')
|
||||
->from('project_users')
|
||||
->where('userid', $userid);
|
||||
});
|
||||
}
|
||||
if ($scopeAll) {
|
||||
$builder->allData();
|
||||
if ($owner !== null) {
|
||||
$builder->where('project_task_users.owner', $owner);
|
||||
}
|
||||
} else {
|
||||
$builder->authData();
|
||||
$builder->authData(null, $owner);
|
||||
}
|
||||
//
|
||||
if ($name) {
|
||||
@@ -1221,6 +1263,14 @@ class ProjectController extends AbstractController
|
||||
unset($item['_sub_num']);
|
||||
unset($item['_sub_complete']);
|
||||
unset($item['_percent']);
|
||||
if (in_array('project_name', $with_extend)) {
|
||||
$item['project_name'] = $item['project']['name'] ?? '';
|
||||
unset($item['project']);
|
||||
}
|
||||
if (in_array('column_name', $with_extend)) {
|
||||
$item['column_name'] = $item['project_column']['name'] ?? '';
|
||||
unset($item['project_column']);
|
||||
}
|
||||
}
|
||||
//
|
||||
if ($list->currentPage() === 1) {
|
||||
@@ -1945,6 +1995,44 @@ class ProjectController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/task/related/delete 删除任务关联
|
||||
*
|
||||
* @apiDescription 需要token身份(限:项目、任务负责人)
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName task__related__delete
|
||||
*
|
||||
* @apiParam {Number} task_id 任务ID
|
||||
* @apiParam {Number} related_task_id 关联任务ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function task__related__delete()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
$task_id = intval(Request::input('task_id'));
|
||||
$related_task_id = intval(Request::input('related_task_id'));
|
||||
if ($task_id <= 0 || $related_task_id <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
//
|
||||
$task = ProjectTask::userTask($task_id);
|
||||
//
|
||||
$project = Project::userProject($task->project_id);
|
||||
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_UPDATE, $task);
|
||||
//
|
||||
$success = ProjectTaskRelation::deleteRelation($task_id, $related_task_id);
|
||||
if (!$success) {
|
||||
return Base::retError('关联不存在');
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('操作成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/task/content 获取任务详细描述
|
||||
*
|
||||
@@ -2243,6 +2331,7 @@ class ProjectController extends AbstractController
|
||||
return Base::retError('任务列表不存在或已被删除');
|
||||
}
|
||||
//
|
||||
$data = ProjectTask::normalizeTimes($data);
|
||||
$task = ProjectTask::addTask(array_merge($data, [
|
||||
'parent_id' => 0,
|
||||
'project_id' => $project->id,
|
||||
@@ -2364,7 +2453,7 @@ class ProjectController extends AbstractController
|
||||
$task->save();
|
||||
ProjectTaskUser::whereTaskId($task->id)->update(['task_pid' => $task->id]);
|
||||
if ($task->visibility == 3 && !empty($visibilityUserids)) {
|
||||
ProjectTaskVisibilityUser::whereTaskId($task->id)->delete();
|
||||
ProjectTaskVisibilityUser::whereTaskId($task->id)->remove();
|
||||
foreach (array_unique($visibilityUserids) as $userid) {
|
||||
if (!$userid) {
|
||||
continue;
|
||||
@@ -2480,6 +2569,7 @@ class ProjectController extends AbstractController
|
||||
$task_id = intval($param['task_id']);
|
||||
//
|
||||
$task = ProjectTask::userTask($task_id);
|
||||
$param = ProjectTask::normalizeTimes($param, $task);
|
||||
//
|
||||
if ($task->hasOwner()) {
|
||||
// 已经存在负责人,则需要检查权限(即:没有任务负责人时,不检查权限)
|
||||
@@ -3016,7 +3106,7 @@ class ProjectController extends AbstractController
|
||||
$taskTag->project_id = $project->id;
|
||||
$taskTag->save();
|
||||
}
|
||||
ProjectTaskUser::whereTaskId($copy->id)->delete();
|
||||
ProjectTaskUser::whereTaskId($copy->id)->remove();
|
||||
$copy->setRelation('taskUser', collect());
|
||||
$copy->setRelation('project', $project);
|
||||
$updateData = [
|
||||
@@ -3036,6 +3126,11 @@ class ProjectController extends AbstractController
|
||||
$copy->addLog('复制{任务}', [
|
||||
'copy_from' => $task->id,
|
||||
]);
|
||||
// 复制子任务
|
||||
$task->copySubTasks($copy, [
|
||||
'reset_complete' => true,
|
||||
'update_project' => true,
|
||||
]);
|
||||
return $copy;
|
||||
});
|
||||
//
|
||||
@@ -3801,4 +3896,158 @@ class ProjectController extends AbstractController
|
||||
->get();
|
||||
return Base::retSuccess('success', $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/task/ai_apply 采纳AI建议
|
||||
*
|
||||
* @apiDescription 标记AI建议为已采纳,返回建议数据供前端调用相应业务接口处理
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName task__ai_apply
|
||||
*
|
||||
* @apiParam {Number} task_id 任务ID
|
||||
* @apiParam {Number} msg_id 消息ID
|
||||
* @apiParam {String} type 建议类型:description/subtasks/assignee/similar
|
||||
* @apiParam {Number} [userid] 用户ID(assignee类型时用于指定采纳哪个推荐)
|
||||
* @apiParam {Number} [related] 关联任务ID(similar类型时用于指定采纳哪个相似任务)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {String} data.type 建议类型
|
||||
* @apiSuccess {Number} data.task_id 任务ID
|
||||
* @apiSuccess {Object} data.result 建议内容(格式根据type不同而异)
|
||||
*/
|
||||
public function task__ai_apply()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
$taskId = intval(Request::input('task_id'));
|
||||
$msgId = intval(Request::input('msg_id'));
|
||||
$type = trim(Request::input('type'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
$related = intval(Request::input('related'));
|
||||
|
||||
// 验证建议类型
|
||||
if (!in_array($type, ProjectTaskAiEvent::getEventTypes())) {
|
||||
return Base::retError('无效的建议类型');
|
||||
}
|
||||
|
||||
// 验证任务
|
||||
$task = ProjectTask::userTask($taskId);
|
||||
if (!$task) {
|
||||
return Base::retError('任务不存在或无权限');
|
||||
}
|
||||
|
||||
// 获取事件记录
|
||||
$event = ProjectTaskAiEvent::where('task_id', $taskId)
|
||||
->where('event_type', $type)
|
||||
->where('msg_id', $msgId)
|
||||
->first();
|
||||
|
||||
if (!$event) {
|
||||
return Base::retError('建议不存在');
|
||||
}
|
||||
|
||||
$result = $event->result;
|
||||
if (empty($result)) {
|
||||
return Base::retError('建议内容为空');
|
||||
}
|
||||
|
||||
// 标记事件为已采纳
|
||||
$event->markApplied();
|
||||
|
||||
// similar 类型:创建任务关联
|
||||
if ($type === 'similar' && $related > 0) {
|
||||
ProjectTaskRelation::createRelation(
|
||||
$taskId,
|
||||
$related,
|
||||
$task->dialog_id,
|
||||
$msgId,
|
||||
User::userid()
|
||||
);
|
||||
}
|
||||
|
||||
// 记录日志
|
||||
if ($type === 'assignee' && $userid > 0) {
|
||||
$user = User::find($userid);
|
||||
$task->addLog('AI建议:指派给 ' . ($user ? $user->nickname : $userid));
|
||||
} elseif ($type === 'similar' && $related > 0) {
|
||||
$task->addLog('AI建议:关联任务 #' . $related);
|
||||
} else {
|
||||
$task->addLog('AI建议:采纳' . $type . '建议');
|
||||
}
|
||||
|
||||
// 更新消息状态
|
||||
$msgResult = AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'applied', $userid, $related);
|
||||
|
||||
// 返回建议数据和消息内容
|
||||
return Base::retSuccess('已采纳', [
|
||||
'type' => $type,
|
||||
'task_id' => $taskId,
|
||||
'result' => $result,
|
||||
'msg' => $msgResult['data'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/task/ai_dismiss 忽略AI建议
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName task__ai_dismiss
|
||||
*
|
||||
* @apiParam {Number} task_id 任务ID
|
||||
* @apiParam {Number} msg_id 消息ID
|
||||
* @apiParam {String} type 建议类型
|
||||
* @apiParam {Number} [userid] 用户ID(assignee类型时用于忽略单个推荐)
|
||||
* @apiParam {Number} [related] 关联任务ID(similar类型时用于忽略单个推荐)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function task__ai_dismiss()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
$taskId = intval(Request::input('task_id'));
|
||||
$msgId = intval(Request::input('msg_id'));
|
||||
$type = trim(Request::input('type'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
$related = intval(Request::input('related'));
|
||||
|
||||
// 验证建议类型
|
||||
if (!in_array($type, ProjectTaskAiEvent::getEventTypes())) {
|
||||
return Base::retError('无效的建议类型');
|
||||
}
|
||||
|
||||
// 验证任务
|
||||
$task = ProjectTask::userTask($taskId);
|
||||
if (!$task) {
|
||||
return Base::retError('任务不存在或无权限');
|
||||
}
|
||||
|
||||
// 验证事件记录存在
|
||||
$event = ProjectTaskAiEvent::where('task_id', $taskId)
|
||||
->where('event_type', $type)
|
||||
->where('msg_id', $msgId)
|
||||
->first();
|
||||
|
||||
if (!$event) {
|
||||
return Base::retError('建议不存在');
|
||||
}
|
||||
|
||||
// 标记事件为已忽略
|
||||
$event->markDismissed();
|
||||
|
||||
// 更新消息状态
|
||||
$msgResult = AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'dismissed', $userid, $related);
|
||||
|
||||
// 返回消息内容
|
||||
return Base::retSuccess('已忽略', [
|
||||
'msg' => $msgResult['data'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ class ReportController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$builder = Report::with(['receivesUser'])->whereUserid($user->userid);
|
||||
$builder = Report::with(['receivesUser'])
|
||||
->select(Report::LIST_FIELDS)
|
||||
->whereUserid($user->userid);
|
||||
$keys = Request::input('keys');
|
||||
if (is_array($keys)) {
|
||||
if ($keys['key']) {
|
||||
@@ -59,6 +61,11 @@ class ReportController extends AbstractController
|
||||
$builder->whereHas('sendUser', function ($q2) use ($keys) {
|
||||
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
|
||||
});
|
||||
} elseif (Base::isNumber($keys['key'])) {
|
||||
$builder->where(function ($query) use ($keys) {
|
||||
$query->where("id", intval($keys['key']))
|
||||
->orWhere("title", "LIKE", "%{$keys['key']}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where("title", "LIKE", "%{$keys['key']}%");
|
||||
}
|
||||
@@ -99,7 +106,8 @@ class ReportController extends AbstractController
|
||||
public function receive(): array
|
||||
{
|
||||
$user = User::auth();
|
||||
$builder = Report::with(['receivesUser']);
|
||||
$builder = Report::with(['receivesUser'])
|
||||
->select(Report::LIST_FIELDS);
|
||||
$builder->whereHas("receivesUser", function ($query) use ($user) {
|
||||
$query->where("report_receives.userid", $user->userid);
|
||||
});
|
||||
@@ -111,7 +119,11 @@ class ReportController extends AbstractController
|
||||
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
|
||||
});
|
||||
} elseif (Base::isNumber($keys['key'])) {
|
||||
$builder->where("userid", intval($keys['key']));
|
||||
$builder->where(function ($query) use ($keys) {
|
||||
$query->where("userid", intval($keys['key']))
|
||||
->orWhere("id", intval($keys['key']))
|
||||
->orWhere("title", "LIKE", "%{$keys['key']}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where("title", "LIKE", "%{$keys['key']}%");
|
||||
}
|
||||
|
||||
619
app/Http/Controllers/Api/SearchController.php
Normal file
619
app/Http/Controllers/Api/SearchController.php
Normal file
@@ -0,0 +1,619 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use Request;
|
||||
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;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use App\Module\Manticore\ManticoreUser;
|
||||
use App\Module\Manticore\ManticoreProject;
|
||||
use App\Module\Manticore\ManticoreTask;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
|
||||
/**
|
||||
* @apiDefine search
|
||||
*
|
||||
* 智能搜索
|
||||
*/
|
||||
class SearchController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/search/contact 搜索联系人
|
||||
*
|
||||
* @apiDescription 需要token身份,优先使用 Manticore Search,未安装则使用 MySQL 搜索
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup search
|
||||
* @apiName contact
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 Manticore 有效)
|
||||
* @apiParam {Number} [take] 获取数量(默认:20,最大:50)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function contact()
|
||||
{
|
||||
User::auth();
|
||||
|
||||
$key = trim(Request::input('key'));
|
||||
$searchType = Request::input('search_type', 'hybrid');
|
||||
$take = Base::getPaginate(50, 20, 'take');
|
||||
|
||||
if (empty($key)) {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
|
||||
// 优先使用 Manticore 搜索
|
||||
if (Apps::isInstalled('search')) {
|
||||
$results = ManticoreUser::search($key, $searchType, $take);
|
||||
|
||||
// 补充用户完整信息
|
||||
$userids = array_column($results, 'userid');
|
||||
if (!empty($userids)) {
|
||||
$users = User::whereIn('userid', $userids)
|
||||
->select(User::$basicField)
|
||||
->get()
|
||||
->keyBy('userid');
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// MySQL 回退搜索
|
||||
$results = $this->searchContactByMysql($key, $take);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $results);
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索联系人
|
||||
*
|
||||
* @param string $key 搜索关键词
|
||||
* @param int $take 获取数量
|
||||
* @return array
|
||||
*/
|
||||
private function searchContactByMysql(string $key, int $take): array
|
||||
{
|
||||
$users = User::select(User::$basicField)
|
||||
->where('bot', 0)
|
||||
->whereNull('disable_at')
|
||||
->searchByKeyword($key)
|
||||
->orderByDesc('line_at')
|
||||
->take($take)
|
||||
->get();
|
||||
|
||||
// 获取用户标签
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/search/project 搜索项目
|
||||
*
|
||||
* @apiDescription 需要token身份,优先使用 Manticore Search,未安装则使用 MySQL 搜索
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup search
|
||||
* @apiName project
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 Manticore 有效)
|
||||
* @apiParam {Number} [take] 获取数量(默认:20,最大:50)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function project()
|
||||
{
|
||||
$user = User::auth();
|
||||
|
||||
$key = trim(Request::input('key'));
|
||||
$searchType = Request::input('search_type', 'hybrid');
|
||||
$take = Base::getPaginate(50, 20, 'take');
|
||||
|
||||
if (empty($key)) {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
|
||||
// 优先使用 Manticore 搜索
|
||||
if (Apps::isInstalled('search')) {
|
||||
$results = ManticoreProject::search($user->userid, $key, $searchType, $take);
|
||||
|
||||
// 补充项目完整信息
|
||||
$projectIds = array_column($results, 'project_id');
|
||||
if (!empty($projectIds)) {
|
||||
$projects = 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// MySQL 回退搜索
|
||||
$results = $this->searchProjectByMysql($user->userid, $key, $take);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $results);
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索项目
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $key 搜索关键词
|
||||
* @param int $take 获取数量
|
||||
* @return array
|
||||
*/
|
||||
private function searchProjectByMysql(int $userid, string $key, int $take): array
|
||||
{
|
||||
$projects = Project::authData()
|
||||
->whereNull('projects.archived_at')
|
||||
->searchByKeyword($key)
|
||||
->orderByDesc('projects.id')
|
||||
->take($take)
|
||||
->get();
|
||||
|
||||
return $projects->map(function ($project) {
|
||||
$array = $project->toArray();
|
||||
$array['relevance'] = 0;
|
||||
$array['desc_preview'] = null;
|
||||
return $array;
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/search/task 搜索任务
|
||||
*
|
||||
* @apiDescription 需要token身份,优先使用 Manticore Search,未安装则使用 MySQL 搜索
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup search
|
||||
* @apiName task
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 Manticore 有效)
|
||||
* @apiParam {Number} [take] 获取数量(默认:20,最大:50)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function task()
|
||||
{
|
||||
$user = User::auth();
|
||||
|
||||
$key = trim(Request::input('key'));
|
||||
$searchType = Request::input('search_type', 'hybrid');
|
||||
$take = Base::getPaginate(50, 20, 'take');
|
||||
|
||||
if (empty($key)) {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
|
||||
// 优先使用 Manticore 搜索
|
||||
if (Apps::isInstalled('search')) {
|
||||
$results = ManticoreTask::search($user->userid, $key, $searchType, $take);
|
||||
|
||||
// 补充任务完整信息
|
||||
$taskIds = array_column($results, 'task_id');
|
||||
if (!empty($taskIds)) {
|
||||
$tasks = ProjectTask::with(['taskUser', 'taskTag'])
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// MySQL 回退搜索
|
||||
$results = $this->searchTaskByMysql($user->userid, $key, $take);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $results);
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索任务
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $key 搜索关键词
|
||||
* @param int $take 获取数量
|
||||
* @return array
|
||||
*/
|
||||
private function searchTaskByMysql(int $userid, string $key, int $take): array
|
||||
{
|
||||
$tasks = ProjectTask::with(['taskUser', 'taskTag'])
|
||||
->whereIn('project_tasks.project_id', function ($query) use ($userid) {
|
||||
$query->select('project_id')
|
||||
->from('project_users')
|
||||
->where('userid', $userid);
|
||||
})
|
||||
->whereNull('project_tasks.archived_at')
|
||||
->whereNull('project_tasks.deleted_at')
|
||||
->searchByKeyword($key)
|
||||
->orderByDesc('project_tasks.id')
|
||||
->take($take)
|
||||
->get();
|
||||
|
||||
return $tasks->map(function ($task) {
|
||||
$array = $task->toArray();
|
||||
$array['relevance'] = 0;
|
||||
$array['desc_preview'] = null;
|
||||
$array['content_preview'] = null;
|
||||
return $array;
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/search/file 搜索文件
|
||||
*
|
||||
* @apiDescription 需要token身份,优先使用 Manticore Search,未安装则使用 MySQL 搜索
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup search
|
||||
* @apiName file
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 Manticore 有效)
|
||||
* @apiParam {Number} [take] 获取数量(默认:20,最大:50)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function file()
|
||||
{
|
||||
$user = User::auth();
|
||||
|
||||
$key = trim(Request::input('key'));
|
||||
$searchType = Request::input('search_type', 'hybrid');
|
||||
$take = Base::getPaginate(50, 20, 'take');
|
||||
|
||||
if (empty($key)) {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
|
||||
// 优先使用 Manticore 搜索
|
||||
if (Apps::isInstalled('search')) {
|
||||
$results = ManticoreFile::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', []);
|
||||
} else {
|
||||
// MySQL 回退搜索
|
||||
$results = $this->searchFileByMysql($user->userid, $key, $take);
|
||||
return Base::retSuccess('success', $results);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索文件
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $key 搜索关键词
|
||||
* @param int $take 获取数量
|
||||
* @return array
|
||||
*/
|
||||
private function searchFileByMysql(int $userid, string $key, int $take): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
// 搜索用户自己的文件
|
||||
$ownFiles = File::where('userid', $userid)
|
||||
->searchByKeyword($key)
|
||||
->take($take)
|
||||
->get();
|
||||
|
||||
foreach ($ownFiles as $file) {
|
||||
$results[] = array_merge($file->toArray(), [
|
||||
'relevance' => 0,
|
||||
'content_preview' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// 搜索共享给用户的文件
|
||||
$remaining = $take - count($results);
|
||||
if ($remaining > 0) {
|
||||
$sharedFiles = File::sharedToUser($userid)
|
||||
->searchByKeyword($key)
|
||||
->take($remaining)
|
||||
->get();
|
||||
|
||||
foreach ($sharedFiles as $file) {
|
||||
$temp = $file->toArray();
|
||||
if ($file->pshare === $file->id) {
|
||||
$temp['pid'] = 0;
|
||||
}
|
||||
$temp['relevance'] = 0;
|
||||
$temp['content_preview'] = null;
|
||||
$results[] = $temp;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/search/message 搜索消息
|
||||
*
|
||||
* @apiDescription 需要token身份,优先使用 Manticore Search,未安装则使用 MySQL 搜索
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup search
|
||||
* @apiName message
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 Manticore 有效)
|
||||
* @apiParam {Number} [take] 获取数量(默认:20,最大:50)
|
||||
* @apiParam {String} [mode] 返回模式(message/position/dialog,默认:message)
|
||||
* - message: 返回消息详细信息
|
||||
* - position: 只返回消息ID
|
||||
* - dialog: 返回对话级数据
|
||||
* @apiParam {Number} [dialog_id] 对话ID(筛选指定对话内的消息)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function message()
|
||||
{
|
||||
$user = User::auth();
|
||||
|
||||
$key = trim(Request::input('key'));
|
||||
$searchType = Request::input('search_type', 'hybrid');
|
||||
$take = Base::getPaginate(50, 20, 'take');
|
||||
$mode = Request::input('mode', 'message');
|
||||
$dialogId = intval(Request::input('dialog_id', 0));
|
||||
|
||||
// 验证 mode 参数
|
||||
if (!in_array($mode, ['message', 'position', 'dialog'])) {
|
||||
$mode = 'message';
|
||||
}
|
||||
|
||||
if (empty($key)) {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
|
||||
// 如果指定了 dialog_id,需要验证用户有权限访问该对话
|
||||
if ($dialogId > 0) {
|
||||
WebSocketDialog::checkDialog($dialogId);
|
||||
}
|
||||
|
||||
// 优先使用 Manticore 搜索
|
||||
if (Apps::isInstalled('search')) {
|
||||
$results = ManticoreMsg::search($user->userid, $key, $searchType, 0, $take, $dialogId);
|
||||
} else {
|
||||
// MySQL 回退搜索
|
||||
$results = $this->searchMessageByMysql($user->userid, $key, $take, $dialogId);
|
||||
}
|
||||
|
||||
// 根据 mode 返回不同格式的数据
|
||||
return $this->formatMessageResults($results, $mode, $user->userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索消息
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $key 搜索关键词
|
||||
* @param int $take 获取数量
|
||||
* @param int $dialogId 对话ID(0表示不限制)
|
||||
* @return array
|
||||
*/
|
||||
private function searchMessageByMysql(int $userid, string $key, int $take, int $dialogId = 0): array
|
||||
{
|
||||
$builder = WebSocketDialogMsg::select([
|
||||
'id as msg_id',
|
||||
'dialog_id',
|
||||
'userid',
|
||||
'type',
|
||||
'msg',
|
||||
'created_at',
|
||||
])
|
||||
->accessibleByUser($userid)
|
||||
->where('bot', 0)
|
||||
->searchByKeyword($key);
|
||||
|
||||
if ($dialogId > 0) {
|
||||
$builder->where('dialog_id', $dialogId);
|
||||
}
|
||||
|
||||
$items = $builder->orderByDesc('id')
|
||||
->limit($take)
|
||||
->get();
|
||||
|
||||
return $items->map(function ($item) {
|
||||
return [
|
||||
'msg_id' => $item->msg_id,
|
||||
'dialog_id' => $item->dialog_id,
|
||||
'userid' => $item->userid,
|
||||
'type' => $item->type,
|
||||
'msg' => $item->msg,
|
||||
'created_at' => $item->created_at,
|
||||
'relevance' => 0,
|
||||
'content_preview' => null,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化消息搜索结果
|
||||
*
|
||||
* @param array $results 搜索结果
|
||||
* @param string $mode 返回模式
|
||||
* @param int $userid 用户ID
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
private function formatMessageResults(array $results, string $mode, int $userid)
|
||||
{
|
||||
switch ($mode) {
|
||||
case 'position':
|
||||
// 只返回消息ID
|
||||
$data = array_column($results, 'msg_id');
|
||||
return Base::retSuccess('success', compact('data'));
|
||||
|
||||
case 'dialog':
|
||||
// 返回对话级数据
|
||||
$list = [];
|
||||
$seenDialogs = [];
|
||||
foreach ($results as $item) {
|
||||
$dialogIdFromResult = $item['dialog_id'];
|
||||
// 每个对话只返回一次
|
||||
if (isset($seenDialogs[$dialogIdFromResult])) {
|
||||
continue;
|
||||
}
|
||||
$seenDialogs[$dialogIdFromResult] = true;
|
||||
|
||||
if ($dialog = WebSocketDialog::find($dialogIdFromResult)) {
|
||||
$dialogData = array_merge($dialog->toArray(), [
|
||||
'search_msg_id' => $item['msg_id'],
|
||||
]);
|
||||
$list[] = WebSocketDialog::synthesizeData($dialogData, $userid);
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', ['data' => $list]);
|
||||
|
||||
case 'message':
|
||||
default:
|
||||
// 返回消息详细信息(默认行为)
|
||||
$msgIds = array_column($results, 'msg_id');
|
||||
if (!empty($msgIds)) {
|
||||
$msgs = WebSocketDialogMsg::whereIn('id', $msgIds)
|
||||
->with(['user' => function ($query) {
|
||||
$query->select(User::$basicField);
|
||||
}])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// 创建结果映射以保持原始顺序和额外字段
|
||||
$resultsMap = [];
|
||||
foreach ($results as $item) {
|
||||
$resultsMap[$item['msg_id']] = $item;
|
||||
}
|
||||
|
||||
$formattedResults = [];
|
||||
foreach ($msgIds as $msgId) {
|
||||
$msgData = $msgs->get($msgId);
|
||||
$originalItem = $resultsMap[$msgId] ?? [];
|
||||
if ($msgData) {
|
||||
$formattedResults[] = [
|
||||
'id' => $msgData->id,
|
||||
'msg_id' => $msgData->id,
|
||||
'dialog_id' => $msgData->dialog_id,
|
||||
'userid' => $msgData->userid,
|
||||
'type' => $msgData->type,
|
||||
'msg' => $msgData->msg,
|
||||
'created_at' => $msgData->created_at,
|
||||
'user' => $msgData->user,
|
||||
'relevance' => $originalItem['relevance'] ?? 0,
|
||||
'content_preview' => $originalItem['content_preview'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', $formattedResults);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ class SystemController extends AbstractController
|
||||
'file_upload_limit',
|
||||
'unclaimed_task_reminder',
|
||||
'unclaimed_task_reminder_time',
|
||||
'task_ai_auto_analyze',
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
}
|
||||
@@ -146,6 +147,7 @@ class SystemController extends AbstractController
|
||||
$setting['file_upload_limit'] = $setting['file_upload_limit'] ?: '';
|
||||
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
|
||||
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
|
||||
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open';
|
||||
$setting['server_timezone'] = config('app.timezone');
|
||||
$setting['server_version'] = Base::getVersion();
|
||||
//
|
||||
@@ -456,6 +458,24 @@ class SystemController extends AbstractController
|
||||
if ($all['modes']) {
|
||||
$all['modes'] = array_intersect($all['modes'], ['auto', 'manual', 'locat', 'face']);
|
||||
}
|
||||
// 验证提前和延后时间是否重叠(跨天打卡支持)
|
||||
if ($all['open'] === 'open') {
|
||||
$times = is_array($all['time']) ? $all['time'] : Base::json2array($all['time']);
|
||||
if (count($times) >= 2) {
|
||||
$startMinutes = intval(substr($times[0], 0, 2)) * 60 + intval(substr($times[0], 3, 2));
|
||||
$endMinutes = intval(substr($times[1], 0, 2)) * 60 + intval(substr($times[1], 3, 2));
|
||||
$shiftDuration = $endMinutes - $startMinutes;
|
||||
if ($shiftDuration <= 0) {
|
||||
$shiftDuration += 24 * 60; // 处理跨天班次
|
||||
}
|
||||
$advance = intval($all['advance']) ?: 120;
|
||||
$delay = intval($all['delay']) ?: 120;
|
||||
$maxAllowed = 24 * 60 - $shiftDuration;
|
||||
if ($advance + $delay >= $maxAllowed) {
|
||||
return Base::retError('提前和延后时间设置存在重叠,最大提前+延后时间不能超过 ' . ($maxAllowed - 1) . ' 分钟');
|
||||
}
|
||||
}
|
||||
}
|
||||
$setting = Base::setting('checkinSetting', Base::newTrim($all));
|
||||
} else {
|
||||
$setting = Base::setting('checkinSetting');
|
||||
@@ -697,27 +717,16 @@ class SystemController extends AbstractController
|
||||
if ($type == 'save') {
|
||||
User::auth('admin');
|
||||
$list = Request::input('list');
|
||||
$array = [];
|
||||
if (empty($list) || !is_array($list)) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
foreach ($list AS $item) {
|
||||
if (empty($item['name']) || empty($item['color']) || empty($item['priority'])) {
|
||||
continue;
|
||||
}
|
||||
$array[] = [
|
||||
'name' => $item['name'],
|
||||
'color' => $item['color'],
|
||||
'days' => intval($item['days']),
|
||||
'priority' => intval($item['priority']),
|
||||
];
|
||||
}
|
||||
$array = Setting::normalizeTaskPriorityList($list);
|
||||
if (empty($array)) {
|
||||
return Base::retError('参数为空');
|
||||
}
|
||||
$setting = Base::setting('priority', $array);
|
||||
} else {
|
||||
$setting = Base::setting('priority');
|
||||
$setting = Setting::normalizeTaskPriorityList(Base::setting('priority'));
|
||||
}
|
||||
//
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
|
||||
@@ -1282,6 +1291,8 @@ class SystemController extends AbstractController
|
||||
//
|
||||
$secondStart = strtotime("2000-01-01 {$time[0]}") - strtotime("2000-01-01 00:00:00");
|
||||
$secondEnd = strtotime("2000-01-01 {$time[1]}") - strtotime("2000-01-01 00:00:00");
|
||||
// 获取延后时间配置(用于跨天打卡导出)
|
||||
$delaySeconds = (intval($setting['delay']) ?: 120) * 60;
|
||||
//
|
||||
$botUser = User::botGetOrCreate('system-msg');
|
||||
if (empty($botUser)) {
|
||||
@@ -1290,7 +1301,7 @@ class SystemController extends AbstractController
|
||||
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
|
||||
//
|
||||
$doo = Doo::load();
|
||||
go(function () use ($doo, $secondStart, $secondEnd, $time, $userid, $date, $user, $botUser, $dialog) {
|
||||
go(function () use ($doo, $secondStart, $secondEnd, $time, $userid, $date, $user, $botUser, $dialog, $delaySeconds) {
|
||||
Coroutine::sleep(1);
|
||||
//
|
||||
$headings = [];
|
||||
@@ -1327,9 +1338,10 @@ class SystemController extends AbstractController
|
||||
$index++;
|
||||
$sameDate = date("Y-m-d", $startT);
|
||||
$sameTimes = $recordTimes[$sameDate] ?? [];
|
||||
$sameCollect = UserCheckinRecord::atCollect($sameDate, $sameTimes);
|
||||
$sameCollect = UserCheckinRecord::atCollect($sameDate, $sameTimes, $time[0]);
|
||||
$firstBetween = [Carbon::createFromTimestamp($startT), Carbon::createFromTimestamp($startT + $secondEnd - 1)];
|
||||
$lastBetween = [Carbon::createFromTimestamp($startT + $secondStart + 1), Carbon::createFromTimestamp($startT + 86400)];
|
||||
// 扩展下班打卡范围以支持跨天打卡
|
||||
$lastBetween = [Carbon::createFromTimestamp($startT + $secondStart + 1), Carbon::createFromTimestamp($startT + 86400 + $delaySeconds)];
|
||||
$firstRecord = $sameCollect?->whereBetween("datetime", $firstBetween)->first();
|
||||
$lastRecord = $sameCollect?->whereBetween("datetime", $lastBetween)->last();
|
||||
$firstTimestamp = $firstRecord['timestamp'] ?: 0;
|
||||
|
||||
@@ -37,7 +37,6 @@ use App\Models\UserRecentItem;
|
||||
use App\Models\UserTag;
|
||||
use App\Models\UserTagRecognition;
|
||||
use App\Models\UserAppSort;
|
||||
use App\Module\Apps;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\UserEmailVerification;
|
||||
use App\Module\AgoraIO\AgoraTokenGenerator;
|
||||
@@ -301,6 +300,8 @@ class UsersController extends AbstractController
|
||||
* @apiGroup users
|
||||
* @apiName token__expire
|
||||
*
|
||||
* @apiParam {Number} [refresh] 是否刷新 token(1=是),token 剩余有效期不足总有效期的 1/3 时才会刷新
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
@@ -308,10 +309,11 @@ class UsersController extends AbstractController
|
||||
* @apiSuccess {Number|null} data.remaining_seconds 距离过期剩余秒数(负值表示已过期)
|
||||
* @apiSuccess {Boolean} data.expired token 是否已过期
|
||||
* @apiSuccess {String} data.server_time 当前服务器时间
|
||||
* @apiSuccess {String} [data.token] 刷新后的新 token(仅当 refresh=1 且 token 即将过期时返回)
|
||||
*/
|
||||
public function token__expire()
|
||||
{
|
||||
User::auth();
|
||||
$user = User::auth();
|
||||
$expiredAt = Doo::userExpiredAt();
|
||||
$expired = Doo::userExpired();
|
||||
$expiredAtCarbon = $expiredAt ? Carbon::parse($expiredAt) : null;
|
||||
@@ -321,6 +323,14 @@ class UsersController extends AbstractController
|
||||
'expired' => $expired,
|
||||
'server_time' => Carbon::now()->toDateTimeString(),
|
||||
];
|
||||
// 请求刷新 token:剩余有效期不足总有效期的 1/3 时才刷新
|
||||
if (Request::input('refresh') && $expiredAtCarbon) {
|
||||
$tokenValidDays = max(1, intval(Base::settingFind('system', 'token_valid_days', 30)));
|
||||
$refreshThresholdDays = ceil($tokenValidDays / 3);
|
||||
if ($expiredAtCarbon->isBefore(Carbon::now()->addDays($refreshThresholdDays))) {
|
||||
$data['token'] = User::generateToken($user, true);
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
|
||||
@@ -378,10 +388,14 @@ class UsersController extends AbstractController
|
||||
//
|
||||
$refreshToken = false;
|
||||
if (in_array(Base::platform(), ['ios', 'android'])) {
|
||||
// 移动端token还剩7天到期时获取新的token
|
||||
// 移动端token剩余有效期不足总有效期的1/3时获取新的token
|
||||
$expiredAt = Doo::userExpiredAt();
|
||||
if ($expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now()->addDays(7))) {
|
||||
$refreshToken = true;
|
||||
if ($expiredAt) {
|
||||
$tokenValidDays = max(1, intval(Base::settingFind('system', 'token_valid_days', 30)));
|
||||
$refreshThresholdDays = ceil($tokenValidDays / 3);
|
||||
if (Carbon::parse($expiredAt)->isBefore(Carbon::now()->addDays($refreshThresholdDays))) {
|
||||
$refreshToken = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
User::generateToken($user, $refreshToken);
|
||||
@@ -663,7 +677,12 @@ class UsersController extends AbstractController
|
||||
if (str_contains($keys['key'], "@")) {
|
||||
$builder->where("email", "like", "%{$keys['key']}%");
|
||||
} elseif (Base::isNumber($keys['key'])) {
|
||||
$builder->where("userid", intval($keys['key']));
|
||||
$builder->where(function ($query) use ($keys) {
|
||||
$query->where("userid", intval($keys['key']))
|
||||
->orWhere("nickname", "like", "%{$keys['key']}%")
|
||||
->orWhere("pinyin", "like", "%{$keys['key']}%")
|
||||
->orWhere("profession", "like", "%{$keys['key']}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where(function($query) use ($keys) {
|
||||
$query->where("nickname", "like", "%{$keys['key']}%")
|
||||
@@ -1099,8 +1118,6 @@ class UsersController extends AbstractController
|
||||
$upArray = [];
|
||||
$upLdap = [];
|
||||
$transferUser = null;
|
||||
$hookAction = '';
|
||||
$hookEvent = '';
|
||||
switch ($type) {
|
||||
case 'setadmin':
|
||||
$msg = '设置成功';
|
||||
@@ -1182,16 +1199,12 @@ class UsersController extends AbstractController
|
||||
return Base::retError('交接人已离职,请选择另一个交接人');
|
||||
}
|
||||
}
|
||||
$hookAction = 'user_offboard';
|
||||
$hookEvent = 'offboard';
|
||||
break;
|
||||
|
||||
case 'cleardisable':
|
||||
$msg = '操作成功';
|
||||
$upArray['identity'] = array_diff($userInfo->identity, ['disable']);
|
||||
$upArray['disable_at'] = null;
|
||||
$hookAction = 'user_onboard';
|
||||
$hookEvent = 'restore';
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
@@ -1310,9 +1323,6 @@ class UsersController extends AbstractController
|
||||
}
|
||||
});
|
||||
}
|
||||
if ($hookAction) {
|
||||
Apps::dispatchUserHook($userInfo, $hookAction, $hookEvent);
|
||||
}
|
||||
//
|
||||
return Base::retSuccess($msg, $userInfo);
|
||||
}
|
||||
@@ -2834,7 +2844,11 @@ class UsersController extends AbstractController
|
||||
$dialogIds[] = $dialog['id'];
|
||||
}
|
||||
if ($key && count($dialogList) < $dialogTake) {
|
||||
$dialogUsers = User::searchUser($key, $dialogTake - count($dialogList));
|
||||
$dialogUsers = User::select(User::$basicField)
|
||||
->searchByKeyword($key)
|
||||
->orderBy('userid')
|
||||
->take($dialogTake - count($dialogList))
|
||||
->get();
|
||||
foreach ($dialogUsers as $item) {
|
||||
$dialog = WebSocketDialog::getUserDialog($user->userid, $item->userid, now()->addDay());
|
||||
if ($dialog && !in_array($dialog->id, $dialogIds)) {
|
||||
|
||||
@@ -21,8 +21,9 @@ use App\Tasks\AutoArchivedTask;
|
||||
use App\Tasks\DeleteBotMsgTask;
|
||||
use App\Tasks\CheckinRemindTask;
|
||||
use App\Tasks\CloseMeetingRoomTask;
|
||||
use App\Tasks\ZincSearchSyncTask;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
use App\Tasks\UnclaimedTaskRemindTask;
|
||||
use App\Tasks\AiTaskLoopTask;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
use Laravolt\Avatar\Avatar;
|
||||
|
||||
@@ -271,8 +272,10 @@ class IndexController extends InvokeController
|
||||
Task::deliver(new UnclaimedTaskRemindTask());
|
||||
// 关闭会议室
|
||||
Task::deliver(new CloseMeetingRoomTask());
|
||||
// ZincSearch 同步
|
||||
Task::deliver(new ZincSearchSyncTask());
|
||||
// Manticore Search 同步
|
||||
Task::deliver(new ManticoreSyncTask());
|
||||
// AI 任务建议
|
||||
Task::deliver(new AiTaskLoopTask());
|
||||
|
||||
return "success";
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ class WebApi
|
||||
RequestContext::set('start_time', microtime(true));
|
||||
RequestContext::set('header_language', $request->header('language'));
|
||||
|
||||
// 强制 https
|
||||
$APP_SCHEME = env('APP_SCHEME', 'auto');
|
||||
if (in_array(strtolower($APP_SCHEME), ['https', 'on', 'ssl', '1', 'true', 'yes'], true)) {
|
||||
$request->server->set('HTTPS', 'on');
|
||||
$request->headers->set('X-Forwarded-Proto', 'https');
|
||||
$request->setTrustedProxies([$request->getClientIp()], $request::HEADER_X_FORWARDED_PROTO);
|
||||
}
|
||||
|
||||
// 更新请求的基本URL
|
||||
RequestContext::updateBaseUrl($request);
|
||||
|
||||
@@ -56,12 +64,6 @@ class WebApi
|
||||
}
|
||||
}
|
||||
|
||||
// 强制 https
|
||||
$APP_SCHEME = env('APP_SCHEME', 'auto');
|
||||
if (in_array(strtolower($APP_SCHEME), ['https', 'on', 'ssl', '1', 'true', 'yes'], true)) {
|
||||
$request->setTrustedProxies([$request->getClientIp()], $request::HEADER_X_FORWARDED_PROTO);
|
||||
}
|
||||
|
||||
// 执行下一个中间件
|
||||
$response = $next($request);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use Request;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Tasks\PushTask;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
use App\Observers\AbstractObserver;
|
||||
use App\Exceptions\ApiException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
@@ -40,6 +42,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File sharedToUser(int $userid)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereCid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedId($value)
|
||||
@@ -128,6 +132,45 @@ class File extends AbstractModel
|
||||
*/
|
||||
const zipMaxSize = 1024 * 1024 * 1024; // 1G
|
||||
|
||||
/**
|
||||
* 按关键词搜索文件(Scope)
|
||||
* 支持:文件ID(纯数字)、文件名
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
if (is_numeric($keyword)) {
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where("id", intval($keyword))
|
||||
->orWhere("name", "like", "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
return $query->where("name", "like", "%{$keyword}%");
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选用户可访问的共享文件(Scope)
|
||||
* 不包括用户自己的文件,仅返回他人共享给该用户的文件
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param int $userid 用户ID
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSharedToUser($query, int $userid)
|
||||
{
|
||||
return $query->whereIn('pshare', function ($subQuery) use ($userid) {
|
||||
$subQuery->select('files.id')
|
||||
->from('files')
|
||||
->join('file_users', 'files.id', '=', 'file_users.file_id')
|
||||
->where('files.userid', '!=', $userid)
|
||||
->where(function ($q) use ($userid) {
|
||||
$q->whereIn('file_users.userid', [0, $userid]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件列表
|
||||
@@ -584,6 +627,26 @@ class File extends AbstractModel
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新子文件的 userid 并同步到 Manticore
|
||||
* @param int $userid 新的 userid
|
||||
* @return int 更新的文件数量
|
||||
*/
|
||||
public function updateChildFilesUserid(int $userid): int
|
||||
{
|
||||
self::where('pids', 'like', "%,{$this->id},%")->update(['userid' => $userid]);
|
||||
|
||||
// 批量 update 绕过 Observer,手动触发 Manticore 同步
|
||||
$childFileIds = self::where('pids', 'like', "%,{$this->id},%")
|
||||
->where('type', '!=', 'folder')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
foreach ($childFileIds as $childFileId) {
|
||||
AbstractObserver::taskDeliver(new ManticoreSyncTask('file_sync', ['id' => $childFileId]));
|
||||
}
|
||||
return count($childFileIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件分享链接
|
||||
* @param $userid
|
||||
@@ -710,7 +773,7 @@ class File extends AbstractModel
|
||||
/**
|
||||
* code获取文件ID、名称
|
||||
* @param $code
|
||||
* @return File
|
||||
* @return File|null
|
||||
*/
|
||||
public static function code2IdName($code) {
|
||||
$arr = explode(",", base64_decode($code));
|
||||
|
||||
@@ -45,7 +45,7 @@ class FileUser extends AbstractModel
|
||||
} else {
|
||||
FileLink::whereFileId($file_id)->delete();
|
||||
}
|
||||
FileUser::whereFileId($file_id)->delete();
|
||||
FileUser::whereFileId($file_id)->remove();
|
||||
});
|
||||
}
|
||||
/**
|
||||
@@ -58,7 +58,7 @@ class FileUser extends AbstractModel
|
||||
{
|
||||
return AbstractModel::transaction(function() use ($userid, $file_id) {
|
||||
FileLink::whereFileId($file_id)->whereUserid($userid)->delete();
|
||||
return self::whereFileId($file_id)->whereUserid($userid)->delete();
|
||||
return self::whereFileId($file_id)->whereUserid($userid)->remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
132
app/Models/ManticoreSyncFailure.php
Normal file
132
app/Models/ManticoreSyncFailure.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* Manticore 同步失败记录
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $data_type 数据类型: msg/file/task/project/user
|
||||
* @property int $data_id 数据ID
|
||||
* @property string $action 操作类型: sync/delete
|
||||
* @property string|null $error_message 错误信息
|
||||
* @property int $retry_count 重试次数
|
||||
* @property \Carbon\Carbon|null $last_retry_at 最后重试时间
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class ManticoreSyncFailure extends AbstractModel
|
||||
{
|
||||
protected $table = 'manticore_sync_failures';
|
||||
|
||||
protected $fillable = [
|
||||
'data_type',
|
||||
'data_id',
|
||||
'action',
|
||||
'error_message',
|
||||
'retry_count',
|
||||
'last_retry_at',
|
||||
];
|
||||
|
||||
protected $dates = [
|
||||
'last_retry_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 记录同步失败
|
||||
*
|
||||
* @param string $dataType 数据类型
|
||||
* @param int $dataId 数据ID
|
||||
* @param string $action 操作类型 sync/delete
|
||||
* @param string $errorMessage 错误信息
|
||||
*/
|
||||
public static function recordFailure(string $dataType, int $dataId, string $action, string $errorMessage = ''): void
|
||||
{
|
||||
self::updateOrCreate(
|
||||
[
|
||||
'data_type' => $dataType,
|
||||
'data_id' => $dataId,
|
||||
'action' => $action,
|
||||
],
|
||||
[
|
||||
'error_message' => mb_substr($errorMessage, 0, 500),
|
||||
'retry_count' => \DB::raw('retry_count + 1'),
|
||||
'last_retry_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成功记录
|
||||
*
|
||||
* @param string $dataType 数据类型
|
||||
* @param int $dataId 数据ID
|
||||
* @param string $action 操作类型
|
||||
*/
|
||||
public static function removeSuccess(string $dataType, int $dataId, string $action): void
|
||||
{
|
||||
self::where('data_type', $dataType)
|
||||
->where('data_id', $dataId)
|
||||
->where('action', $action)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待重试的记录
|
||||
* 根据重试次数决定间隔:1次=1分钟,2次=5分钟,3次=15分钟,4次+=30分钟
|
||||
*
|
||||
* @param int $limit 数量限制
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public static function getPendingRetries(int $limit = 100)
|
||||
{
|
||||
return self::where(function ($query) {
|
||||
$query->whereNull('last_retry_at')
|
||||
->orWhere(function ($q) {
|
||||
// 根据重试次数决定间隔
|
||||
$q->where(function ($sub) {
|
||||
// 重试1次:等待1分钟
|
||||
$sub->where('retry_count', 1)
|
||||
->where('last_retry_at', '<', now()->subMinutes(1));
|
||||
})->orWhere(function ($sub) {
|
||||
// 重试2次:等待5分钟
|
||||
$sub->where('retry_count', 2)
|
||||
->where('last_retry_at', '<', now()->subMinutes(5));
|
||||
})->orWhere(function ($sub) {
|
||||
// 重试3次:等待15分钟
|
||||
$sub->where('retry_count', 3)
|
||||
->where('last_retry_at', '<', now()->subMinutes(15));
|
||||
})->orWhere(function ($sub) {
|
||||
// 重试4次以上:等待30分钟
|
||||
$sub->where('retry_count', '>=', 4)
|
||||
->where('last_retry_at', '<', now()->subMinutes(30));
|
||||
});
|
||||
});
|
||||
})
|
||||
->orderBy('last_retry_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getStats(): array
|
||||
{
|
||||
return [
|
||||
'total' => self::count(),
|
||||
'by_type' => self::selectRaw('data_type, COUNT(*) as count')
|
||||
->groupBy('data_type')
|
||||
->pluck('count', 'data_type')
|
||||
->toArray(),
|
||||
'by_action' => self::selectRaw('action, COUNT(*) as count')
|
||||
->groupBy('action')
|
||||
->pluck('count', 'action')
|
||||
->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ use Request;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchiveDays($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchiveMethod($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchivedAt($value)
|
||||
@@ -164,6 +165,18 @@ class Project extends AbstractModel
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词搜索项目(Scope)
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
return $query->where("projects.name", "like", "%{$keyword}%");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务统计数据
|
||||
* @param $userid
|
||||
|
||||
@@ -74,6 +74,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask whereArchivedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask whereArchivedFollow($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask whereArchivedUserid($value)
|
||||
@@ -353,6 +354,32 @@ class ProjectTask extends AbstractModel
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词搜索任务(Scope)
|
||||
* 支持:任务ID(纯数字)、任务名称、描述
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
if (is_numeric($keyword)) {
|
||||
// 纯数字:匹配任务ID 或 名称/描述
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where("project_tasks.id", intval($keyword))
|
||||
->orWhere("project_tasks.name", "like", "%{$keyword}%")
|
||||
->orWhere("project_tasks.desc", "like", "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 普通文本:搜索名称/描述
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where("project_tasks.name", "like", "%{$keyword}%")
|
||||
->orWhere("project_tasks.desc", "like", "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成描述
|
||||
* @param $content
|
||||
@@ -372,6 +399,38 @@ class ProjectTask extends AbstractModel
|
||||
return Base::cutStr(strip_tags($content), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化时间参数,兼容 start_at/end_at 转换为 times
|
||||
* @param array $data 请求数据
|
||||
* @param self|null $task 任务实例(更新时传入)
|
||||
* @return array 处理后的data
|
||||
*/
|
||||
public static function normalizeTimes(array $data, ?self $task = null): array
|
||||
{
|
||||
if (isset($data['times']) || (!isset($data['start_at']) && !isset($data['end_at']))) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$startAt = $data['start_at'] ?? null;
|
||||
$endAt = $data['end_at'] ?? null;
|
||||
|
||||
if ($endAt && !$startAt) {
|
||||
// 只传 end_at:保留已有 start_at,否则取当前时间
|
||||
$startAt = $task?->start_at
|
||||
? Carbon::parse($task->start_at)->toDateTimeString()
|
||||
: date('Y-m-d H:i:s');
|
||||
} elseif ($startAt && !$endAt) {
|
||||
// 只传 start_at:必须已有 end_at
|
||||
if (!$task?->end_at) {
|
||||
throw new ApiException('请设置结束时间');
|
||||
}
|
||||
$endAt = Carbon::parse($task->end_at)->toDateTimeString();
|
||||
}
|
||||
|
||||
$data['times'] = [$startAt, $endAt];
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务
|
||||
* @param $data
|
||||
@@ -418,6 +477,22 @@ class ProjectTask extends AbstractModel
|
||||
}
|
||||
//
|
||||
$retPre = $parent_id ? '子任务' : '任务';
|
||||
|
||||
// 优先级:主任务在缺省时按系统默认补齐,并尽量补全 name/color
|
||||
if ($parent_id == 0) {
|
||||
$priorityList = Setting::normalizeTaskPriorityList(Base::setting('priority'));
|
||||
if ($p_level > 0) {
|
||||
$matched = reset(array_filter($priorityList, fn($item) => intval($item['priority']) === $p_level)) ?: null;
|
||||
} else {
|
||||
$matched = Setting::getDefaultTaskPriorityItem($priorityList);
|
||||
}
|
||||
if ($matched) {
|
||||
$p_level = $p_level > 0 ? $p_level : intval($matched['priority']);
|
||||
$p_name = $p_name ?: $matched['name'];
|
||||
$p_color = $p_color ?: $matched['color'];
|
||||
}
|
||||
}
|
||||
|
||||
$task = self::createInstance([
|
||||
'parent_id' => $parent_id,
|
||||
'project_id' => $project_id,
|
||||
@@ -675,12 +750,22 @@ class ProjectTask extends AbstractModel
|
||||
if ($this->complete_at) {
|
||||
throw new ApiException('任务已完成');
|
||||
}
|
||||
$this->completeTask(Carbon::now(), isset($newFlowItem) ? $newFlowItem->name : null);
|
||||
// 只有用户单独提交 complete_at 时才自动设置工作流状态
|
||||
if (!Arr::exists($data, 'flow_item_id')) {
|
||||
$flowItemName = $this->checkAndAutoSetFlowItem('end', -4005);
|
||||
} else {
|
||||
$flowItemName = isset($newFlowItem) ? $newFlowItem->name : null;
|
||||
}
|
||||
$this->completeTask(Carbon::now(), $flowItemName);
|
||||
} else {
|
||||
// 标记未完成
|
||||
if (!$this->complete_at) {
|
||||
throw new ApiException('未完成任务');
|
||||
}
|
||||
// 只有用户单独提交 complete_at 时才自动设置工作流状态
|
||||
if (!Arr::exists($data, 'flow_item_id')) {
|
||||
$this->checkAndAutoSetFlowItem('start', -4006);
|
||||
}
|
||||
$this->completeTask(null);
|
||||
}
|
||||
$updateMarking['is_update_project'] = true;
|
||||
@@ -758,7 +843,7 @@ class ProjectTask extends AbstractModel
|
||||
$this->visibility = $data["visibility"];
|
||||
ProjectTask::whereParentId($data['task_id'])->change(['visibility' => $data["visibility"]]);
|
||||
}
|
||||
ProjectTaskVisibilityUser::whereTaskId($data['task_id'])->delete();
|
||||
ProjectTaskVisibilityUser::whereTaskId($data['task_id'])->remove();
|
||||
if (Arr::exists($data, 'visibility_appointor')) {
|
||||
foreach ($data['visibility_appointor'] as $uid) {
|
||||
if ($uid) {
|
||||
@@ -1186,6 +1271,126 @@ class ProjectTask extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目的工作流状态项(start 和 end)
|
||||
* @param int $projectId 项目ID
|
||||
* @return array ['start' => ProjectFlowItem|null, 'end' => ProjectFlowItem|null]
|
||||
*/
|
||||
public static function getProjectFlowItems(int $projectId): array
|
||||
{
|
||||
$startFlowItem = null;
|
||||
$endFlowItem = null;
|
||||
$projectFlow = ProjectFlow::whereProjectId($projectId)->orderByDesc('id')->first();
|
||||
if ($projectFlow) {
|
||||
$flowItems = ProjectFlowItem::whereFlowId($projectFlow->id)->orderBy('sort')->get();
|
||||
foreach ($flowItems as $item) {
|
||||
if ($item->status == 'start' && !$startFlowItem) {
|
||||
$startFlowItem = $item;
|
||||
}
|
||||
if ($item->status == 'end' && !$endFlowItem) {
|
||||
$endFlowItem = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['start' => $startFlowItem, 'end' => $endFlowItem];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成工作流状态名称
|
||||
* @param ProjectFlowItem|null $flowItem
|
||||
* @return string
|
||||
*/
|
||||
public static function formatFlowItemName(?ProjectFlowItem $flowItem): string
|
||||
{
|
||||
return $flowItem ? ($flowItem->status . '|' . $flowItem->name . '|' . $flowItem->color) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制子任务到新的父任务
|
||||
* @param ProjectTask $newParentTask 新的父任务
|
||||
* @param array $options 选项
|
||||
* - reset_complete: 是否重置完成状态并映射到 start 工作流(默认 true)
|
||||
* - sync_time: 是否同步时间到父任务的时间(默认 false)
|
||||
* - update_project: 是否更新项目相关字段(project_id、column_id)(默认 false)
|
||||
* @return array 新创建的子任务数组
|
||||
*/
|
||||
public function copySubTasks(ProjectTask $newParentTask, array $options = []): array
|
||||
{
|
||||
$resetComplete = $options['reset_complete'] ?? true;
|
||||
$syncTime = $options['sync_time'] ?? false;
|
||||
$updateProject = $options['update_project'] ?? false;
|
||||
|
||||
$newSubTasks = [];
|
||||
$subTasks = self::whereParentId($this->id)->get();
|
||||
if ($subTasks->isEmpty()) {
|
||||
return $newSubTasks;
|
||||
}
|
||||
|
||||
// 获取 start 工作流状态
|
||||
$flowItems = $resetComplete ? self::getProjectFlowItems($newParentTask->project_id) : ['start' => null];
|
||||
$startFlowItem = $flowItems['start'];
|
||||
|
||||
foreach ($subTasks as $subTask) {
|
||||
$newSubTask = $subTask->copyTask();
|
||||
$newSubTask->parent_id = $newParentTask->id;
|
||||
|
||||
// 同步时间
|
||||
if ($syncTime) {
|
||||
$newSubTask->start_at = $newParentTask->start_at;
|
||||
$newSubTask->end_at = $newParentTask->end_at;
|
||||
}
|
||||
|
||||
// 更新项目相关字段
|
||||
if ($updateProject) {
|
||||
$newSubTask->project_id = $newParentTask->project_id;
|
||||
$newSubTask->column_id = $newParentTask->column_id;
|
||||
}
|
||||
|
||||
// 重置完成状态
|
||||
if ($resetComplete) {
|
||||
$newSubTask->complete_at = null;
|
||||
$newSubTask->flow_item_id = $startFlowItem?->id ?? 0;
|
||||
$newSubTask->flow_item_name = self::formatFlowItemName($startFlowItem);
|
||||
}
|
||||
|
||||
$newSubTask->save();
|
||||
$newSubTasks[] = $newSubTask;
|
||||
}
|
||||
|
||||
return $newSubTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动子任务到新项目/列
|
||||
* @param int $projectId 目标项目ID
|
||||
* @param int $columnId 目标列ID
|
||||
*/
|
||||
public function moveSubTasks(int $projectId, int $columnId): void
|
||||
{
|
||||
$subTasks = self::whereParentId($this->id)->get();
|
||||
if ($subTasks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$flowItems = self::getProjectFlowItems($projectId);
|
||||
$startFlowItem = $flowItems['start'];
|
||||
$endFlowItem = $flowItems['end'];
|
||||
|
||||
foreach ($subTasks as $subTask) {
|
||||
$subTask->project_id = $projectId;
|
||||
$subTask->column_id = $columnId;
|
||||
// 根据完成状态映射工作流
|
||||
if ($subTask->complete_at) {
|
||||
$subTask->flow_item_id = $endFlowItem?->id ?? 0;
|
||||
$subTask->flow_item_name = self::formatFlowItemName($endFlowItem);
|
||||
} else {
|
||||
$subTask->flow_item_id = $startFlowItem?->id ?? 0;
|
||||
$subTask->flow_item_name = self::formatFlowItemName($startFlowItem);
|
||||
}
|
||||
$subTask->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步项目成员至聊天室
|
||||
*/
|
||||
@@ -1344,6 +1549,49 @@ class ProjectTask extends AbstractModel
|
||||
return $this->appendattrs['has_owner'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并自动设置工作流状态
|
||||
* @param string $status 目标状态类型 ('start' 或 'end')
|
||||
* @param int $errorCode 多状态时的错误码 (-4005 或 -4006)
|
||||
* @return string|null 自动设置的状态名称,无状态时返回 null
|
||||
*/
|
||||
private function checkAndAutoSetFlowItem(string $status, int $errorCode): ?string
|
||||
{
|
||||
$flowItems = ProjectFlowItem::whereProjectId($this->project_id)
|
||||
->whereStatus($status)
|
||||
->get(['id', 'name', 'status', 'color']);
|
||||
|
||||
if ($flowItems->count() > 1) {
|
||||
$msg = $status === 'end' ? '存在多个结束状态,请选择要使用的状态' : '存在多个开始状态,请选择要使用的状态';
|
||||
throw new ApiException($msg, [
|
||||
'task_id' => $this->id,
|
||||
'flow_items' => $flowItems->toArray(),
|
||||
], $errorCode);
|
||||
}
|
||||
|
||||
if ($flowItems->count() == 1) {
|
||||
$autoFlowItem = $flowItems->first();
|
||||
$oldFlowItemId = $this->flow_item_id;
|
||||
$oldFlowItemName = $this->flow_item_name;
|
||||
$this->flow_item_id = $autoFlowItem->id;
|
||||
$this->flow_item_name = $autoFlowItem->status . "|" . $autoFlowItem->name . "|" . $autoFlowItem->color;
|
||||
|
||||
if ($oldFlowItemId != $this->flow_item_id) {
|
||||
ProjectTaskFlowChange::createInstance([
|
||||
'task_id' => $this->id,
|
||||
'userid' => User::userid(),
|
||||
'before_flow_item_id' => $oldFlowItemId,
|
||||
'before_flow_item_name' => $oldFlowItemName,
|
||||
'after_flow_item_id' => $this->flow_item_id,
|
||||
'after_flow_item_name' => $this->flow_item_name,
|
||||
])->save();
|
||||
}
|
||||
return $autoFlowItem->name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记已完成、未完成
|
||||
* @param Carbon|null $complete_at 完成时间
|
||||
@@ -1918,11 +2166,8 @@ class ProjectTask extends AbstractModel
|
||||
$taskUser->save();
|
||||
}
|
||||
}
|
||||
// 子任务
|
||||
ProjectTask::whereParentId($this->id)->change([
|
||||
'project_id' => $projectId,
|
||||
'column_id' => $columnId,
|
||||
]);
|
||||
// 子任务 - 根据完成状态映射工作流
|
||||
$this->moveSubTasks($projectId, $columnId);
|
||||
//
|
||||
if ($flowItemId) {
|
||||
// 更新任务流程
|
||||
|
||||
154
app/Models/ProjectTaskAiEvent.php
Normal file
154
app/Models/ProjectTaskAiEvent.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ProjectTaskAiEvent
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $task_id 任务ID
|
||||
* @property string $event_type 事件类型
|
||||
* @property string $status 状态
|
||||
* @property int $retry_count 重试次数
|
||||
* @property array|null $result 执行结果
|
||||
* @property string|null $error 错误信息
|
||||
* @property int $msg_id 消息ID
|
||||
* @property \Illuminate\Support\Carbon|null $executed_at
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
*/
|
||||
class ProjectTaskAiEvent extends AbstractModel
|
||||
{
|
||||
const EVENT_DESCRIPTION = 'description';
|
||||
const EVENT_SUBTASKS = 'subtasks';
|
||||
const EVENT_ASSIGNEE = 'assignee';
|
||||
const EVENT_SIMILAR = 'similar';
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_PROCESSING = 'processing';
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
const STATUS_FAILED = 'failed';
|
||||
const STATUS_SKIPPED = 'skipped';
|
||||
const STATUS_APPLIED = 'applied';
|
||||
const STATUS_DISMISSED = 'dismissed';
|
||||
|
||||
const MAX_RETRY = 3;
|
||||
|
||||
protected $table = 'project_task_ai_events';
|
||||
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'event_type',
|
||||
'status',
|
||||
'retry_count',
|
||||
'result',
|
||||
'error',
|
||||
'msg_id',
|
||||
'executed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'result' => 'array',
|
||||
'executed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联任务
|
||||
*/
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectTask::class, 'task_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有事件类型
|
||||
*/
|
||||
public static function getEventTypes(): array
|
||||
{
|
||||
return [
|
||||
self::EVENT_DESCRIPTION,
|
||||
self::EVENT_SUBTASKS,
|
||||
self::EVENT_ASSIGNEE,
|
||||
self::EVENT_SIMILAR,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为处理中
|
||||
*/
|
||||
public function markProcessing(): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_PROCESSING,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为完成
|
||||
*/
|
||||
public function markCompleted(array $result, int $msgId = 0): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'result' => $result,
|
||||
'msg_id' => $msgId,
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为失败
|
||||
*/
|
||||
public function markFailed(string $error): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'retry_count' => $this->retry_count + 1,
|
||||
'error' => $error,
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为跳过
|
||||
*/
|
||||
public function markSkipped(string $reason = ''): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_SKIPPED,
|
||||
'error' => $reason,
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否可以重试
|
||||
*/
|
||||
public function canRetry(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAILED
|
||||
&& $this->retry_count < self::MAX_RETRY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为已采纳
|
||||
*/
|
||||
public function markApplied(): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_APPLIED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为已忽略
|
||||
*/
|
||||
public function markDismissed(): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_DISMISSED,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,121 @@ class ProjectTaskRelation extends AbstractModel
|
||||
return $this->belongsTo(ProjectTask::class, 'related_task_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建双向任务关联
|
||||
*
|
||||
* @param int $sourceTaskId 源任务ID
|
||||
* @param int $targetTaskId 目标任务ID
|
||||
* @param int|null $dialogId 来源对话ID
|
||||
* @param int|null $msgId 来源消息ID
|
||||
* @param int|null $userid 操作人
|
||||
* @param bool $push 是否推送更新
|
||||
* @return bool 是否创建成功
|
||||
*/
|
||||
public static function createRelation(
|
||||
int $sourceTaskId,
|
||||
int $targetTaskId,
|
||||
?int $dialogId = null,
|
||||
?int $msgId = null,
|
||||
?int $userid = null,
|
||||
bool $push = true
|
||||
): bool {
|
||||
if ($sourceTaskId === $targetTaskId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sourceTask = ProjectTask::with('project')->find($sourceTaskId);
|
||||
$targetTask = ProjectTask::with('project')->find($targetTaskId);
|
||||
|
||||
if (!$sourceTask || !$targetTask) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($sourceTask->deleted_at || $targetTask->deleted_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建正向关联:源任务提及目标任务
|
||||
$mentionRelation = static::updateOrCreate(
|
||||
[
|
||||
'task_id' => $sourceTaskId,
|
||||
'related_task_id' => $targetTaskId,
|
||||
'direction' => self::DIRECTION_MENTION,
|
||||
],
|
||||
[
|
||||
'dialog_id' => $dialogId,
|
||||
'msg_id' => $msgId,
|
||||
'userid' => $userid,
|
||||
]
|
||||
);
|
||||
|
||||
// 创建反向关联:目标任务被源任务提及
|
||||
$reverseRelation = static::updateOrCreate(
|
||||
[
|
||||
'task_id' => $targetTaskId,
|
||||
'related_task_id' => $sourceTaskId,
|
||||
'direction' => self::DIRECTION_MENTIONED_BY,
|
||||
],
|
||||
[
|
||||
'dialog_id' => $dialogId,
|
||||
'msg_id' => $msgId,
|
||||
'userid' => $userid,
|
||||
]
|
||||
);
|
||||
|
||||
// 推送关联更新
|
||||
if ($push) {
|
||||
$needPush = $mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()
|
||||
|| $reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged();
|
||||
|
||||
if ($needPush) {
|
||||
if ($sourceTask->project) {
|
||||
$sourceTask->pushMsg('relation', null, null, false);
|
||||
}
|
||||
if ($targetTask->project) {
|
||||
$targetTask->pushMsg('relation', null, null, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除双向任务关联
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $relatedTaskId 关联任务ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public static function deleteRelation(int $taskId, int $relatedTaskId): bool
|
||||
{
|
||||
// 删除正向关联
|
||||
$deleted1 = static::whereTaskId($taskId)
|
||||
->whereRelatedTaskId($relatedTaskId)
|
||||
->delete();
|
||||
|
||||
// 删除反向关联
|
||||
$deleted2 = static::whereTaskId($relatedTaskId)
|
||||
->whereRelatedTaskId($taskId)
|
||||
->delete();
|
||||
|
||||
if ($deleted1 || $deleted2) {
|
||||
// 推送关联更新
|
||||
$sourceTask = ProjectTask::with('project')->find($taskId);
|
||||
$targetTask = ProjectTask::with('project')->find($relatedTaskId);
|
||||
if ($sourceTask?->project) {
|
||||
$sourceTask->pushMsg('relation', null, null, false);
|
||||
}
|
||||
if ($targetTask?->project) {
|
||||
$targetTask->pushMsg('relation', null, null, false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void
|
||||
{
|
||||
if ($msg->type !== 'text') {
|
||||
@@ -84,71 +199,25 @@ class ProjectTaskRelation extends AbstractModel
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceTasks = ProjectTask::with('project')->whereDialogId($msg->dialog_id)->get();
|
||||
if ($sourceTasks->isEmpty()) {
|
||||
$sourceTaskIds = ProjectTask::whereDialogId($msg->dialog_id)
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
if (empty($sourceTaskIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetTasks = ProjectTask::with('project')->whereIn('id', $targetIds)->get()->keyBy('id');
|
||||
if ($targetTasks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pushTasks = [];
|
||||
foreach ($sourceTasks as $sourceTask) {
|
||||
foreach ($sourceTaskIds as $sourceTaskId) {
|
||||
foreach ($targetIds as $targetId) {
|
||||
if ($targetId === $sourceTask->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetTask = $targetTasks->get($targetId);
|
||||
if (!$targetTask) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mentionRelation = static::updateOrCreate(
|
||||
[
|
||||
'task_id' => $sourceTask->id,
|
||||
'related_task_id' => $targetTask->id,
|
||||
'direction' => self::DIRECTION_MENTION,
|
||||
],
|
||||
[
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
'msg_id' => $msg->id,
|
||||
'userid' => $msg->userid,
|
||||
]
|
||||
self::createRelation(
|
||||
$sourceTaskId,
|
||||
$targetId,
|
||||
$msg->dialog_id,
|
||||
$msg->id,
|
||||
$msg->userid
|
||||
);
|
||||
|
||||
if ($mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()) {
|
||||
$pushTasks[$sourceTask->id] = $sourceTask;
|
||||
}
|
||||
|
||||
$reverseRelation = static::updateOrCreate(
|
||||
[
|
||||
'task_id' => $targetTask->id,
|
||||
'related_task_id' => $sourceTask->id,
|
||||
'direction' => self::DIRECTION_MENTIONED_BY,
|
||||
],
|
||||
[
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
'msg_id' => $msg->id,
|
||||
'userid' => $msg->userid,
|
||||
]
|
||||
);
|
||||
|
||||
if ($reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged()) {
|
||||
$pushTasks[$targetTask->id] = $targetTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pushTasks as $task) {
|
||||
$task->loadMissing('project');
|
||||
if (!$task->project) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$task->pushMsg('relation', null, null, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ use JetBrains\PhpStorm\Pure;
|
||||
* @property string $sign 汇报唯一标识
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ReportReceive> $Receives
|
||||
* @property-read int|null $receives_count
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ReportAnalysis> $aiAnalyses
|
||||
* @property-read int|null $ai_analyses_count
|
||||
* @property-read \App\Models\ReportAnalysis|null $aiAnalysis
|
||||
* @property-read mixed $receives
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $receivesUser
|
||||
* @property-read int|null $receives_user_count
|
||||
@@ -56,6 +59,15 @@ class Report extends AbstractModel
|
||||
|
||||
const WEEKLY = "weekly";
|
||||
const DAILY = "daily";
|
||||
public const LIST_FIELDS = [
|
||||
'id',
|
||||
'title',
|
||||
'type',
|
||||
'userid',
|
||||
'sign',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
"title",
|
||||
|
||||
@@ -4,6 +4,37 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ReportAnalysis
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $rid 报告ID
|
||||
* @property int $userid 生成分析的会员ID
|
||||
* @property string $model 使用的模型名称
|
||||
* @property string $analysis_text AI 分析的原始文本(Markdown)
|
||||
* @property array|null $meta 额外的上下文信息
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Report|null $report
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereAnalysisText($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereMeta($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereModel($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereRid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class ReportAnalysis extends AbstractModel
|
||||
{
|
||||
protected $table = 'report_ai_analyses';
|
||||
|
||||
@@ -69,7 +69,7 @@ class Setting extends AbstractModel
|
||||
|
||||
// AI 机器人设置
|
||||
case 'aibotSetting':
|
||||
if ($value['claude_token'] && empty($value['claude_key'])) {
|
||||
if (!empty($value['claude_token']) && empty($value['claude_key'])) {
|
||||
$value['claude_key'] = $value['claude_token'];
|
||||
}
|
||||
$array = [];
|
||||
@@ -78,7 +78,7 @@ class Setting extends AbstractModel
|
||||
foreach ($aiList as $aiName) {
|
||||
foreach ($fieldList as $fieldName) {
|
||||
$key = $aiName . '_' . $fieldName;
|
||||
$content = $value[$key] ? trim($value[$key]) : '';
|
||||
$content = !empty($value[$key]) ? trim($value[$key]) : '';
|
||||
switch ($fieldName) {
|
||||
case 'models':
|
||||
if ($content) {
|
||||
@@ -106,6 +106,70 @@ class Setting extends AbstractModel
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范任务优先级设置(确保字段完整且仅有一个默认项)
|
||||
* @param mixed $list
|
||||
* @return array<int, array{name:string,color:string,days:int,priority:int,is_default:int}>
|
||||
*/
|
||||
public static function normalizeTaskPriorityList($list)
|
||||
{
|
||||
if (!is_array($list)) {
|
||||
return [];
|
||||
}
|
||||
$normalized = [];
|
||||
$defaultIndex = null;
|
||||
foreach ($list as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string)($item['name'] ?? ''));
|
||||
$color = trim((string)($item['color'] ?? ''));
|
||||
$priority = intval($item['priority'] ?? 0);
|
||||
if ($name === '' || $color === '' || $priority <= 0) {
|
||||
continue;
|
||||
}
|
||||
$days = intval($item['days'] ?? 0);
|
||||
$isDefault = !empty($item['is_default']) || !empty($item['default']);
|
||||
if ($defaultIndex === null && $isDefault) {
|
||||
$defaultIndex = count($normalized);
|
||||
}
|
||||
$normalized[] = [
|
||||
'name' => $name,
|
||||
'color' => $color,
|
||||
'days' => $days,
|
||||
'priority' => $priority,
|
||||
'is_default' => $isDefault ? 1 : 0,
|
||||
];
|
||||
}
|
||||
if (!empty($normalized)) {
|
||||
$defaultIndex = $defaultIndex ?? 0;
|
||||
foreach ($normalized as $i => $row) {
|
||||
$normalized[$i]['is_default'] = $i === $defaultIndex ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认任务优先级(来自 settings.priority)
|
||||
* @param array|null $list
|
||||
* @return array|null
|
||||
*/
|
||||
public static function getDefaultTaskPriorityItem($list = null)
|
||||
{
|
||||
$list = $list ?? Base::setting('priority');
|
||||
$list = self::normalizeTaskPriorityList($list);
|
||||
if (empty($list)) {
|
||||
return null;
|
||||
}
|
||||
foreach ($list as $item) {
|
||||
if (!empty($item['is_default'])) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
return $list[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否开启 AI 助手
|
||||
* @return bool
|
||||
@@ -135,7 +199,6 @@ class Setting extends AbstractModel
|
||||
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
|
||||
return match ($vendor) {
|
||||
'ollama' => $key !== '' || !empty($setting['ollama_base_url']),
|
||||
'wenxin' => $key !== '' && !empty($setting['wenxin_secret']),
|
||||
default => $key !== '',
|
||||
};
|
||||
}
|
||||
@@ -437,7 +500,7 @@ class Setting extends AbstractModel
|
||||
}
|
||||
$limitTime = Carbon::parse($dialogMsg->created_at)->addMinutes($limitNum);
|
||||
if ($limitTime->lt(Carbon::now())) {
|
||||
throw new ApiException('已超过' . Doo::translate(Base::forumMinuteDay($limitNum)) . ',' . $error);
|
||||
throw new ApiException('已超过' . Base::forumMinuteDay($limitNum) . ',' . $error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Table\OnlineData;
|
||||
use App\Observers\AbstractObserver;
|
||||
use App\Services\RequestContext;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
|
||||
@@ -23,7 +25,7 @@ use Carbon\Carbon;
|
||||
* @property string|null $tel 联系电话
|
||||
* @property string $nickname 昵称
|
||||
* @property string|null $profession 职位/职称
|
||||
* @property \Illuminate\Support\Carbon|null $birthday 生日
|
||||
* @property string|null $birthday 生日
|
||||
* @property string|null $address 地址
|
||||
* @property string|null $introduction 个人简介
|
||||
* @property string $userimg 头像
|
||||
@@ -53,7 +55,10 @@ use Carbon\Carbon;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereAddress($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereAz($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereBirthday($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereBot($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereChangepass($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value)
|
||||
@@ -64,6 +69,7 @@ use Carbon\Carbon;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerity($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereEncrypt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereIdentity($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereIntroduction($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLang($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLastAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLastIp($value)
|
||||
@@ -335,9 +341,6 @@ class User extends AbstractModel
|
||||
//
|
||||
return $this->delete();
|
||||
});
|
||||
if ($ret) {
|
||||
Apps::dispatchUserHook($this, 'user_offboard', 'delete');
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
|
||||
@@ -413,7 +416,12 @@ class User extends AbstractModel
|
||||
}
|
||||
}
|
||||
$createdUser = $user->find($user->userid);
|
||||
Apps::dispatchUserHook($createdUser, 'user_onboard', 'onboard');
|
||||
if (!$createdUser->bot) {
|
||||
// Manticore 索引同步
|
||||
AbstractObserver::taskDeliver(new ManticoreSyncTask('user_sync', $createdUser->toArray()));
|
||||
// 触发 user_onboard hook
|
||||
Apps::dispatchUserHook($createdUser, 'user_onboard', 'onboard');
|
||||
}
|
||||
return $createdUser;
|
||||
}
|
||||
|
||||
@@ -774,24 +782,35 @@ class User extends AbstractModel
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户
|
||||
* @param $key
|
||||
* @param $take
|
||||
* @return User[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection
|
||||
* 按关键词搜索用户(Scope)
|
||||
* 支持:邮箱(含@)、用户ID(纯数字)、昵称/拼音/职业
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public static function searchUser($key, $take = 20)
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
return User::select(User::$basicField)
|
||||
->where(function ($query) use ($key) {
|
||||
if (str_contains($key, "@")) {
|
||||
$query->where("email", "like", "%{$key}%");
|
||||
} else {
|
||||
$query->where("nickname", "like", "%{$key}%")
|
||||
->orWhere("pinyin", "like", "%{$key}%")
|
||||
->orWhere("profession", "like", "%{$key}%");
|
||||
}
|
||||
})->orderBy('userid')
|
||||
->take($take)
|
||||
->get();
|
||||
if (str_contains($keyword, "@")) {
|
||||
// 包含 @ 按邮箱搜索
|
||||
return $query->where("email", "like", "%{$keyword}%");
|
||||
}
|
||||
|
||||
if (is_numeric($keyword)) {
|
||||
// 纯数字:匹配用户ID 或 昵称/拼音/职业
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where("userid", intval($keyword))
|
||||
->orWhere("nickname", "like", "%{$keyword}%")
|
||||
->orWhere("pinyin", "like", "%{$keyword}%")
|
||||
->orWhere("profession", "like", "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 普通文本:搜索昵称/拼音/职业
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where("nickname", "like", "%{$keyword}%")
|
||||
->orWhere("pinyin", "like", "%{$keyword}%")
|
||||
->orWhere("profession", "like", "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,15 @@ namespace App\Models;
|
||||
* @property array|null $sorts 排序配置
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereSorts($value)
|
||||
|
||||
@@ -21,7 +21,7 @@ use Throwable;
|
||||
* @property \Illuminate\Support\Carbon|null $clear_at 下一次清理时间
|
||||
* @property string|null $webhook_url 消息webhook地址
|
||||
* @property int|null $webhook_num 消息webhook请求次数
|
||||
* @property array|null $webhook_events Webhook事件配置
|
||||
* @property array $webhook_events Webhook事件配置
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
@@ -40,6 +40,7 @@ use Throwable;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUserid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookEvents($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookNum($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookUrl($value)
|
||||
* @mixin \Eloquent
|
||||
@@ -352,16 +353,47 @@ class UserBot extends AbstractModel
|
||||
$advance = (intval($setting['advance']) ?: 120) * 60;
|
||||
$delay = (intval($setting['delay']) ?: 120) * 60;
|
||||
//
|
||||
$currentTime = Timer::time();
|
||||
$nowDate = date("Y-m-d");
|
||||
$nowTime = date("H:i:s");
|
||||
$yesterdayDate = date("Y-m-d", strtotime("-1 day"));
|
||||
//
|
||||
// 今天的签到窗口
|
||||
$timeStart = strtotime("{$nowDate} {$times[0]}");
|
||||
$timeEnd = strtotime("{$nowDate} {$times[1]}");
|
||||
$timeAdvance = max($timeStart - $advance, strtotime($nowDate));
|
||||
$timeDelay = min($timeEnd + $delay, strtotime("{$nowDate} 23:59:59"));
|
||||
// 移除 23:59:59 限制,允许跨天
|
||||
$todayTimeDelay = $timeEnd + $delay;
|
||||
//
|
||||
// 昨天的延后窗口(用于判断凌晨打卡归属)
|
||||
$yesterdayTimeEnd = strtotime("{$yesterdayDate} {$times[1]}");
|
||||
$yesterdayTimeDelay = $yesterdayTimeEnd + $delay;
|
||||
//
|
||||
// 判断签到归属哪天
|
||||
$targetDate = null;
|
||||
$checkType = null; // 'up' 或 'down'
|
||||
//
|
||||
// 情况1:在今天的有效窗口内
|
||||
if ($currentTime >= $timeAdvance && $currentTime <= $todayTimeDelay) {
|
||||
$targetDate = $nowDate;
|
||||
if ($currentTime < $timeEnd) {
|
||||
$checkType = 'up';
|
||||
} else {
|
||||
$checkType = 'down';
|
||||
}
|
||||
}
|
||||
// 情况2:凌晨时段,检查是否在昨天的延后窗口内
|
||||
elseif ($currentTime < $timeAdvance && $currentTime <= $yesterdayTimeDelay) {
|
||||
$targetDate = $yesterdayDate;
|
||||
$checkType = 'down';
|
||||
}
|
||||
//
|
||||
// 构建错误消息
|
||||
$errorTime = false;
|
||||
if (Timer::time() < $timeAdvance || $timeDelay < Timer::time()) {
|
||||
$errorTime = "不在有效时间内,有效时间为:" . date("H:i", $timeAdvance) . "-" . date("H:i", $timeDelay);
|
||||
if (!$targetDate) {
|
||||
$displayDelay = date("H:i", $todayTimeDelay % 86400);
|
||||
$nextDay = ($todayTimeDelay > strtotime("{$nowDate} 23:59:59")) ? "(+1)" : "";
|
||||
$errorTime = "不在有效时间内,有效时间为:" . date("H:i", $timeAdvance) . "-{$displayDelay}{$nextDay}";
|
||||
}
|
||||
//
|
||||
$macs = explode(",", $mac);
|
||||
@@ -375,7 +407,7 @@ class UserBot extends AbstractModel
|
||||
$array[] = [
|
||||
'userid' => $UserCheckinMac->userid,
|
||||
'mac' => $UserCheckinMac->mac,
|
||||
'date' => $nowDate,
|
||||
'date' => $targetDate ?: $nowDate,
|
||||
];
|
||||
$checkins[] = [
|
||||
'userid' => $UserCheckinMac->userid,
|
||||
@@ -396,7 +428,7 @@ class UserBot extends AbstractModel
|
||||
$array[] = [
|
||||
'userid' => $UserInfo->userid,
|
||||
'mac' => '00:00:00:00:00:00',
|
||||
'date' => $nowDate,
|
||||
'date' => $targetDate ?: $nowDate,
|
||||
];
|
||||
$checkins[] = [
|
||||
'userid' => $UserInfo->userid,
|
||||
@@ -431,7 +463,8 @@ class UserBot extends AbstractModel
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$sendMsg = function($type, $checkin) use ($errorTime, $alreadyTip, $getJokeSoup, $botUser, $nowDate) {
|
||||
$sendMsg = function($type, $checkin) use ($errorTime, $alreadyTip, $getJokeSoup, $botUser, $targetDate, $nowDate) {
|
||||
$displayDate = $targetDate ?: $nowDate;
|
||||
$dialog = WebSocketDialog::checkUserDialog($botUser, $checkin['userid']);
|
||||
if (!$dialog) {
|
||||
return;
|
||||
@@ -448,12 +481,13 @@ class UserBot extends AbstractModel
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 判断已打卡
|
||||
$cacheKey = "Checkin::sendMsg-{$nowDate}-{$type}:" . $checkin['userid'];
|
||||
// 判断已打卡(使用目标日期作为缓存键)
|
||||
$cacheKey = "Checkin::sendMsg-{$displayDate}-{$type}:" . $checkin['userid'];
|
||||
$typeContent = $type == "up" ? "上班" : "下班";
|
||||
if (Cache::get($cacheKey) === "yes") {
|
||||
if ($alreadyTip) {
|
||||
$text = "今日已{$typeContent}打卡,无需重复打卡。";
|
||||
$dateHint = ($displayDate != $nowDate) ? "({$displayDate}) " : "今日";
|
||||
$text = "{$dateHint}已{$typeContent}打卡,无需重复打卡。";
|
||||
$text .= $checkin['remark'] ? " ({$checkin['remark']})": "";
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
@@ -467,7 +501,8 @@ class UserBot extends AbstractModel
|
||||
$hi = date("H:i");
|
||||
$remark = $checkin['remark'] ? " ({$checkin['remark']})": "";
|
||||
$subcontent = $getJokeSoup($type, $checkin['userid']);
|
||||
$title = "{$typeContent}打卡成功,打卡时间: {$hi}{$remark}";
|
||||
$dateInfo = ($displayDate != $nowDate) ? " ({$displayDate})" : "";
|
||||
$title = "{$typeContent}打卡成功,打卡时间: {$hi}{$remark}{$dateInfo}";
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'title' => $title,
|
||||
@@ -482,14 +517,13 @@ class UserBot extends AbstractModel
|
||||
],
|
||||
], $botUser->userid, false, false, $type != "up");
|
||||
};
|
||||
if ($timeAdvance <= Timer::time() && Timer::time() < $timeEnd) {
|
||||
// 上班打卡通知(从最早打卡时间 到 下班打卡时间)
|
||||
// 根据打卡类型发送通知
|
||||
if ($checkType === 'up') {
|
||||
foreach ($checkins as $checkin) {
|
||||
$sendMsg('up', $checkin);
|
||||
}
|
||||
}
|
||||
if ($timeEnd <= Timer::time() && Timer::time() <= $timeDelay) {
|
||||
// 下班打卡通知(下班打卡时间 到 最晚打卡时间)
|
||||
if ($checkType === 'down') {
|
||||
foreach ($checkins as $checkin) {
|
||||
$sendMsg('down', $checkin);
|
||||
}
|
||||
|
||||
@@ -88,16 +88,32 @@ class UserCheckinRecord extends AbstractModel
|
||||
|
||||
/**
|
||||
* 时间收集
|
||||
* @param string $data
|
||||
* @param array $times
|
||||
* @param string $data 日期
|
||||
* @param array $times 签到时间数组
|
||||
* @param string|null $shiftStart 班次开始时间(如 "09:00"),用于判断跨天
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public static function atCollect($data, $times)
|
||||
public static function atCollect($data, $times, $shiftStart = null)
|
||||
{
|
||||
$sameTimes = array_map(function($time) use ($data) {
|
||||
$shiftStartMinutes = null;
|
||||
if ($shiftStart) {
|
||||
$parts = explode(':', $shiftStart);
|
||||
$shiftStartMinutes = intval($parts[0]) * 60 + intval($parts[1]);
|
||||
}
|
||||
|
||||
$sameTimes = array_map(function($time) use ($data, $shiftStartMinutes) {
|
||||
$parts = explode(':', $time);
|
||||
$timeMinutes = intval($parts[0]) * 60 + intval($parts[1]);
|
||||
|
||||
// 如果签到时间早于班次开始时间,视为跨天打卡(属于次日凌晨)
|
||||
$targetDate = $data;
|
||||
if ($shiftStartMinutes !== null && $timeMinutes < $shiftStartMinutes) {
|
||||
$targetDate = date("Y-m-d", strtotime($data . " +1 day"));
|
||||
}
|
||||
|
||||
return [
|
||||
"datetime" => "{$data} {$time}",
|
||||
"timestamp" => strtotime("{$data} {$time}")
|
||||
"datetime" => "{$targetDate} {$time}",
|
||||
"timestamp" => strtotime("{$targetDate} {$time}")
|
||||
];
|
||||
}, $times);
|
||||
return collect($sameTimes);
|
||||
|
||||
@@ -5,6 +5,37 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* App\Models\UserTag
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id 被标签用户ID
|
||||
* @property string $name 标签名称
|
||||
* @property int $created_by 创建人
|
||||
* @property int|null $updated_by 最后更新人
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\User $creator
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserTagRecognition> $recognitions
|
||||
* @property-read int|null $recognitions_count
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereCreatedBy($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereName($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereUpdatedBy($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereUserId($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserTag extends AbstractModel
|
||||
{
|
||||
protected $table = 'user_tags';
|
||||
|
||||
@@ -4,6 +4,32 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\UserTagRecognition
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tag_id 标签ID
|
||||
* @property int $user_id 认可人ID
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\UserTag $tag
|
||||
* @property-read \App\Models\User $user
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereTagId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereUserId($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserTagRecognition extends AbstractModel
|
||||
{
|
||||
protected $table = 'user_tag_recognitions';
|
||||
|
||||
@@ -44,6 +44,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property-read int|mixed $percentage
|
||||
* @property-read \App\Models\User|null $user
|
||||
* @property-read \App\Models\WebSocketDialog|null $webSocketDialog
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg accessibleByUser(int $userid)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
@@ -54,6 +55,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereBot($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereDeletedAt($value)
|
||||
@@ -111,6 +113,36 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return $this->hasOne(User::class, 'userid', 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词搜索消息(Scope)
|
||||
* 搜索 key 字段(消息的可搜索内容)
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
return $query->where('key', 'like', "%{$keyword}%");
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选用户可访问的对话消息(Scope)
|
||||
* 通过 web_socket_dialog_users 表验证用户对对话的访问权限
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param int $userid 用户ID
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeAccessibleByUser($query, int $userid)
|
||||
{
|
||||
return $query->whereIn('dialog_id', function ($subQuery) use ($userid) {
|
||||
$subQuery->select('dialog_id')
|
||||
->from('web_socket_dialog_users')
|
||||
->where('userid', $userid);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 阅读占比
|
||||
* @return int|mixed
|
||||
@@ -460,9 +492,58 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
* @param string $leaveMessage 转发留言
|
||||
* @return mixed
|
||||
*/
|
||||
/**
|
||||
* 收集目标对话
|
||||
* @param array|int $userids 转发给的成员ID
|
||||
* @param array|int $dialogids 转发给的对话ID
|
||||
* @param User $user 当前用户
|
||||
* @return array
|
||||
*/
|
||||
private static function collectTargetDialogs($userids, $dialogids, $user)
|
||||
{
|
||||
$dialogs = [];
|
||||
if ($userids) {
|
||||
if (!is_array($userids)) {
|
||||
$userids = [$userids];
|
||||
}
|
||||
foreach ($userids as $userid) {
|
||||
if (!User::whereUserid($userid)->exists()) {
|
||||
continue;
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
|
||||
if ($dialog) {
|
||||
$dialogs[$dialog->id] = $dialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($dialogids) {
|
||||
if (!is_array($dialogids)) {
|
||||
$dialogids = [$dialogids];
|
||||
}
|
||||
foreach ($dialogids as $dialogid) {
|
||||
if (isset($dialogs[$dialogid])) {
|
||||
continue;
|
||||
}
|
||||
$dialog = WebSocketDialog::find($dialogid);
|
||||
if ($dialog) {
|
||||
$dialogs[$dialog->id] = $dialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $dialogs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 不支持转发的消息类型
|
||||
*/
|
||||
public static $unforwardableTypes = ['tag', 'top', 'todo', 'notice', 'word-chain', 'vote', 'template'];
|
||||
|
||||
public function forwardMsg($dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
|
||||
{
|
||||
return AbstractModel::transaction(function () use ($dialogids, $user, $userids, $showSource, $leaveMessage) {
|
||||
if (in_array($this->type, self::$unforwardableTypes)) {
|
||||
throw new ApiException('此类型消息不支持转发');
|
||||
}
|
||||
$msgData = Base::json2array($this->getRawOriginal('msg'));
|
||||
$forwardData = is_array($msgData['forward_data']) ? $msgData['forward_data'] : [];
|
||||
$forwardId = $forwardData['id'] ?: $this->id;
|
||||
@@ -481,35 +562,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
'leave' => $leaveMessage ? 1 : 0, // 是否留言(用于判断是否发给AI)
|
||||
];
|
||||
$msgs = [];
|
||||
$dialogs = [];
|
||||
if ($userids) {
|
||||
if (!is_array($userids)) {
|
||||
$userids = [$userids];
|
||||
}
|
||||
foreach ($userids as $userid) {
|
||||
if (!User::whereUserid($userid)->exists()) {
|
||||
continue;
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
|
||||
if ($dialog) {
|
||||
$dialogs[$dialog->id] = $dialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($dialogids) {
|
||||
if (!is_array($dialogids)) {
|
||||
$dialogids = [$dialogids];
|
||||
}
|
||||
foreach ($dialogids as $dialogid) {
|
||||
if (isset($dialogs[$dialogid])) {
|
||||
continue;
|
||||
}
|
||||
$dialog = WebSocketDialog::find($dialogid);
|
||||
if ($dialog) {
|
||||
$dialogs[$dialog->id] = $dialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
$dialogs = self::collectTargetDialogs($userids, $dialogids, $user);
|
||||
foreach ($dialogs as $dialog) {
|
||||
$res = self::sendMsg('forward-' . $forwardId, $dialog->id, $this->type, $msgData, $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
@@ -532,6 +585,105 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并转发消息
|
||||
* @param array $msgIds 消息ID数组
|
||||
* @param array|int $dialogids 转发给的对话ID
|
||||
* @param array|int $userids 转发给的成员ID
|
||||
* @param User $user 当前用户
|
||||
* @param int $showSource 是否显示原发送者信息
|
||||
* @param string $leaveMessage 转发留言
|
||||
* @return array
|
||||
*/
|
||||
public static function mergeForwardMsg($msgIds, $dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
|
||||
{
|
||||
return AbstractModel::transaction(function () use ($msgIds, $dialogids, $userids, $user, $showSource, $leaveMessage) {
|
||||
// 查询并验证所有消息
|
||||
$msgs = self::whereIn('id', $msgIds)->orderBy('created_at')->get();
|
||||
if ($msgs->isEmpty()) {
|
||||
throw new ApiException('消息不存在或已被删除');
|
||||
}
|
||||
// 验证所有消息属于同一对话
|
||||
$dialogId = $msgs->first()->dialog_id;
|
||||
if ($msgs->pluck('dialog_id')->unique()->count() > 1) {
|
||||
throw new ApiException('只能合并转发同一对话的消息');
|
||||
}
|
||||
WebSocketDialog::checkDialog($dialogId);
|
||||
// 过滤不支持转发的消息类型
|
||||
$msgs = $msgs->filter(function ($msg) {
|
||||
return !in_array($msg->type, self::$unforwardableTypes);
|
||||
});
|
||||
if ($msgs->isEmpty()) {
|
||||
throw new ApiException('所选消息均不支持转发');
|
||||
}
|
||||
// 收集发送者信息
|
||||
$senderIds = $msgs->pluck('userid')->unique()->values()->toArray();
|
||||
$senderNames = User::whereIn('userid', array_slice($senderIds, 0, 2))
|
||||
->pluck('nickname')
|
||||
->toArray();
|
||||
// 组装预览列表(前4条,精简字段)
|
||||
$msgIds = $msgs->pluck('id')->toArray();
|
||||
$preview = [];
|
||||
foreach ($msgs->take(4) as $msg) {
|
||||
$preview[] = [
|
||||
'userid' => $msg->userid,
|
||||
'type' => $msg->type,
|
||||
'msg' => self::buildPreviewMsg($msg->type, Base::json2array($msg->getRawOriginal('msg'))),
|
||||
];
|
||||
}
|
||||
// 构建合并转发消息体
|
||||
$msgData = [
|
||||
'sender_names' => $senderNames,
|
||||
'sender_total' => count($senderIds),
|
||||
'msg_ids' => $msgIds,
|
||||
'preview' => $preview,
|
||||
'count' => count($msgIds),
|
||||
'forward_data' => [
|
||||
'show' => $showSource,
|
||||
'leave' => $leaveMessage ? 1 : 0,
|
||||
],
|
||||
];
|
||||
$dialogs = self::collectTargetDialogs($userids, $dialogids, $user);
|
||||
// 发送到每个目标对话
|
||||
$result = [];
|
||||
foreach ($dialogs as $dialog) {
|
||||
$res = self::sendMsg(null, $dialog->id, 'merge-forward', $msgData, $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
$result[] = $res['data'];
|
||||
}
|
||||
if ($leaveMessage) {
|
||||
$res = self::sendMsg(null, $dialog->id, 'text', ['text' => $leaveMessage], $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
$result[] = $res['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('转发成功', [
|
||||
'msgs' => $result
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建预览消息(精简字段)
|
||||
* @param string $type
|
||||
* @param array $msg
|
||||
* @return array
|
||||
*/
|
||||
private static function buildPreviewMsg($type, $msg)
|
||||
{
|
||||
switch ($type) {
|
||||
case 'text':
|
||||
return ['text' => $msg['text'] ?? ''];
|
||||
case 'file':
|
||||
return ['name' => $msg['name'] ?? '', 'ext' => $msg['ext'] ?? ''];
|
||||
case 'location':
|
||||
return ['title' => $msg['title'] ?? ''];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
* @param array|int $ids
|
||||
@@ -663,6 +815,9 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
case 'template':
|
||||
return self::previewTemplateMsg($data['msg']);
|
||||
|
||||
case 'merge-forward':
|
||||
return "[" . Doo::translate("聊天记录") . "]";
|
||||
|
||||
case 'preview':
|
||||
return $data['msg']['preview'];
|
||||
|
||||
@@ -683,6 +838,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
$text = $msgData['text'] ?? '';
|
||||
if (!$text) return '';
|
||||
if ($msgData['type'] === 'md') {
|
||||
$text = preg_replace('/<\/?tool-use[^>]*>/', '', $text);
|
||||
$text = preg_replace("/:::\s*reasoning[\s\S]*?:::/", "", $text);
|
||||
if (preg_match('/:::\s*reasoning\s+/', $text)) {
|
||||
return Doo::translate('思考中...');
|
||||
@@ -901,6 +1057,9 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
$result = mb_substr($result, 0, $maxLength);
|
||||
}
|
||||
|
||||
// 规范以斜杠开头的命令
|
||||
$result = preg_replace('/^\s*\\//', '/', $result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -1226,6 +1385,9 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
$msg['height'] = $imageSize[1];
|
||||
}
|
||||
}
|
||||
if ($type === 'merge-forward') {
|
||||
$mtype = 'merge-forward';
|
||||
}
|
||||
if ($push_silence === null) {
|
||||
$push_silence = !in_array($type, ["text", "file", "record", "meeting"]);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Module;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
|
||||
@@ -165,8 +166,28 @@ class AI
|
||||
continue;
|
||||
}
|
||||
$role = trim((string)($item[0] ?? ''));
|
||||
$message = trim((string)($item[1] ?? ''));
|
||||
if ($role === '' || $message === '') {
|
||||
$message = $item[1] ?? '';
|
||||
|
||||
// 跳过空消息
|
||||
if (empty($message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理纯文本(字符串)
|
||||
if (!is_array($message)) {
|
||||
// 纯文本
|
||||
$message = trim((string)$message);
|
||||
if ($role === '' || $message === '') {
|
||||
continue;
|
||||
}
|
||||
// 替换系统条件性提示块占位符
|
||||
if (str_contains($message, '{{SYSTEM_OPTIONAL_PROMPTS}}')) {
|
||||
$optionalPrompts = PromptPlaceholder::buildOptionalPrompts(User::userid());
|
||||
$message = str_replace('{{SYSTEM_OPTIONAL_PROMPTS}}', $optionalPrompts, $message);
|
||||
}
|
||||
}
|
||||
|
||||
if ($role === '') {
|
||||
continue;
|
||||
}
|
||||
$context[] = [$role, $message];
|
||||
@@ -183,12 +204,6 @@ class AI
|
||||
}
|
||||
|
||||
$apiKey = Base::val($setting, $modelType . '_key');
|
||||
if ($modelType === 'wenxin') {
|
||||
$wenxinSecret = Base::val($setting, 'wenxin_secret');
|
||||
if ($wenxinSecret) {
|
||||
$apiKey = trim(($apiKey ?: '') . ':' . $wenxinSecret);
|
||||
}
|
||||
}
|
||||
if ($modelType === 'ollama' && empty($apiKey)) {
|
||||
$apiKey = Base::strRandom(6);
|
||||
}
|
||||
@@ -255,6 +270,101 @@ class AI
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 AI 调用接口
|
||||
* 适用于自定义对话场景
|
||||
*
|
||||
* @param array $messages 消息数组,格式:[['role', 'content'], ...]
|
||||
* role: system | user | assistant
|
||||
* @param int $timeout 超时时间(秒)
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
* @return array 返回结果,成功时 data 包含 content 字段
|
||||
*/
|
||||
public static function invoke(array $messages, int $timeout = 60, bool $noCache = true): array
|
||||
{
|
||||
if (!Apps::isInstalled('ai')) {
|
||||
return Base::retError('应用「AI Assistant」未安装');
|
||||
}
|
||||
|
||||
if (empty($messages)) {
|
||||
return Base::retError('消息内容不能为空');
|
||||
}
|
||||
|
||||
$provider = self::resolveTextProvider();
|
||||
if (!$provider) {
|
||||
return Base::retError("请先配置 AI 助手");
|
||||
}
|
||||
|
||||
// 转换消息格式
|
||||
$formattedMessages = [];
|
||||
foreach ($messages as $msg) {
|
||||
if (!is_array($msg) || count($msg) < 2) {
|
||||
continue;
|
||||
}
|
||||
$role = trim((string)($msg[0] ?? ''));
|
||||
$content = trim((string)($msg[1] ?? ''));
|
||||
if ($role === '' || $content === '') {
|
||||
continue;
|
||||
}
|
||||
// 标准化 role
|
||||
$role = match ($role) {
|
||||
'system' => 'system',
|
||||
'assistant' => 'assistant',
|
||||
default => 'user',
|
||||
};
|
||||
$formattedMessages[] = [
|
||||
'role' => $role,
|
||||
'content' => $content,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($formattedMessages)) {
|
||||
return Base::retError('消息内容格式错误');
|
||||
}
|
||||
|
||||
// 构建缓存 key
|
||||
$cacheKey = "AIInvoke::" . md5(json_encode($formattedMessages));
|
||||
if ($noCache) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addHours(1), function () use ($formattedMessages, $provider, $timeout) {
|
||||
$payload = [
|
||||
"model" => $provider['model'],
|
||||
"messages" => $formattedMessages,
|
||||
];
|
||||
$reasoningEffort = self::getReasoningEffort($provider);
|
||||
if ($reasoningEffort !== null) {
|
||||
$payload['reasoning_effort'] = $reasoningEffort;
|
||||
}
|
||||
$post = json_encode($payload);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setProvider($provider);
|
||||
$ai->setTimeout($timeout);
|
||||
|
||||
$res = $ai->request();
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("AI 调用失败", $res);
|
||||
}
|
||||
|
||||
$content = $res['data'];
|
||||
if (empty($content)) {
|
||||
return Base::retError("AI 返回内容为空");
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'content' => $content,
|
||||
]);
|
||||
});
|
||||
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** ******************************************************************************************** */
|
||||
/** ******************************************************************************************** */
|
||||
/** ******************************************************************************************** */
|
||||
@@ -271,6 +381,9 @@ class AI
|
||||
{
|
||||
Apps::isInstalledThrow('ai');
|
||||
|
||||
$extParams = $extParams ?: [];
|
||||
$extHeaders = $extHeaders ?: [];
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
return Base::retError("语音文件不存在");
|
||||
}
|
||||
@@ -287,7 +400,7 @@ class AI
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function () use ($extParams, $extHeaders, $filePath, $audioProvider) {
|
||||
$post = array_merge($extParams, [
|
||||
'file' => new \CURLFile($filePath),
|
||||
'model' => 'whisper-1',
|
||||
'model' => 'gpt-4o-mini-transcribe',
|
||||
]);
|
||||
$header = array_merge($extHeaders, [
|
||||
'Content-Type' => 'multipart/form-data',
|
||||
@@ -373,8 +486,9 @@ class AI
|
||||
]
|
||||
],
|
||||
];
|
||||
if (self::shouldSendReasoningEffort($provider)) {
|
||||
$payload['reasoning_effort'] = 'minimal';
|
||||
$reasoningEffort = self::getReasoningEffort($provider);
|
||||
if ($reasoningEffort !== null) {
|
||||
$payload['reasoning_effort'] = $reasoningEffort;
|
||||
}
|
||||
$post = json_encode($payload);
|
||||
|
||||
@@ -454,8 +568,9 @@ class AI
|
||||
]
|
||||
],
|
||||
];
|
||||
if (self::shouldSendReasoningEffort($provider)) {
|
||||
$payload['reasoning_effort'] = 'minimal';
|
||||
$reasoningEffort = self::getReasoningEffort($provider);
|
||||
if ($reasoningEffort !== null) {
|
||||
$payload['reasoning_effort'] = $reasoningEffort;
|
||||
}
|
||||
$post = json_encode($payload);
|
||||
|
||||
@@ -542,8 +657,9 @@ class AI
|
||||
]
|
||||
],
|
||||
];
|
||||
if (self::shouldSendReasoningEffort($provider)) {
|
||||
$payload['reasoning_effort'] = 'minimal';
|
||||
$reasoningEffort = self::getReasoningEffort($provider);
|
||||
if ($reasoningEffort !== null) {
|
||||
$payload['reasoning_effort'] = $reasoningEffort;
|
||||
}
|
||||
$post = json_encode($payload);
|
||||
|
||||
@@ -646,14 +762,6 @@ class AI
|
||||
}
|
||||
$model = trim((string)($setting[$vendor . '_model'] ?? ''));
|
||||
break;
|
||||
case 'wenxin':
|
||||
$secret = trim((string)($setting['wenxin_secret'] ?? ''));
|
||||
if ($key === '' || $secret === '' || $baseUrl === '') {
|
||||
return null;
|
||||
}
|
||||
$key = $key . ':' . $secret;
|
||||
$model = trim((string)($setting[$vendor . '_model'] ?? ''));
|
||||
break;
|
||||
default:
|
||||
if ($key === '' || $baseUrl === '') {
|
||||
return null;
|
||||
@@ -712,7 +820,7 @@ class AI
|
||||
|
||||
return [
|
||||
'vendor' => 'openai',
|
||||
'model' => 'whisper-1',
|
||||
'model' => 'gpt-4o-mini-transcribe',
|
||||
'api_key' => $key,
|
||||
'base_url' => rtrim($baseUrl, '/'),
|
||||
'agency' => $agency,
|
||||
@@ -720,22 +828,300 @@ class AI
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要附加 reasoning_effort 参数
|
||||
* 获取 reasoning_effort 参数值
|
||||
* @param array $provider
|
||||
* @return bool
|
||||
* @return string|null 返回 'none'/'low' 或 null(不需要此参数)
|
||||
*/
|
||||
protected static function shouldSendReasoningEffort(array $provider): bool
|
||||
protected static function getReasoningEffort(array $provider): ?string
|
||||
{
|
||||
if (($provider['vendor'] ?? '') !== 'openai') {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
$model = $provider['model'] ?? '';
|
||||
|
||||
// 匹配 gpt- 开头后跟数字的模型名称
|
||||
if (preg_match('/^gpt-(\d+)/', $model, $matches)) {
|
||||
return intval($matches[1]) >= 5;
|
||||
|
||||
// gpt-5.1 及之后版本支持 none
|
||||
if (preg_match('/^gpt-(\d+)\.(\d+)/', $model, $matches)) {
|
||||
$major = intval($matches[1]);
|
||||
$minor = intval($matches[2]);
|
||||
if ($major > 5 || ($major === 5 && $minor >= 1)) {
|
||||
return 'none';
|
||||
}
|
||||
if ($major === 5) {
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
// gpt-5 (无小版本号) 使用 low
|
||||
if (preg_match('/^gpt-(\d+)(?![.\d])/', $model, $matches)) {
|
||||
if (intval($matches[1]) >= 5) {
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 OpenAI 兼容接口获取文本的 Embedding 向量
|
||||
*
|
||||
* @param string $text 需要转换的文本
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
* @return array 返回结果,成功时 data 为向量数组
|
||||
*/
|
||||
public static function getEmbedding($text, $noCache = false)
|
||||
{
|
||||
if (!Apps::isInstalled('ai')) {
|
||||
return Base::retError('应用「AI Assistant」未安装');
|
||||
}
|
||||
|
||||
if (empty($text)) {
|
||||
return Base::retError('文本内容不能为空');
|
||||
}
|
||||
|
||||
// 截断过长的文本(OpenAI 限制 8191 tokens,约 32K 字符)
|
||||
$text = mb_substr($text, 0, 30000);
|
||||
|
||||
$cacheKey = "openAIEmbedding::" . md5($text);
|
||||
if ($noCache) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
$provider = self::resolveEmbeddingProvider();
|
||||
if (!$provider) {
|
||||
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
|
||||
}
|
||||
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $provider) {
|
||||
$payload = [
|
||||
"model" => $provider['model'],
|
||||
"input" => $text,
|
||||
];
|
||||
|
||||
// 统一向量维度为 1536(与 Manticore 配置一致)
|
||||
// OpenAI、智谱等支持 dimensions 参数的厂商需要显式指定
|
||||
$supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']);
|
||||
if ($supportsDimensions) {
|
||||
$payload['dimensions'] = 1536;
|
||||
}
|
||||
|
||||
$post = json_encode($payload);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setProvider($provider);
|
||||
$ai->setUrlPath('/embeddings');
|
||||
$ai->setTimeout(30);
|
||||
|
||||
$res = $ai->request(true);
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("Embedding 请求失败", $res);
|
||||
}
|
||||
|
||||
$resData = Base::json2array($res['data']);
|
||||
if (empty($resData['data'][0]['embedding'])) {
|
||||
return Base::retError("Embedding 接口返回数据格式错误", $resData);
|
||||
}
|
||||
|
||||
$embedding = $resData['data'][0]['embedding'];
|
||||
if (!is_array($embedding) || empty($embedding)) {
|
||||
return Base::retError("Embedding 向量为空");
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", $embedding);
|
||||
});
|
||||
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取文本的 Embedding 向量
|
||||
* OpenAI API 原生支持批量输入,一次请求处理多个文本
|
||||
*
|
||||
* @param array $texts 文本数组(最多 100 条)
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
* @return array 返回结果,成功时 data 为向量数组的数组(与输入顺序对应)
|
||||
*/
|
||||
public static function getBatchEmbeddings(array $texts, $noCache = false)
|
||||
{
|
||||
if (!Apps::isInstalled('ai')) {
|
||||
return Base::retError('应用「AI Assistant」未安装');
|
||||
}
|
||||
|
||||
if (empty($texts)) {
|
||||
return Base::retSuccess("success", []);
|
||||
}
|
||||
|
||||
// 限制批量大小
|
||||
// OpenAI 限制:最多 2048 条,单次请求合计最多 300,000 tokens
|
||||
// 这里限制 500 条,假设平均每条 500 tokens,合计 250,000 tokens
|
||||
$texts = array_slice($texts, 0, 500);
|
||||
|
||||
// 准备结果数组,并检查缓存
|
||||
$results = [];
|
||||
$uncachedTexts = [];
|
||||
$uncachedIndices = [];
|
||||
|
||||
foreach ($texts as $index => $text) {
|
||||
if (empty($text)) {
|
||||
$results[$index] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 截断过长的文本
|
||||
$text = mb_substr($text, 0, 30000);
|
||||
$texts[$index] = $text; // 更新截断后的文本
|
||||
|
||||
$cacheKey = "openAIEmbedding::" . md5($text);
|
||||
|
||||
if ($noCache) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
if (!$noCache && Cache::has($cacheKey)) {
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (Base::isSuccess($cached)) {
|
||||
$results[$index] = $cached['data'];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 未命中缓存,加入待请求列表
|
||||
$uncachedTexts[] = $text;
|
||||
$uncachedIndices[] = $index;
|
||||
}
|
||||
|
||||
// 如果所有文本都在缓存中
|
||||
if (empty($uncachedTexts)) {
|
||||
// 按原始顺序返回
|
||||
ksort($results);
|
||||
return Base::retSuccess("success", array_values($results));
|
||||
}
|
||||
|
||||
// 获取 provider
|
||||
$provider = self::resolveEmbeddingProvider();
|
||||
if (!$provider) {
|
||||
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
|
||||
}
|
||||
|
||||
// 构建批量请求
|
||||
$payload = [
|
||||
"model" => $provider['model'],
|
||||
"input" => $uncachedTexts,
|
||||
];
|
||||
|
||||
$supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']);
|
||||
if ($supportsDimensions) {
|
||||
$payload['dimensions'] = 1536;
|
||||
}
|
||||
|
||||
$post = json_encode($payload);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setProvider($provider);
|
||||
$ai->setUrlPath('/embeddings');
|
||||
$ai->setTimeout(120); // 批量请求需要更长超时
|
||||
|
||||
$res = $ai->request(true);
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("批量 Embedding 请求失败", $res);
|
||||
}
|
||||
|
||||
$resData = Base::json2array($res['data']);
|
||||
if (empty($resData['data'])) {
|
||||
return Base::retError("Embedding 接口返回数据格式错误", $resData);
|
||||
}
|
||||
|
||||
// 处理返回的向量并写入缓存
|
||||
foreach ($resData['data'] as $item) {
|
||||
$itemIndex = $item['index'] ?? null;
|
||||
if ($itemIndex === null || !isset($uncachedIndices[$itemIndex])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalIndex = $uncachedIndices[$itemIndex];
|
||||
$embedding = $item['embedding'] ?? [];
|
||||
|
||||
if (!empty($embedding) && is_array($embedding)) {
|
||||
$results[$originalIndex] = $embedding;
|
||||
} else {
|
||||
$results[$originalIndex] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 填充未获取到向量的位置
|
||||
foreach ($uncachedIndices as $originalIndex) {
|
||||
if (!isset($results[$originalIndex])) {
|
||||
$results[$originalIndex] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 按原始顺序返回
|
||||
ksort($results);
|
||||
return Base::retSuccess("success", array_values($results));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Embedding 模型配置
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
protected static function resolveEmbeddingProvider()
|
||||
{
|
||||
$setting = Base::setting('aibotSetting');
|
||||
if (!is_array($setting)) {
|
||||
$setting = [];
|
||||
}
|
||||
|
||||
// 优先使用 OpenAI(支持 embedding 接口)
|
||||
$key = trim((string)($setting['openai_key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$baseUrl = trim((string)($setting['openai_base_url'] ?? ''));
|
||||
$baseUrl = $baseUrl ?: 'https://api.openai.com/v1';
|
||||
$agency = trim((string)($setting['openai_agency'] ?? ''));
|
||||
|
||||
return [
|
||||
'vendor' => 'openai',
|
||||
'model' => 'text-embedding-3-small',
|
||||
'api_key' => $key,
|
||||
'base_url' => rtrim($baseUrl, '/'),
|
||||
'agency' => $agency,
|
||||
];
|
||||
}
|
||||
|
||||
$vendorDefaults = [
|
||||
'deepseek' => [
|
||||
'base_url' => 'https://api.deepseek.com',
|
||||
'model' => 'deepseek-embedding',
|
||||
],
|
||||
'zhipu' => [
|
||||
'base_url' => 'https://open.bigmodel.cn/api/paas/v4',
|
||||
'model' => 'embedding-3',
|
||||
],
|
||||
];
|
||||
|
||||
// 尝试其他支持 embedding 的服务(如 deepseek、zhipu、qianwen 等)
|
||||
foreach ($vendorDefaults as $vendor => $defaults) {
|
||||
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
|
||||
|
||||
if ($key !== '') {
|
||||
$baseUrl = trim((string)($setting[$vendor . '_base_url'] ?? ''));
|
||||
$baseUrl = $baseUrl ?: $defaults['base_url']; // 使用配置或默认值
|
||||
$agency = trim((string)($setting[$vendor . '_agency'] ?? ''));
|
||||
|
||||
return [
|
||||
'vendor' => $vendor,
|
||||
'model' => $defaults['model'],
|
||||
'api_key' => $key,
|
||||
'base_url' => rtrim($baseUrl, '/'),
|
||||
'agency' => $agency,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
858
app/Module/AiTaskSuggestion.php
Normal file
858
app/Module/AiTaskSuggestion.php
Normal file
@@ -0,0 +1,858 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskAiEvent;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreBase;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AiTaskSuggestion
|
||||
{
|
||||
/**
|
||||
* AI 助手的 userid
|
||||
*/
|
||||
const AI_ASSISTANT_USERID = -1;
|
||||
|
||||
/**
|
||||
* 相似度阈值
|
||||
*/
|
||||
const SIMILAR_THRESHOLD = 0.5;
|
||||
|
||||
/**
|
||||
* 检查是否满足执行条件
|
||||
*/
|
||||
public static function shouldExecute(ProjectTask $task, string $eventType): bool
|
||||
{
|
||||
switch ($eventType) {
|
||||
case ProjectTaskAiEvent::EVENT_DESCRIPTION:
|
||||
// 描述为空或长度 < 20
|
||||
$content = trim($task->content ?? '');
|
||||
return empty($content) || mb_strlen($content) < 20;
|
||||
|
||||
case ProjectTaskAiEvent::EVENT_SUBTASKS:
|
||||
// 无子任务且标题长度 > 5
|
||||
$hasSubtasks = ProjectTask::where('parent_id', $task->id)->exists();
|
||||
return !$hasSubtasks && mb_strlen($task->name) > 5;
|
||||
|
||||
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
|
||||
// 未指定负责人
|
||||
$hasOwner = ProjectTaskUser::where('task_id', $task->id)->where('owner', 1)->exists();
|
||||
return !$hasOwner;
|
||||
|
||||
case ProjectTaskAiEvent::EVENT_SIMILAR:
|
||||
// 需要安装 search 插件才能使用向量搜索
|
||||
return Apps::isInstalled('search');
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成任务描述建议
|
||||
* @param ProjectTask $task 任务对象
|
||||
*/
|
||||
public static function generateDescription(ProjectTask $task): ?array
|
||||
{
|
||||
$language = self::getUserLanguageInfo($task->userid)['name'];
|
||||
$prompt = self::buildDescriptionPrompt($task, $language);
|
||||
$result = self::callAi($prompt);
|
||||
|
||||
if (empty($result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'description',
|
||||
'content' => $result,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成子任务拆分建议
|
||||
* @param ProjectTask $task 任务对象
|
||||
*/
|
||||
public static function generateSubtasks(ProjectTask $task): ?array
|
||||
{
|
||||
$language = self::getUserLanguageInfo($task->userid)['name'];
|
||||
$prompt = self::buildSubtasksPrompt($task, $language);
|
||||
$result = self::callAi($prompt);
|
||||
|
||||
if (empty($result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析返回的子任务列表
|
||||
$subtasks = self::parseSubtasksList($result);
|
||||
if (empty($subtasks)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'subtasks',
|
||||
'content' => $subtasks,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成负责人推荐
|
||||
* @param ProjectTask $task 任务对象
|
||||
*/
|
||||
public static function generateAssignee(ProjectTask $task): ?array
|
||||
{
|
||||
// 获取当前任务已有的成员(负责人和协助人)
|
||||
$existingUserIds = ProjectTaskUser::where('task_id', $task->id)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
// 获取项目成员,排除已有任务成员
|
||||
$members = self::getProjectMembersInfo($task->project_id);
|
||||
$members = array_filter($members, function ($member) use ($existingUserIds) {
|
||||
return !in_array($member['userid'], $existingUserIds);
|
||||
});
|
||||
$members = array_values($members); // 重新索引
|
||||
|
||||
if (empty($members)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$language = self::getUserLanguageInfo($task->userid)['name'];
|
||||
$prompt = self::buildAssigneePrompt($task, $members, $language);
|
||||
$result = self::callAi($prompt);
|
||||
|
||||
if (empty($result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析推荐结果
|
||||
$recommendations = self::parseAssigneeRecommendations($result, $members);
|
||||
if (empty($recommendations)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'assignee',
|
||||
'content' => $recommendations,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索相似任务
|
||||
* @param ProjectTask $task 任务对象
|
||||
*/
|
||||
public static function findSimilarTasks(ProjectTask $task): ?array
|
||||
{
|
||||
// 使用 AI 模块的 Embedding 搜索
|
||||
$searchText = $task->name;
|
||||
if (empty($searchText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = AI::getEmbedding($searchText);
|
||||
if (Base::isError($result) || empty($result['data'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$embedding = $result['data'];
|
||||
|
||||
// 搜索相似任务(排除自己和子任务)
|
||||
$similarTasks = self::searchSimilarByEmbedding(
|
||||
$embedding,
|
||||
$task->project_id,
|
||||
$task->id
|
||||
);
|
||||
|
||||
if (empty($similarTasks)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取用户语言对应的文案
|
||||
$lang = self::getUserLanguageInfo($task->userid)['code'];
|
||||
|
||||
return [
|
||||
'type' => 'similar',
|
||||
'lang' => $lang,
|
||||
'content' => $similarTasks,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('AiTaskSuggestion::findSimilarTasks error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户语言信息
|
||||
* @param int $userid 用户ID
|
||||
* @return array ['code' => 语言代码, 'name' => 语言名称]
|
||||
*/
|
||||
private static function getUserLanguageInfo(int $userid): array
|
||||
{
|
||||
$user = User::find($userid);
|
||||
$code = $user->lang ?? 'zh';
|
||||
$name = Doo::getLanguages($code) ?: '简体中文';
|
||||
return ['code' => $code, 'name' => $name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多语言标题和提示文案
|
||||
* @param string $lang 语言代码
|
||||
* @return array
|
||||
*/
|
||||
private static function getLocalizedTitles(string $lang): array
|
||||
{
|
||||
$titles = [
|
||||
'zh' => [
|
||||
'description' => '建议补充任务描述',
|
||||
'subtasks' => '建议拆分子任务',
|
||||
'assignee' => '推荐负责人',
|
||||
'similar' => '发现相似任务',
|
||||
'similar_hint' => '以下任务与当前任务内容相似,可能是重复任务或可作为参考:',
|
||||
],
|
||||
'zh-CHT' => [
|
||||
'description' => '建議補充任務描述',
|
||||
'subtasks' => '建議拆分子任務',
|
||||
'assignee' => '推薦負責人',
|
||||
'similar' => '發現相似任務',
|
||||
'similar_hint' => '以下任務與當前任務內容相似,可能是重複任務或可作為參考:',
|
||||
],
|
||||
'en' => [
|
||||
'description' => 'Suggested Task Description',
|
||||
'subtasks' => 'Suggested Subtasks',
|
||||
'assignee' => 'Recommended Assignee',
|
||||
'similar' => 'Similar Tasks Found',
|
||||
'similar_hint' => 'The following tasks are similar and may be duplicates or references:',
|
||||
],
|
||||
'ko' => [
|
||||
'description' => '작업 설명 추가 제안',
|
||||
'subtasks' => '하위 작업 분할 제안',
|
||||
'assignee' => '추천 담당자',
|
||||
'similar' => '유사한 작업 발견',
|
||||
'similar_hint' => '다음 작업은 현재 작업과 유사하며 중복되거나 참고할 수 있습니다:',
|
||||
],
|
||||
'ja' => [
|
||||
'description' => 'タスク説明の追加を提案',
|
||||
'subtasks' => 'サブタスクの分割を提案',
|
||||
'assignee' => '推奨担当者',
|
||||
'similar' => '類似タスクを発見',
|
||||
'similar_hint' => '以下のタスクは現在のタスクと類似しており、重複している可能性があります:',
|
||||
],
|
||||
'de' => [
|
||||
'description' => 'Vorgeschlagene Aufgabenbeschreibung',
|
||||
'subtasks' => 'Vorgeschlagene Unteraufgaben',
|
||||
'assignee' => 'Empfohlener Verantwortlicher',
|
||||
'similar' => 'Ähnliche Aufgaben gefunden',
|
||||
'similar_hint' => 'Die folgenden Aufgaben sind ähnlich und könnten Duplikate oder Referenzen sein:',
|
||||
],
|
||||
'fr' => [
|
||||
'description' => 'Description de tâche suggérée',
|
||||
'subtasks' => 'Sous-tâches suggérées',
|
||||
'assignee' => 'Responsable recommandé',
|
||||
'similar' => 'Tâches similaires trouvées',
|
||||
'similar_hint' => 'Les tâches suivantes sont similaires et peuvent être des doublons ou des références:',
|
||||
],
|
||||
'id' => [
|
||||
'description' => 'Saran Deskripsi Tugas',
|
||||
'subtasks' => 'Saran Pembagian Subtugas',
|
||||
'assignee' => 'Penanggung Jawab yang Direkomendasikan',
|
||||
'similar' => 'Tugas Serupa Ditemukan',
|
||||
'similar_hint' => 'Tugas berikut mirip dengan tugas saat ini dan mungkin duplikat atau referensi:',
|
||||
],
|
||||
'ru' => [
|
||||
'description' => 'Предлагаемое описание задачи',
|
||||
'subtasks' => 'Предлагаемые подзадачи',
|
||||
'assignee' => 'Рекомендуемый ответственный',
|
||||
'similar' => 'Найдены похожие задачи',
|
||||
'similar_hint' => 'Следующие задачи похожи на текущую и могут быть дубликатами или справочными:',
|
||||
],
|
||||
];
|
||||
|
||||
return $titles[$lang] ?? $titles['zh'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义用户输入以防止 Prompt 注入
|
||||
*/
|
||||
private static function escapeUserInput(string $input, int $length = 500): string
|
||||
{
|
||||
// 移除可能影响 AI Prompt 解析的特殊字符
|
||||
$input = str_replace(['```', '---', '==='], '', $input);
|
||||
// 截断过长的输入
|
||||
return mb_substr(trim($input), 0, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建描述生成 Prompt
|
||||
* @param ProjectTask $task 任务对象
|
||||
* @param string $language 输出语言名称
|
||||
*/
|
||||
private static function buildDescriptionPrompt(ProjectTask $task, string $language): string
|
||||
{
|
||||
$taskName = self::escapeUserInput($task->name, 100);
|
||||
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
|
||||
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
|
||||
|
||||
return <<<PROMPT
|
||||
你是一名任务规划助手,擅长根据任务标题推断并补充任务描述。
|
||||
|
||||
所属项目:{$projectName}
|
||||
所属栏目:{$columnName}
|
||||
任务标题:{$taskName}
|
||||
|
||||
你的任务:
|
||||
根据标题、项目和栏目信息,推断任务意图并生成实用的任务描述。
|
||||
|
||||
生成原则:
|
||||
1. 基于标题关键词和上下文进行合理推断,内容要具体、可执行
|
||||
2. 使用 Markdown 格式,根据任务性质灵活组织结构(可包含目标、要求、验收标准等)
|
||||
3. 简单任务保持简洁,复杂任务可适当展开,避免空泛的套话
|
||||
|
||||
输出语言:与任务标题的语言保持一致,如无法确定则使用{$language}
|
||||
|
||||
输出要求:
|
||||
- 仅返回 Markdown 格式的描述内容
|
||||
- 禁止输出额外说明、引导语或与任务无关的内容
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建子任务拆分 Prompt
|
||||
* @param ProjectTask $task 任务对象
|
||||
* @param string $language 输出语言名称
|
||||
*/
|
||||
private static function buildSubtasksPrompt(ProjectTask $task, string $language): string
|
||||
{
|
||||
$taskName = self::escapeUserInput($task->name, 100);
|
||||
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
|
||||
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
|
||||
$content = self::escapeUserInput($task->content ?? '');
|
||||
|
||||
return <<<PROMPT
|
||||
你是一名任务拆解助手,擅长将复杂任务分解为可执行的子任务。
|
||||
|
||||
所属项目:{$projectName}
|
||||
所属栏目:{$columnName}
|
||||
任务标题:{$taskName}
|
||||
任务描述:{$content}
|
||||
|
||||
你的任务:
|
||||
分析任务内容,拆解出关键的执行步骤作为子任务。
|
||||
|
||||
拆解原则:
|
||||
1. 每个子任务聚焦单一可执行动作,避免含糊或重复
|
||||
2. 根据任务复杂度灵活决定数量(通常 2-5 个),简单任务少拆,复杂任务多拆
|
||||
3. 子任务之间保持合理的执行顺序或逻辑关系
|
||||
4. 子任务名称简洁明了,控制在 8-30 个字符内
|
||||
|
||||
输出语言:与任务标题的语言保持一致,如无法确定则使用{$language}
|
||||
|
||||
输出格式:
|
||||
1. [子任务名称]
|
||||
2. [子任务名称]
|
||||
...
|
||||
|
||||
输出要求:
|
||||
- 仅返回子任务列表,禁止输出额外说明或引导语
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建负责人推荐 Prompt
|
||||
* @param ProjectTask $task 任务对象
|
||||
* @param array $members 成员列表
|
||||
* @param string $language 输出语言名称
|
||||
*/
|
||||
private static function buildAssigneePrompt(ProjectTask $task, array $members, string $language): string
|
||||
{
|
||||
$taskName = self::escapeUserInput($task->name, 100);
|
||||
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
|
||||
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
|
||||
$taskContent = self::escapeUserInput($task->content ?? '');
|
||||
|
||||
$membersText = '';
|
||||
foreach ($members as $member) {
|
||||
$nickname = self::escapeUserInput($member['nickname'], 20);
|
||||
$membersText .= "- {$nickname}(ID:{$member['userid']})";
|
||||
if (!empty($member['profession'])) {
|
||||
$profession = self::escapeUserInput($member['profession'], 50);
|
||||
$membersText .= ",职位:{$profession}";
|
||||
}
|
||||
$membersText .= ",进行中:{$member['in_progress_count']}个";
|
||||
$membersText .= ",近期完成:{$member['completed_count']}个";
|
||||
$membersText .= "\n";
|
||||
}
|
||||
|
||||
return <<<PROMPT
|
||||
你是一名任务分配助手,根据任务内容和成员情况推荐合适的负责人。
|
||||
|
||||
所属项目:{$projectName}
|
||||
所属栏目:{$columnName}
|
||||
任务标题:{$taskName}
|
||||
任务描述:{$taskContent}
|
||||
|
||||
可选成员:
|
||||
{$membersText}
|
||||
|
||||
推荐原则:
|
||||
1. 分析任务内容,匹配成员职位或专业方向
|
||||
2. 优先推荐进行中任务较少的成员,平衡工作负载
|
||||
3. 近期完成任务多说明执行力强,可作为参考
|
||||
|
||||
输出语言:推荐理由的语言与任务标题保持一致,如无法确定则使用{$language}
|
||||
|
||||
输出格式:
|
||||
1. [userid]|[推荐理由]
|
||||
2. [userid]|[推荐理由]
|
||||
|
||||
输出要求:
|
||||
- 推荐 1-2 名最合适的负责人,按优先级排序
|
||||
- 推荐理由需具体说明为何此人适合该任务,不超过 20 字
|
||||
- 仅返回推荐列表,禁止输出额外说明
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 AI 接口
|
||||
*/
|
||||
private static function callAi(string $prompt): ?string
|
||||
{
|
||||
try {
|
||||
// 使用 AI 模块调用
|
||||
$result = AI::invoke([
|
||||
['system', '你是 DooTask 任务管理系统的 AI 助手,帮助用户管理任务。'],
|
||||
['user', $prompt],
|
||||
]);
|
||||
|
||||
if (Base::isError($result)) {
|
||||
\Log::error('AiTaskSuggestion::callAi error: ' . ($result['msg'] ?? 'Unknown error'));
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result['data']['content'] ?? null;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('AiTaskSuggestion::callAi error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目成员信息
|
||||
*/
|
||||
private static function getProjectMembersInfo(int $projectId): array
|
||||
{
|
||||
$projectUsers = ProjectUser::where('project_id', $projectId)->get();
|
||||
$members = [];
|
||||
|
||||
foreach ($projectUsers as $pu) {
|
||||
$user = User::find($pu->userid);
|
||||
if (!$user || $user->bot || $user->disable_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取进行中任务数量
|
||||
$inProgressCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->where('project_task_users.userid', $user->userid)
|
||||
->whereNull('project_tasks.complete_at')
|
||||
->whereNull('project_tasks.archived_at')
|
||||
->whereNull('project_tasks.deleted_at')
|
||||
->count();
|
||||
|
||||
// 获取近期完成任务数量
|
||||
$completedCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->where('project_task_users.userid', $user->userid)
|
||||
->whereNotNull('project_tasks.complete_at')
|
||||
->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(30))
|
||||
->whereNull('project_tasks.deleted_at')
|
||||
->count();
|
||||
|
||||
$members[] = [
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname,
|
||||
'profession' => $user->profession ?? '',
|
||||
'in_progress_count' => $inProgressCount,
|
||||
'completed_count' => $completedCount,
|
||||
];
|
||||
}
|
||||
|
||||
return $members;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析子任务列表
|
||||
*/
|
||||
private static function parseSubtasksList(string $text): array
|
||||
{
|
||||
$lines = explode("\n", trim($text));
|
||||
$subtasks = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
// 移除序号前缀
|
||||
$line = preg_replace('/^\d+[\.\)、]\s*/', '', $line);
|
||||
if (!empty($line) && mb_strlen($line) <= 100) {
|
||||
$subtasks[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice($subtasks, 0, 5); // 最多5个
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析负责人推荐结果
|
||||
*/
|
||||
private static function parseAssigneeRecommendations(string $text, array $members): array
|
||||
{
|
||||
$memberMap = [];
|
||||
foreach ($members as $m) {
|
||||
$memberMap[$m['userid']] = $m;
|
||||
}
|
||||
|
||||
$lines = explode("\n", trim($text));
|
||||
$recommendations = [];
|
||||
|
||||
$addedUserIds = []; // 记录已添加的用户ID,防止重复
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
$line = preg_replace('/^\d+[\.\)、]\s*/', '', $line);
|
||||
|
||||
if (preg_match('/^(\d+)\|(.+)$/', $line, $matches)) {
|
||||
$userid = intval($matches[1]);
|
||||
$reason = trim($matches[2]);
|
||||
|
||||
// 跳过已添加的用户
|
||||
if (in_array($userid, $addedUserIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($memberMap[$userid])) {
|
||||
$recommendations[] = [
|
||||
'userid' => $userid,
|
||||
'nickname' => $memberMap[$userid]['nickname'],
|
||||
'reason' => $reason,
|
||||
];
|
||||
$addedUserIds[] = $userid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice($recommendations, 0, 2); // 最多2个
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Embedding 搜索相似任务
|
||||
*
|
||||
* @param array $embedding 任务内容的向量表示
|
||||
* @param int $projectId 项目ID(用于过滤同项目任务)
|
||||
* @param int $excludeTaskId 排除的任务ID(当前任务)
|
||||
* @return array 相似任务列表
|
||||
*/
|
||||
private static function searchSimilarByEmbedding(array $embedding, int $projectId, int $excludeTaskId): array
|
||||
{
|
||||
if (empty($embedding)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 ManticoreBase 进行向量搜索
|
||||
// userid=0 跳过权限过滤,我们通过 project_id 过滤
|
||||
$results = ManticoreBase::taskVectorSearch($embedding, 0, 200);
|
||||
|
||||
if (empty($results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取当前任务的子任务ID列表
|
||||
$childTaskIds = ProjectTask::where('parent_id', $excludeTaskId)
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
// 过滤:同项目、排除当前任务及其子任务、相似度阈值
|
||||
$similarTasks = [];
|
||||
foreach ($results as $item) {
|
||||
// 过滤不同项目的任务
|
||||
if ($item['project_id'] != $projectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 排除当前任务
|
||||
if ($item['task_id'] == $excludeTaskId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 排除子任务
|
||||
if (in_array($item['task_id'], $childTaskIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 相似度阈值
|
||||
$similarity = $item['similarity'] ?? 0;
|
||||
if ($similarity < self::SIMILAR_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$similarTasks[] = [
|
||||
'task_id' => $item['task_id'],
|
||||
'name' => $item['task_name'] ?? '',
|
||||
'similarity' => round($similarity, 2),
|
||||
];
|
||||
|
||||
// 最多返回 5 个相似任务
|
||||
if (count($similarTasks) >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $similarTasks;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('searchSimilarByEmbedding error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Markdown 消息
|
||||
* @param int $taskId 任务ID
|
||||
* @param array $suggestions 建议列表
|
||||
* @param int $msgId 消息ID
|
||||
* @param string $lang 语言代码
|
||||
*/
|
||||
public static function buildMarkdownMessage(int $taskId, array $suggestions, int $msgId = 0, string $lang = 'zh'): string
|
||||
{
|
||||
$parts = [];
|
||||
$titles = self::getLocalizedTitles($lang);
|
||||
|
||||
foreach ($suggestions as $suggestion) {
|
||||
// 如果 suggestion 中有 lang,使用它(similar 类型)
|
||||
$suggestionLang = $suggestion['lang'] ?? $lang;
|
||||
$suggestionTitles = ($suggestionLang !== $lang) ? self::getLocalizedTitles($suggestionLang) : $titles;
|
||||
|
||||
switch ($suggestion['type']) {
|
||||
case 'description':
|
||||
$parts[] = self::buildDescriptionMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
|
||||
break;
|
||||
case 'subtasks':
|
||||
$parts[] = self::buildSubtasksMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
|
||||
break;
|
||||
case 'assignee':
|
||||
$parts[] = self::buildAssigneeMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
|
||||
break;
|
||||
case 'similar':
|
||||
$parts[] = self::buildSimilarMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n\n---\n\n", $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建描述建议 Markdown
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $msgId 消息ID
|
||||
* @param string $content 描述内容
|
||||
* @param array $titles 本地化标题
|
||||
*/
|
||||
private static function buildDescriptionMarkdown(int $taskId, int $msgId, string $content, array $titles): string
|
||||
{
|
||||
$title = $titles['description'];
|
||||
return <<<MD
|
||||
### {$title}
|
||||
|
||||
{$content}
|
||||
|
||||
:::ai-action{type="description" task="{$taskId}" msg="{$msgId}"}:::
|
||||
MD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建子任务建议 Markdown
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $msgId 消息ID
|
||||
* @param array $subtasks 子任务列表
|
||||
* @param array $titles 本地化标题
|
||||
*/
|
||||
private static function buildSubtasksMarkdown(int $taskId, int $msgId, array $subtasks, array $titles): string
|
||||
{
|
||||
$title = $titles['subtasks'];
|
||||
$list = '';
|
||||
foreach ($subtasks as $i => $name) {
|
||||
$num = $i + 1;
|
||||
$list .= "{$num}. {$name}\n";
|
||||
}
|
||||
|
||||
return <<<MD
|
||||
### {$title}
|
||||
|
||||
{$list}
|
||||
:::ai-action{type="subtasks" task="{$taskId}" msg="{$msgId}"}:::
|
||||
MD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建负责人建议 Markdown
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $msgId 消息ID
|
||||
* @param array $recommendations 推荐列表
|
||||
* @param array $titles 本地化标题
|
||||
*/
|
||||
private static function buildAssigneeMarkdown(int $taskId, int $msgId, array $recommendations, array $titles): string
|
||||
{
|
||||
$title = $titles['assignee'];
|
||||
$list = '';
|
||||
foreach ($recommendations as $rec) {
|
||||
$stUserId = $rec['userid'];
|
||||
$viewUrl = "dootask://contact/{$stUserId}";
|
||||
$list .= "- **[{$rec['nickname']}]({$viewUrl})** - {$rec['reason']} :::ai-action{type=\"assignee\" task=\"{$taskId}\" msg=\"{$msgId}\" userid=\"{$stUserId}\"}:::\n";
|
||||
}
|
||||
|
||||
return <<<MD
|
||||
### {$title}
|
||||
|
||||
{$list}
|
||||
MD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建相似任务 Markdown
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $msgId 消息ID
|
||||
* @param array $similarTasks 相似任务列表
|
||||
* @param array $titles 本地化标题
|
||||
*/
|
||||
private static function buildSimilarMarkdown(int $taskId, int $msgId, array $similarTasks, array $titles): string
|
||||
{
|
||||
$title = $titles['similar'];
|
||||
$hint = $titles['similar_hint'];
|
||||
$list = '';
|
||||
foreach ($similarTasks as $i => $st) {
|
||||
$num = $i + 1;
|
||||
$stTaskId = $st['task_id'];
|
||||
$viewUrl = "dootask://task/{$stTaskId}";
|
||||
$list .= "{$num}. **[#{$stTaskId}]({$viewUrl})** {$st['name']} :::ai-action{type=\"similar\" task=\"{$taskId}\" msg=\"{$msgId}\" related=\"{$stTaskId}\"}:::\n";
|
||||
}
|
||||
|
||||
return <<<MD
|
||||
### {$title}
|
||||
|
||||
{$hint}
|
||||
|
||||
{$list}
|
||||
MD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送建议消息
|
||||
* @param ProjectTask $task 任务对象
|
||||
* @param array $suggestions 建议列表
|
||||
*/
|
||||
public static function sendSuggestionMessage(ProjectTask $task, array $suggestions): ?int
|
||||
{
|
||||
if (empty($suggestions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果任务没有对话,自动创建
|
||||
if (!$task->dialog_id) {
|
||||
$dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task');
|
||||
if ($dialog) {
|
||||
$task->dialog_id = $dialog->id;
|
||||
$task->save();
|
||||
$task->pushMsg('dialog');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户语言
|
||||
$lang = self::getUserLanguageInfo($task->userid)['code'];
|
||||
|
||||
// 先发送消息获取 msg_id,然后更新消息内容带上 msg_id
|
||||
$tempMarkdown = self::buildMarkdownMessage($task->id, $suggestions, 0, $lang);
|
||||
$result = WebSocketDialogMsg::sendMsg(
|
||||
null,
|
||||
$task->dialog_id,
|
||||
'text',
|
||||
['text' => $tempMarkdown, 'type' => 'md'],
|
||||
self::AI_ASSISTANT_USERID,
|
||||
true, // push_self
|
||||
false, // push_retry
|
||||
true // push_silence
|
||||
);
|
||||
if (Base::isError($result)) {
|
||||
return null;
|
||||
}
|
||||
$msgId = $result['data']->id ?? 0;
|
||||
if (empty($msgId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 更新消息,带上真实的 msg_id
|
||||
$finalMarkdown = self::buildMarkdownMessage($task->id, $suggestions, $msgId, $lang);
|
||||
WebSocketDialogMsg::sendMsg(
|
||||
'change-' . $msgId,
|
||||
$task->dialog_id,
|
||||
'text',
|
||||
['text' => $finalMarkdown, 'type' => 'md'],
|
||||
self::AI_ASSISTANT_USERID,
|
||||
true, // push_self
|
||||
);
|
||||
return $msgId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新消息状态(采纳/忽略后)
|
||||
*
|
||||
* @param int $msgId 消息ID
|
||||
* @param int $dialogId 对话ID
|
||||
* @param string $type 建议类型
|
||||
* @param string $status 状态:applied/dismissed
|
||||
* @param int $userid 用户ID(assignee类型单独处理时使用)
|
||||
* @param int $related 关联任务ID(similar类型单独处理时使用)
|
||||
* @return array 更新后的消息数据
|
||||
*/
|
||||
public static function updateMessageStatus(int $msgId, int $dialogId, string $type, string $status, int $userid = 0, int $related = 0): array
|
||||
{
|
||||
// 验证消息存在且属于指定对话
|
||||
$msg = WebSocketDialogMsg::where('id', $msgId)
|
||||
->where('dialog_id', $dialogId)
|
||||
->first();
|
||||
if (!$msg) {
|
||||
return Base::retError('消息不存在');
|
||||
}
|
||||
|
||||
$content = $msg->msg['text'] ?? '';
|
||||
if (empty($content)) {
|
||||
return Base::retError('消息内容为空');
|
||||
}
|
||||
|
||||
// 根据类型和参数构建匹配模式,添加 status 属性
|
||||
if ($type === 'assignee' && $userid > 0) {
|
||||
$pattern = '/(:::ai-action\{type="assignee"[^}]*userid="' . $userid . '"[^}]*)\}:::/';
|
||||
} elseif ($type === 'similar' && $related > 0) {
|
||||
$pattern = '/(:::ai-action\{type="similar"[^}]*related="' . $related . '"[^}]*)\}:::/';
|
||||
} else {
|
||||
$pattern = '/(:::ai-action\{type="' . preg_quote($type, '/') . '"[^}]*)\}:::/';
|
||||
}
|
||||
|
||||
$newContent = preg_replace($pattern, '$1 status="' . $status . '"}:::', $content);
|
||||
|
||||
// 更新消息并返回结果
|
||||
return WebSocketDialogMsg::sendMsg(
|
||||
'change-' . $msgId,
|
||||
$dialogId,
|
||||
'text',
|
||||
['text' => $newContent, 'type' => 'md'],
|
||||
self::AI_ASSISTANT_USERID
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use App\Services\RequestContext;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use App\Module\Base;
|
||||
@@ -25,7 +26,7 @@ class Apps
|
||||
|
||||
$key = 'app_installed_' . $appId;
|
||||
if (RequestContext::has($key)) {
|
||||
return RequestContext::get($key);
|
||||
return (bool) RequestContext::get($key, false);
|
||||
}
|
||||
|
||||
$configFile = base_path('docker/appstore/config/' . $appId . '/config.yml');
|
||||
@@ -54,7 +55,7 @@ class Apps
|
||||
'office' => 'OnlyOffice',
|
||||
'drawio' => 'Drawio',
|
||||
'minder' => 'Minder',
|
||||
'search' => 'ZincSearch',
|
||||
'manticore' => 'Manticore Search',
|
||||
default => $appId,
|
||||
};
|
||||
throw new ApiException("应用「{$name}」未安装", [], 0, false);
|
||||
@@ -62,9 +63,14 @@ class Apps
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch user lifecycle hook to appstore (onboard/offboard/delete/restore).
|
||||
* Dispatch user lifecycle hook to appstore (user_onboard/user_offboard/user_update).
|
||||
*
|
||||
* @param User $user 用户对象
|
||||
* @param string $action Hook 动作: user_onboard, user_offboard, user_update
|
||||
* @param string $eventType 事件类型: onboard, restore, offboarded, delete, profile_update, admin_update
|
||||
* @param array $changedFields 变更字段列表(仅 user_update 时有值)
|
||||
*/
|
||||
public static function dispatchUserHook(User $user, string $action, string $eventType = ''): void
|
||||
public static function dispatchUserHook(User $user, string $action, string $eventType = '', array $changedFields = []): void
|
||||
{
|
||||
$appKey = env('APP_KEY', '');
|
||||
if (empty($appKey)) {
|
||||
@@ -72,18 +78,40 @@ class Apps
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取用户部门信息
|
||||
$departments = [];
|
||||
if (!empty($user->department)) {
|
||||
$deptIds = is_array($user->department)
|
||||
? $user->department
|
||||
: array_filter(explode(',', $user->department));
|
||||
if (!empty($deptIds)) {
|
||||
$deptList = UserDepartment::whereIn('id', $deptIds)->get(['id', 'name']);
|
||||
foreach ($deptList as $dept) {
|
||||
$departments[] = [
|
||||
'id' => (string) $dept->id,
|
||||
'name' => (string) $dept->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$url = sprintf('http://appstore/api/v1/internal/hooks/%s', $action);
|
||||
$payload = [
|
||||
'user' => [
|
||||
'id' => (string) $user->userid,
|
||||
'email' => (string) $user->email,
|
||||
'name' => (string) $user->nickname,
|
||||
'role' => in_array('admin', $user->identity ?? []) ? 'admin' : 'normal',
|
||||
'role' => $user->isAdmin() ? 'admin' : 'normal',
|
||||
'tel' => (string) ($user->tel ?? ''),
|
||||
'profession' => (string) ($user->profession ?? ''),
|
||||
'birthday' => $user->birthday ? (string) $user->birthday : '',
|
||||
'address' => (string) ($user->address ?? ''),
|
||||
'introduction' => (string) ($user->introduction ?? ''),
|
||||
'departments' => $departments,
|
||||
],
|
||||
'event_type' => $eventType,
|
||||
'changed_fields' => $changedFields,
|
||||
];
|
||||
if ($eventType !== '') {
|
||||
$payload['event_type'] = $eventType;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
|
||||
2129
app/Module/Manticore/ManticoreBase.php
Normal file
2129
app/Module/Manticore/ManticoreBase.php
Normal file
File diff suppressed because it is too large
Load Diff
670
app/Module/Manticore/ManticoreFile.php
Normal file
670
app/Module/Manticore/ManticoreFile.php
Normal file
@@ -0,0 +1,670 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\FileContent;
|
||||
use App\Models\FileUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\TextExtractor;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 文件搜索类
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索文件: search($userid, $keyword, $searchType, $from, $size);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(File $file);
|
||||
* - 批量同步: batchSync($files);
|
||||
* - 删除索引: delete($fileId);
|
||||
*
|
||||
* 3. 权限更新方法
|
||||
* - 更新权限: updateAllowedUsers($fileId);
|
||||
*
|
||||
* 4. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class ManticoreFile
|
||||
{
|
||||
/**
|
||||
* 可搜索的文件类型
|
||||
*/
|
||||
public const SEARCHABLE_TYPES = ['document', 'word', 'excel', 'ppt', 'txt', 'md', 'text', 'code'];
|
||||
|
||||
/**
|
||||
* 最大内容长度(字符)- 提取后的文本内容限制
|
||||
*/
|
||||
public const MAX_CONTENT_LENGTH = 100000; // 100K 字符
|
||||
|
||||
/**
|
||||
* 不同文件类型的最大大小限制(字节)
|
||||
*/
|
||||
public const MAX_FILE_SIZE = [
|
||||
'office' => 50 * 1024 * 1024, // 50MB - Office 文件图片占空间大但文本少
|
||||
'text' => 5 * 1024 * 1024, // 5MB - 纯文本文件
|
||||
'other' => 20 * 1024 * 1024, // 20MB - PDF 等其他文件
|
||||
];
|
||||
|
||||
/**
|
||||
* Office 文件扩展名
|
||||
*/
|
||||
public const OFFICE_EXTENSIONS = [
|
||||
'doc', 'docx', 'dot', 'dotx', 'odt', 'ott', 'rtf',
|
||||
'xls', 'xlsx', 'xlsm', 'xlt', 'xltx', 'ods', 'ots', 'csv', 'tsv',
|
||||
'ppt', 'pptx', 'pps', 'ppsx', 'odp', 'otp'
|
||||
];
|
||||
|
||||
/**
|
||||
* 纯文本文件扩展名
|
||||
*/
|
||||
public const TEXT_EXTENSIONS = [
|
||||
'txt', 'md', 'text', 'log', 'json', 'xml', 'html', 'htm', 'css', 'js', 'ts',
|
||||
'php', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'rb', 'sh', 'bash', 'sql',
|
||||
'yaml', 'yml', 'ini', 'conf', 'vue', 'jsx', 'tsx'
|
||||
];
|
||||
|
||||
/**
|
||||
* 搜索文件(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $from = 0, int $size = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("search")) {
|
||||
// 未安装 Manticore,降级到 MySQL LIKE 搜索
|
||||
return self::searchByMysql($userid, $keyword, $from, $size);
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
// 纯全文搜索
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::fullTextSearch($keyword, $userid, $size, $from)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
// 纯向量搜索(需要先获取 embedding)
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
// embedding 获取失败,降级到全文搜索
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::fullTextSearch($keyword, $userid, $size, $from)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::vectorSearch($embedding, $userid, $size)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
// 混合搜索
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::hybridSearch($keyword, $embedding, $userid, $size)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore search error: ' . $e->getMessage());
|
||||
return self::searchByMysql($userid, $keyword, $from, $size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results Manticore 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'id' => $item['file_id'],
|
||||
'file_id' => $item['file_id'],
|
||||
'name' => $item['file_name'],
|
||||
'type' => $item['file_type'],
|
||||
'ext' => $item['file_ext'],
|
||||
'userid' => $item['userid'],
|
||||
'content_preview' => isset($item['content']) ? mb_substr($item['content'], 0, 500) : null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 降级搜索(仅搜索文件名)
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $keyword 关键词
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
private static function searchByMysql(int $userid, string $keyword, int $from, int $size): array
|
||||
{
|
||||
// 搜索用户自己的文件
|
||||
$builder = File::where('userid', $userid)
|
||||
->where('name', 'like', "%{$keyword}%")
|
||||
->where('type', '!=', 'folder');
|
||||
|
||||
$results = $builder->skip($from)->take($size)->get();
|
||||
|
||||
return $results->map(function ($file) {
|
||||
return [
|
||||
'id' => $file->id,
|
||||
'file_id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'type' => $file->type,
|
||||
'ext' => $file->ext,
|
||||
'userid' => $file->userid,
|
||||
'content_preview' => null,
|
||||
'relevance' => 0,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限计算方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取文件的 allowed_users 列表
|
||||
*
|
||||
* 有权限查看此文件的用户列表:
|
||||
* - 文件所有者 (userid)
|
||||
* - 共享用户(FileUser 表中的 userid)
|
||||
* - userid=0 表示公开共享
|
||||
*
|
||||
* @param File $file 文件模型
|
||||
* @return array 有权限的用户ID数组
|
||||
*/
|
||||
public static function getAllowedUsers(File $file): array
|
||||
{
|
||||
$userids = [$file->userid]; // 所有者
|
||||
|
||||
// 获取共享用户(包括 userid=0 表示公开)
|
||||
$shareUsers = FileUser::where('file_id', $file->id)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
return array_unique(array_merge($userids, $shareUsers));
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个文件到 Manticore(含 allowed_users)
|
||||
*
|
||||
* @param File $file 文件模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(File $file, bool $withVector = false): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 不处理文件夹
|
||||
if ($file->type === 'folder') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据文件类型检查大小限制
|
||||
$maxSize = self::getMaxFileSizeByExt($file->ext);
|
||||
if ($file->size > $maxSize) {
|
||||
// 删除可能存在的旧索引(文件更新后可能超限)
|
||||
self::delete($file->id);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 提取文件内容
|
||||
$content = self::extractFileContent($file);
|
||||
|
||||
// 限制提取后的内容长度
|
||||
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && Apps::isInstalled('ai')) {
|
||||
// 向量内容包含文件名和文件内容
|
||||
$vectorContent = self::buildVectorContent($file->name, $content);
|
||||
if (!empty($vectorContent)) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($vectorContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件的 allowed_users
|
||||
$allowedUsers = self::getAllowedUsers($file);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertFileVector([
|
||||
'file_id' => $file->id,
|
||||
'userid' => $file->userid,
|
||||
'pshare' => $file->pshare ?? 0,
|
||||
'file_name' => $file->name,
|
||||
'file_type' => $file->type,
|
||||
'file_ext' => $file->ext,
|
||||
'content' => $content,
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore sync error: ' . $e->getMessage(), [
|
||||
'file_id' => $file->id,
|
||||
'file_name' => $file->name,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取最大文件大小限制
|
||||
*
|
||||
* @param string|null $ext 文件扩展名
|
||||
* @return int 最大文件大小(字节)
|
||||
*/
|
||||
private static function getMaxFileSizeByExt(?string $ext): int
|
||||
{
|
||||
$ext = strtolower($ext ?? '');
|
||||
|
||||
if (in_array($ext, self::OFFICE_EXTENSIONS)) {
|
||||
return self::MAX_FILE_SIZE['office'];
|
||||
}
|
||||
|
||||
if (in_array($ext, self::TEXT_EXTENSIONS)) {
|
||||
return self::MAX_FILE_SIZE['text'];
|
||||
}
|
||||
|
||||
return self::MAX_FILE_SIZE['other'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有文件类型中的最大文件大小限制
|
||||
*
|
||||
* @return int 最大文件大小(字节)
|
||||
*/
|
||||
public static function getMaxFileSize(): int
|
||||
{
|
||||
return max(self::MAX_FILE_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步文件
|
||||
*
|
||||
* @param iterable $files 文件列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $files, bool $withVector = false): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($files as $file) {
|
||||
if (self::sync($file, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件索引
|
||||
*
|
||||
* @param int $fileId 文件ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $fileId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::deleteFileVector($fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文件内容(支持分页)
|
||||
*
|
||||
* @param File|string $fileOrPath 文件模型 或 文件路径/URL
|
||||
* @param int $offset 起始位置(字符数),默认 0
|
||||
* @param int $limit 获取长度(字符数),默认 50000,最大 200000
|
||||
* @return array 包含 content, total_length, offset, limit, has_more, 或 error
|
||||
*/
|
||||
public static function extractFileContentPaginated(File|string $fileOrPath, int $offset = 0, int $limit = 50000): array
|
||||
{
|
||||
$offset = max(0, $offset);
|
||||
$limit = min(max(1, $limit), 200000);
|
||||
|
||||
// 根据参数类型获取完整内容
|
||||
if ($fileOrPath instanceof File) {
|
||||
if ($fileOrPath->type === 'folder') {
|
||||
return ['error' => '文件夹无法提取内容'];
|
||||
}
|
||||
$fullContent = self::extractFileContent($fileOrPath);
|
||||
} else {
|
||||
$fullContent = self::extractFileContentFromPath($fileOrPath);
|
||||
if (is_array($fullContent)) {
|
||||
return $fullContent; // 返回错误信息
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($fullContent)) {
|
||||
return ['error' => '无法提取文件内容'];
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
$totalLength = mb_strlen($fullContent);
|
||||
|
||||
if ($offset >= $totalLength) {
|
||||
return [
|
||||
'content' => '',
|
||||
'total_length' => $totalLength,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'has_more' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$content = mb_substr($fullContent, $offset, $limit);
|
||||
$hasMore = ($offset + mb_strlen($content)) < $totalLength;
|
||||
|
||||
return [
|
||||
'content' => $content,
|
||||
'total_length' => $totalLength,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'has_more' => $hasMore,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过路径/URL 提取完整内容
|
||||
* @return string|array 内容字符串,或错误数组
|
||||
*/
|
||||
private static function extractFileContentFromPath(string $pathOrUrl): string|array
|
||||
{
|
||||
// 从 URL 中提取相对路径
|
||||
if (str_starts_with($pathOrUrl, 'http://') || str_starts_with($pathOrUrl, 'https://')) {
|
||||
$parsed = parse_url($pathOrUrl);
|
||||
$pathOrUrl = ltrim($parsed['path'] ?? '', '/');
|
||||
}
|
||||
if (preg_match('/^.*?(uploads\/.*)$/', $pathOrUrl, $matches)) {
|
||||
$pathOrUrl = $matches[1];
|
||||
}
|
||||
|
||||
// 安全检查:只允许 uploads 目录
|
||||
if (!str_starts_with($pathOrUrl, 'uploads/')) {
|
||||
return ['error' => '不支持的文件路径'];
|
||||
}
|
||||
|
||||
return self::extractFromPath($pathOrUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文件内容(内部使用,返回完整内容)
|
||||
*
|
||||
* @param File $file 文件模型
|
||||
* @return string 文件内容文本
|
||||
*/
|
||||
private static function extractFileContent(File $file): string
|
||||
{
|
||||
// 1. 先尝试从 FileContent 的 text 字段获取(已提取的文本内容)
|
||||
$fileContent = FileContent::where('fid', $file->id)->orderByDesc('id')->first();
|
||||
if (!$fileContent) {
|
||||
return '';
|
||||
}
|
||||
if (!empty($fileContent->text)) {
|
||||
return $fileContent->text;
|
||||
}
|
||||
|
||||
// 2. 尝试从 FileContent 的 content 字段获取
|
||||
if (!empty($fileContent->content)) {
|
||||
$contentData = Base::json2array($fileContent->content);
|
||||
|
||||
// 2.1 某些文件类型直接存储内容
|
||||
if (!empty($contentData['content']) && is_string($contentData['content'])) {
|
||||
return $contentData['content'];
|
||||
}
|
||||
|
||||
// 2.2 通过路径提取
|
||||
$filePath = $contentData['url'] ?? null;
|
||||
if ($filePath && str_starts_with($filePath, 'uploads/')) {
|
||||
$result = self::extractFromPath($filePath);
|
||||
if (is_string($result)) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径提取内容(核心方法)
|
||||
* @return string|array 内容字符串,或错误数组
|
||||
*/
|
||||
private static function extractFromPath(string $relativePath): string|array
|
||||
{
|
||||
$fullPath = public_path($relativePath);
|
||||
if (!file_exists($fullPath)) {
|
||||
return ['error' => '文件不存在'];
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||
$maxFileSize = self::getMaxFileSizeByExt($ext);
|
||||
|
||||
$result = TextExtractor::extractFile(
|
||||
$fullPath,
|
||||
(int) ($maxFileSize / 1024),
|
||||
(int) (self::MAX_CONTENT_LENGTH / 1024)
|
||||
);
|
||||
|
||||
if (!Base::isSuccess($result)) {
|
||||
return ['error' => $result['msg'] ?? '无法提取文件内容'];
|
||||
}
|
||||
|
||||
return $result['data'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用于生成向量的内容
|
||||
* 包含文件名和文件内容,确保语义搜索能匹配文件名
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::clearAllFileVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引文件数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ManticoreBase::getIndexedFileCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限更新方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 更新文件的 allowed_users 权限列表
|
||||
* 从 MySQL 获取最新的共享用户并更新到 Manticore
|
||||
*
|
||||
* @param int $fileId 文件ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function updateAllowedUsers(int $fileId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $fileId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$file = File::find($fileId);
|
||||
if (!$file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userids = self::getAllowedUsers($file);
|
||||
return ManticoreBase::updateFileAllowedUsers($fileId, $userids);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore updateAllowedUsers error: ' . $e->getMessage(), ['file_id' => $fileId]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成文件向量
|
||||
* 用于后台异步处理,将已索引文件的向量批量生成
|
||||
*
|
||||
* @param array $fileIds 文件ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $fileIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($fileIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询文件信息
|
||||
$files = File::whereIn('id', $fileIds)
|
||||
->where('type', '!=', 'folder')
|
||||
->get();
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个文件的内容(包含文件名)
|
||||
$fileContents = [];
|
||||
foreach ($files as $file) {
|
||||
// 检查文件大小限制
|
||||
$maxSize = self::getMaxFileSizeByExt($file->ext);
|
||||
if ($file->size > $maxSize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = self::extractFileContent($file);
|
||||
// 向量内容包含文件名和文件内容
|
||||
$vectorContent = self::buildVectorContent($file->name, $content);
|
||||
if (!empty($vectorContent)) {
|
||||
// 限制内容长度
|
||||
$vectorContent = mb_substr($vectorContent, 0, self::MAX_CONTENT_LENGTH);
|
||||
$fileContents[$file->id] = $vectorContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($fileContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($fileContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $fileId) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$fileId] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateFileVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreFile generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
139
app/Module/Manticore/ManticoreKeyValue.php
Normal file
139
app/Module/Manticore/ManticoreKeyValue.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Module\Apps;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 键值存储类
|
||||
*
|
||||
* 用于存储同步进度等配置信息
|
||||
*/
|
||||
class ManticoreKeyValue
|
||||
{
|
||||
/**
|
||||
* 获取值
|
||||
*
|
||||
* @param string $key 键
|
||||
* @param mixed $default 默认值
|
||||
* @return mixed 值
|
||||
*/
|
||||
public static function get(string $key, $default = null)
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
$result = $instance->queryOne(
|
||||
"SELECT v FROM key_values WHERE k = ?",
|
||||
[$key]
|
||||
);
|
||||
|
||||
return $result ? $result['v'] : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置值
|
||||
*
|
||||
* @param string $key 键
|
||||
* @param mixed $value 值
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function set(string $key, $value): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
|
||||
// 先删除已存在的记录
|
||||
$instance->execute("DELETE FROM key_values WHERE k = ?", [$key]);
|
||||
|
||||
// 生成唯一 ID(基于 key 的 hash)
|
||||
$id = abs(crc32($key));
|
||||
|
||||
// 插入新记录
|
||||
return $instance->execute(
|
||||
"INSERT INTO key_values (id, k, v) VALUES (?, ?, ?)",
|
||||
[$id, $key, (string)$value]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除值
|
||||
*
|
||||
* @param string $key 键
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(string $key): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
return $instance->execute("DELETE FROM key_values WHERE k = ?", [$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有键值
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
return $instance->execute("TRUNCATE TABLE key_values");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查键是否存在
|
||||
*
|
||||
* @param string $key 键
|
||||
* @return bool 是否存在
|
||||
*/
|
||||
public static function exists(string $key): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
$result = $instance->queryOne(
|
||||
"SELECT id FROM key_values WHERE k = ?",
|
||||
[$key]
|
||||
);
|
||||
|
||||
return $result !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有键值对
|
||||
*
|
||||
* @return array 键值对数组
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
$results = $instance->query("SELECT k, v FROM key_values");
|
||||
|
||||
$data = [];
|
||||
foreach ($results as $row) {
|
||||
$data[$row['k']] = $row['v'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
561
app/Module/Manticore/ManticoreMsg.php
Normal file
561
app/Module/Manticore/ManticoreMsg.php
Normal file
@@ -0,0 +1,561 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Carbon\Carbon;
|
||||
use DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 消息搜索类
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索消息: search($userid, $keyword, $searchType, $from, $size);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(WebSocketDialogMsg $msg);
|
||||
* - 批量同步: batchSync($msgs);
|
||||
* - 删除索引: delete($msgId);
|
||||
*
|
||||
* 3. 权限更新方法
|
||||
* - 更新对话权限: updateDialogAllowedUsers($dialogId);
|
||||
*
|
||||
* 4. 工具方法
|
||||
* - 清空索引: clear();
|
||||
* - 判断是否索引: shouldIndex($msg);
|
||||
*/
|
||||
class ManticoreMsg
|
||||
{
|
||||
/**
|
||||
* 可索引的消息类型
|
||||
*/
|
||||
public const INDEXABLE_TYPES = ['text', 'file', 'record', 'meeting', 'vote'];
|
||||
|
||||
/**
|
||||
* 最大内容长度(字符)
|
||||
*/
|
||||
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
|
||||
|
||||
/**
|
||||
* 判断消息是否应该被索引
|
||||
*
|
||||
* @param WebSocketDialogMsg $msg 消息模型
|
||||
* @return bool 是否应该索引
|
||||
*/
|
||||
public static function shouldIndex(WebSocketDialogMsg $msg): bool
|
||||
{
|
||||
// 1. 排除机器人消息
|
||||
if ($msg->bot === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查消息类型
|
||||
if (!in_array($msg->type, self::INDEXABLE_TYPES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 排除 key 为空的消息
|
||||
if (empty($msg->key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索消息(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回数量
|
||||
* @param int $dialogId 对话ID(0表示不限制)
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $from = 0, int $size = 20, int $dialogId = 0): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
// 纯全文搜索
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::msgFullTextSearch($keyword, $userid, $size, $from, $dialogId)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
// 纯向量搜索(需要先获取 embedding)
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
// embedding 获取失败,降级到全文搜索
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::msgFullTextSearch($keyword, $userid, $size, $from, $dialogId)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::msgVectorSearch($embedding, $userid, $size, $dialogId)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
// 混合搜索
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::msgHybridSearch($keyword, $embedding, $userid, $size, $dialogId)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore msg search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results Manticore 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'id' => $item['msg_id'],
|
||||
'msg_id' => $item['msg_id'],
|
||||
'dialog_id' => $item['dialog_id'],
|
||||
'userid' => $item['userid'],
|
||||
'msg_type' => $item['msg_type'],
|
||||
'content_preview' => isset($item['content']) ? mb_substr($item['content'], 0, 200) : null,
|
||||
'created_at' => $item['created_at'] ?? null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按对话搜索消息(用于对话列表搜索)
|
||||
*
|
||||
* 返回包含匹配消息的对话列表,每个对话只返回一次
|
||||
* 当 Manticore 未安装时,回退到 MySQL LIKE 搜索
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回数量
|
||||
* @return array 对话列表
|
||||
*/
|
||||
public static function searchDialogs(int $userid, string $keyword, int $from = 0, int $size = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 未安装 Manticore 时使用 MySQL 回退搜索
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return self::searchDialogsByMysql($userid, $keyword, $from, $size);
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用全文搜索获取更多结果,然后按对话分组
|
||||
$results = ManticoreBase::msgFullTextSearch($keyword, $userid, 100, 0);
|
||||
|
||||
if (empty($results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 收集所有对话ID
|
||||
$dialogIds = array_unique(array_column($results, 'dialog_id'));
|
||||
|
||||
// 获取用户在这些对话中的信息
|
||||
$dialogUsers = WebSocketDialogUser::where('userid', $userid)
|
||||
->whereIn('dialog_id', $dialogIds)
|
||||
->get()
|
||||
->keyBy('dialog_id');
|
||||
|
||||
// 按对话分组,每个对话只保留最相关的消息
|
||||
$msgs = [];
|
||||
$seenDialogs = [];
|
||||
foreach ($results as $item) {
|
||||
$dialogId = $item['dialog_id'];
|
||||
|
||||
// 每个对话只取第一条(最相关的)
|
||||
if (isset($seenDialogs[$dialogId])) {
|
||||
continue;
|
||||
}
|
||||
$seenDialogs[$dialogId] = true;
|
||||
|
||||
// 获取用户在该对话的信息
|
||||
$dialogUser = $dialogUsers->get($dialogId);
|
||||
if (!$dialogUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$msgs[] = [
|
||||
'id' => $dialogId,
|
||||
'search_msg_id' => $item['msg_id'],
|
||||
'user_at' => $dialogUser->updated_at ? Carbon::parse($dialogUser->updated_at)->format('Y-m-d H:i:s') : null,
|
||||
'mark_unread' => $dialogUser->mark_unread,
|
||||
'silence' => $dialogUser->silence,
|
||||
'hide' => $dialogUser->hide,
|
||||
'color' => $dialogUser->color,
|
||||
'top_at' => $dialogUser->top_at ? Carbon::parse($dialogUser->top_at)->format('Y-m-d H:i:s') : null,
|
||||
'last_at' => $dialogUser->last_at ? Carbon::parse($dialogUser->last_at)->format('Y-m-d H:i:s') : null,
|
||||
];
|
||||
|
||||
// 已达到需要的数量
|
||||
if (count($msgs) >= $from + $size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 应用分页
|
||||
return array_slice($msgs, $from, $size);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore searchDialogs error: ' . $e->getMessage());
|
||||
// 出错时回退到 MySQL 搜索
|
||||
return self::searchDialogsByMysql($userid, $keyword, $from, $size);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索(按对话搜索消息)
|
||||
*
|
||||
* 通过联表查询获取用户有权限的对话中匹配的消息
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回数量
|
||||
* @return array 对话列表
|
||||
*/
|
||||
private static function searchDialogsByMysql(int $userid, string $keyword, int $from = 0, int $size = 20): array
|
||||
{
|
||||
$items = DB::table('web_socket_dialog_users as u')
|
||||
->select([
|
||||
'd.*',
|
||||
'u.top_at',
|
||||
'u.last_at',
|
||||
'u.mark_unread',
|
||||
'u.silence',
|
||||
'u.hide',
|
||||
'u.color',
|
||||
'u.updated_at as user_at',
|
||||
'm.id as search_msg_id'
|
||||
])
|
||||
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
|
||||
->join('web_socket_dialog_msgs as m', 'm.dialog_id', '=', 'd.id')
|
||||
->where('u.userid', $userid)
|
||||
->where('m.bot', 0)
|
||||
->whereNull('d.deleted_at')
|
||||
->where('m.key', 'like', "%{$keyword}%")
|
||||
->orderByDesc('m.id')
|
||||
->offset($from)
|
||||
->limit($size)
|
||||
->get()
|
||||
->all();
|
||||
|
||||
$msgs = [];
|
||||
foreach ($items as $item) {
|
||||
$msgs[] = [
|
||||
'id' => $item->id,
|
||||
'search_msg_id' => $item->search_msg_id,
|
||||
'user_at' => Carbon::parse($item->user_at)->format('Y-m-d H:i:s'),
|
||||
'mark_unread' => $item->mark_unread,
|
||||
'silence' => $item->silence,
|
||||
'hide' => $item->hide,
|
||||
'color' => $item->color,
|
||||
'top_at' => Carbon::parse($item->top_at)->format('Y-m-d H:i:s'),
|
||||
'last_at' => Carbon::parse($item->last_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
return $msgs;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限计算方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取消息的 allowed_users 列表
|
||||
*
|
||||
* 对话的所有成员都有权限查看该对话的消息
|
||||
*
|
||||
* @param WebSocketDialogMsg $msg 消息模型
|
||||
* @return array 有权限的用户ID数组
|
||||
*/
|
||||
public static function getAllowedUsers(WebSocketDialogMsg $msg): array
|
||||
{
|
||||
return self::getDialogUserIds($msg->dialog_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话的所有成员ID
|
||||
*
|
||||
* @param int $dialogId 对话ID
|
||||
* @return array 成员用户ID数组
|
||||
*/
|
||||
public static function getDialogUserIds(int $dialogId): array
|
||||
{
|
||||
if ($dialogId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return WebSocketDialogUser::where('dialog_id', $dialogId)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个消息到 Manticore(含 allowed_users)
|
||||
*
|
||||
* @param WebSocketDialogMsg $msg 消息模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(WebSocketDialogMsg $msg, bool $withVector = false): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否应该索引
|
||||
if (!self::shouldIndex($msg)) {
|
||||
// 不符合索引条件,尝试删除已存在的索引
|
||||
return ManticoreBase::deleteMsgVector($msg->id);
|
||||
}
|
||||
|
||||
try {
|
||||
// 提取消息内容(使用 key 字段)
|
||||
$content = $msg->key ?? '';
|
||||
|
||||
// 限制内容长度
|
||||
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($content) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($content);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取消息的 allowed_users
|
||||
$allowedUsers = self::getAllowedUsers($msg);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertMsgVector([
|
||||
'msg_id' => $msg->id,
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
'userid' => $msg->userid,
|
||||
'msg_type' => $msg->type,
|
||||
'content' => $content,
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(),
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore msg sync error: ' . $e->getMessage(), [
|
||||
'msg_id' => $msg->id,
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步消息
|
||||
*
|
||||
* @param iterable $msgs 消息列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $msgs, bool $withVector = false): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($msgs as $msg) {
|
||||
if (self::sync($msg, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成向量(供后台任务调用)
|
||||
*
|
||||
* @param array $msgIds 消息ID数组
|
||||
* @param int $batchSize 每批 embedding 数量
|
||||
* @return int 成功生成向量的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $msgIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled('ai') || empty($msgIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
// 分批处理
|
||||
foreach (array_chunk($msgIds, $batchSize) as $batchIds) {
|
||||
// 获取消息
|
||||
$msgs = WebSocketDialogMsg::whereIn('id', $batchIds)
|
||||
->whereIn('type', self::INDEXABLE_TYPES)
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
if ($msgs->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 准备文本
|
||||
$texts = [];
|
||||
$idsArray = [];
|
||||
foreach ($batchIds as $id) {
|
||||
if (isset($msgs[$id])) {
|
||||
$content = mb_substr($msgs[$id]->key ?? '', 0, self::MAX_CONTENT_LENGTH);
|
||||
if (!empty($content)) {
|
||||
$texts[] = $content;
|
||||
$idsArray[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($texts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 批量获取 embeddings
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
|
||||
if (Base::isError($result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'] ?? [];
|
||||
|
||||
// 构建批量更新数据 [msg_id => vectorStr]
|
||||
$vectorData = [];
|
||||
foreach ($embeddings as $index => $embedding) {
|
||||
if (empty($embedding) || !is_array($embedding)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$msgId = $idsArray[$index] ?? null;
|
||||
if (!$msgId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vectorData[$msgId] = '[' . implode(',', $embedding) . ']';
|
||||
}
|
||||
|
||||
// 批量更新向量(优化:减少数据库操作次数)
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateMsgVectors($vectorData);
|
||||
$count += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息索引
|
||||
*
|
||||
* @param int $msgId 消息ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $msgId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::deleteMsgVector($msgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::clearAllMsgVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引消息数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ManticoreBase::getIndexedMsgCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限更新方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 更新对话下所有消息的 allowed_users 权限列表
|
||||
* 从 MySQL 获取最新的对话成员并更新到 Manticore
|
||||
*
|
||||
* @param int $dialogId 对话ID
|
||||
* @return int 更新的消息数量
|
||||
*/
|
||||
public static function updateDialogAllowedUsers(int $dialogId): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $dialogId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$userids = self::getDialogUserIds($dialogId);
|
||||
return ManticoreBase::updateDialogAllowedUsers($dialogId, $userids);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore updateDialogAllowedUsers error: ' . $e->getMessage(), ['dialog_id' => $dialogId]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
369
app/Module/Manticore/ManticoreProject.php
Normal file
369
app/Module/Manticore/ManticoreProject.php
Normal file
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 项目搜索类
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索项目: search($userid, $keyword, $searchType, $limit);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(Project $project);
|
||||
* - 批量同步: batchSync($projects);
|
||||
* - 删除索引: delete($projectId);
|
||||
*
|
||||
* 3. 权限更新方法
|
||||
* - 更新权限: updateAllowedUsers($projectId);
|
||||
*
|
||||
* 4. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class ManticoreProject
|
||||
{
|
||||
/**
|
||||
* 搜索项目(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param int $userid 用户ID(权限过滤)
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $limit 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $limit = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::projectFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::projectFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::projectVectorSearch($embedding, $userid, $limit)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::projectHybridSearch($keyword, $embedding, $userid, $limit)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore project search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results Manticore 返回的结果
|
||||
* @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' => isset($item['project_desc']) ? mb_substr($item['project_desc'], 0, 300) : null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取项目的 allowed_users 列表
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return array 有权限的用户ID数组
|
||||
*/
|
||||
public static function getAllowedUsers(int $projectId): array
|
||||
{
|
||||
return ProjectUser::where('project_id', $projectId)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个项目到 Manticore(含 allowed_users)
|
||||
*
|
||||
* @param Project $project 项目模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(Project $project, bool $withVector = false): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已归档的项目不索引
|
||||
if ($project->archived_at) {
|
||||
return self::delete($project->id);
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($project);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取项目成员列表(作为 allowed_users)
|
||||
$allowedUsers = self::getAllowedUsers($project->id);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertProjectVector([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $project->userid ?? 0,
|
||||
'personal' => $project->personal ?? 0,
|
||||
'project_name' => $project->name ?? '',
|
||||
'project_desc' => $project->desc ?? '',
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore 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 项目列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $projects, bool $withVector = false): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($projects as $project) {
|
||||
if (self::sync($project, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目索引
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $projectId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::deleteProjectVector($projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::clearAllProjectVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引项目数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ManticoreBase::getIndexedProjectCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限更新方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 更新项目的 allowed_users 权限列表
|
||||
* 从 MySQL 获取最新的项目成员并更新到 Manticore
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function updateAllowedUsers(int $projectId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $projectId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$userids = self::getAllowedUsers($projectId);
|
||||
return ManticoreBase::updateProjectAllowedUsers($projectId, $userids);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore updateAllowedUsers error: ' . $e->getMessage(), ['project_id' => $projectId]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成项目向量
|
||||
* 用于后台异步处理,将已索引项目的向量批量生成
|
||||
*
|
||||
* @param array $projectIds 项目ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $projectIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($projectIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询项目信息
|
||||
$projects = Project::whereIn('id', $projectIds)
|
||||
->whereNull('archived_at')
|
||||
->get();
|
||||
|
||||
if ($projects->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个项目的内容
|
||||
$projectContents = [];
|
||||
foreach ($projects as $project) {
|
||||
$searchableContent = self::buildSearchableContent($project);
|
||||
if (!empty($searchableContent)) {
|
||||
$projectContents[$project->id] = $searchableContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($projectContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($projectContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $projectId) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$projectId] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateProjectVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreProject generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
593
app/Module/Manticore/ManticoreTask.php
Normal file
593
app/Module/Manticore/ManticoreTask.php
Normal file
@@ -0,0 +1,593 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskContent;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 任务搜索类
|
||||
*
|
||||
* 权限逻辑说明:
|
||||
* - visibility = 1: 项目人员可见,通过项目成员计算 allowed_users
|
||||
* - visibility = 2: 任务人员可见,通过任务成员计算 allowed_users
|
||||
* - visibility = 3: 指定成员可见,通过任务成员 + 可见性成员计算 allowed_users
|
||||
* - 子任务继承父任务的 allowed_users
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索任务: search($userid, $keyword, $searchType, $limit);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(ProjectTask $task);
|
||||
* - 批量同步: batchSync($tasks);
|
||||
* - 删除索引: delete($taskId);
|
||||
*
|
||||
* 3. 权限更新方法
|
||||
* - 更新权限: updateAllowedUsers($taskId);
|
||||
* - 项目成员变更级联更新: cascadeUpdateByProject($projectId);
|
||||
* - 父任务变更级联到子任务: cascadeToChildren($taskId);
|
||||
*
|
||||
* 4. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class ManticoreTask
|
||||
{
|
||||
/**
|
||||
* 最大内容长度(字符)
|
||||
*/
|
||||
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
|
||||
|
||||
/**
|
||||
* 搜索任务(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param int $userid 用户ID(权限过滤)
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $limit 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $limit = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::taskFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::taskFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::taskVectorSearch($embedding, $userid, $limit)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::taskHybridSearch($keyword, $embedding, $userid, $limit)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore task search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results Manticore 返回的结果
|
||||
* @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' => isset($item['task_desc']) ? mb_substr($item['task_desc'], 0, 300) : null,
|
||||
'content_preview' => isset($item['task_content']) ? mb_substr($item['task_content'], 0, 500) : null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限计算方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取任务的 allowed_users 列表
|
||||
*
|
||||
* 根据 visibility 计算有权限查看此任务的用户列表:
|
||||
* - visibility=1: 项目成员
|
||||
* - visibility=2: 任务成员(负责人/协作人)
|
||||
* - visibility=3: 任务成员 + 可见性指定成员
|
||||
* - 子任务: 还需要继承父任务的成员
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @param int $depth 递归深度(防止无限递归)
|
||||
* @param array $visited 已访问的任务ID(防止循环引用)
|
||||
* @return array 有权限的用户ID数组
|
||||
*/
|
||||
public static function getAllowedUsers(ProjectTask $task, int $depth = 0, array $visited = []): array
|
||||
{
|
||||
// 防止无限递归:深度超过10层或循环引用
|
||||
if ($depth > 10 || in_array($task->id, $visited)) {
|
||||
return [];
|
||||
}
|
||||
$visited[] = $task->id;
|
||||
|
||||
$userids = [];
|
||||
|
||||
// 1. 根据 visibility 获取基础成员
|
||||
if ($task->visibility == 1) {
|
||||
// visibility=1: 项目成员
|
||||
$userids = ProjectUser::where('project_id', $task->project_id)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
} else {
|
||||
// visibility=2,3: 任务成员(负责人/协作人)
|
||||
$userids = ProjectTaskUser::where('task_id', $task->id)
|
||||
->orWhere('task_pid', $task->id)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
// visibility=3: 加上可见性指定成员
|
||||
if ($task->visibility == 3) {
|
||||
$visUsers = ProjectTaskVisibilityUser::where('task_id', $task->id)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
$userids = array_merge($userids, $visUsers);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果是子任务,继承父任务成员
|
||||
if ($task->parent_id > 0) {
|
||||
$parentTask = ProjectTask::find($task->parent_id);
|
||||
if ($parentTask) {
|
||||
$parentUsers = self::getAllowedUsers($parentTask, $depth + 1, $visited);
|
||||
$userids = array_merge($userids, $parentUsers);
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($userids);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个任务到 Manticore(含 allowed_users)
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(ProjectTask $task, bool $withVector = false): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
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 = null;
|
||||
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取任务的 allowed_users
|
||||
$allowedUsers = self::getAllowedUsers($task);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::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,
|
||||
'allowed_users' => $allowedUsers,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore 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) {
|
||||
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 任务列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $tasks, bool $withVector = false): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($tasks as $task) {
|
||||
if (self::sync($task, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务索引
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $taskId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::deleteTaskVector($taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::clearAllTaskVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引任务数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ManticoreBase::getIndexedTaskCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限更新方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 更新任务的 allowed_users 权限列表
|
||||
* 重新计算并更新 Manticore 中的权限
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function updateAllowedUsers(int $taskId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $taskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$task = ProjectTask::find($taskId);
|
||||
if (!$task) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userids = self::getAllowedUsers($task);
|
||||
return ManticoreBase::updateTaskAllowedUsers($taskId, $userids);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore updateAllowedUsers error: ' . $e->getMessage(), ['task_id' => $taskId]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 级联更新项目下所有 visibility=1 任务的 allowed_users
|
||||
* 当项目成员变更时调用
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return int 更新的任务数量
|
||||
*/
|
||||
public static function cascadeUpdateByProject(int $projectId): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $projectId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取项目成员
|
||||
$projectUsers = ProjectUser::where('project_id', $projectId)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
// 分批更新该项目下所有 visibility=1 的任务
|
||||
$count = 0;
|
||||
ProjectTask::where('project_id', $projectId)
|
||||
->where('visibility', 1)
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('archived_at')
|
||||
->chunk(100, function ($tasks) use ($projectUsers, &$count) {
|
||||
foreach ($tasks as $task) {
|
||||
// 对于子任务,需要合并父任务成员
|
||||
$allowedUsers = $projectUsers;
|
||||
if ($task->parent_id > 0) {
|
||||
$parentTask = ProjectTask::find($task->parent_id);
|
||||
if ($parentTask) {
|
||||
$parentUsers = self::getAllowedUsers($parentTask);
|
||||
$allowedUsers = array_unique(array_merge($allowedUsers, $parentUsers));
|
||||
}
|
||||
}
|
||||
|
||||
ManticoreBase::updateTaskAllowedUsers($task->id, $allowedUsers);
|
||||
$count++;
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore cascadeUpdateByProject error: ' . $e->getMessage(), ['project_id' => $projectId]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 级联更新所有子任务的 allowed_users
|
||||
* 当父任务的成员变更时调用
|
||||
*
|
||||
* @param int $taskId 父任务ID
|
||||
* @return void
|
||||
*/
|
||||
public static function cascadeToChildren(int $taskId): void
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ProjectTask::where('parent_id', $taskId)
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('archived_at')
|
||||
->each(function ($child) {
|
||||
$allowedUsers = self::getAllowedUsers($child);
|
||||
ManticoreBase::updateTaskAllowedUsers($child->id, $allowedUsers);
|
||||
// 递归处理子任务的子任务
|
||||
self::cascadeToChildren($child->id);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore cascadeToChildren error: ' . $e->getMessage(), ['task_id' => $taskId]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成任务向量
|
||||
* 用于后台异步处理,将已索引任务的向量批量生成
|
||||
*
|
||||
* @param array $taskIds 任务ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $taskIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($taskIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询任务信息
|
||||
$tasks = ProjectTask::whereIn('id', $taskIds)
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('archived_at')
|
||||
->get();
|
||||
|
||||
if ($tasks->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个任务的内容
|
||||
$taskContents = [];
|
||||
foreach ($tasks as $task) {
|
||||
$taskContent = self::getTaskContent($task);
|
||||
$searchableContent = self::buildSearchableContent($task, $taskContent);
|
||||
if (!empty($searchableContent)) {
|
||||
// 限制内容长度
|
||||
$searchableContent = mb_substr($searchableContent, 0, self::MAX_CONTENT_LENGTH);
|
||||
$taskContents[$task->id] = $searchableContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($taskContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($taskContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $taskId) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$taskId] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateTaskVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreTask generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
362
app/Module/Manticore/ManticoreUser.php
Normal file
362
app/Module/Manticore/ManticoreUser.php
Normal file
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserTag;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 用户搜索类(联系人搜索)
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索用户: search($keyword, $searchType, $limit);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(User $user);
|
||||
* - 批量同步: batchSync($users);
|
||||
* - 删除索引: delete($userid);
|
||||
*
|
||||
* 3. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class ManticoreUser
|
||||
{
|
||||
/**
|
||||
* 搜索用户(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $limit 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(string $keyword, string $searchType = 'hybrid', int $limit = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::userFullTextSearch($keyword, $limit, 0)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::userFullTextSearch($keyword, $limit, 0)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::userVectorSearch($embedding, $limit)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::userHybridSearch($keyword, $embedding, $limit)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore user search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results Manticore 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'userid' => $item['userid'],
|
||||
'nickname' => $item['nickname'],
|
||||
'email' => $item['email'],
|
||||
'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,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取用户的标签(按认可数排序,最多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
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(User $user, bool $withVector = false): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 不处理机器人账号
|
||||
if ($user->bot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 不处理已禁用的账号
|
||||
if ($user->disable_at) {
|
||||
return self::delete($user->userid);
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取用户标签(Top 10)
|
||||
$tags = self::getUserTags($user->userid);
|
||||
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($user, $tags);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 Manticore
|
||||
$result = ManticoreBase::upsertUserVector([
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname ?? '',
|
||||
'email' => $user->email ?? '',
|
||||
'profession' => $user->profession ?? '',
|
||||
'tags' => $tags,
|
||||
'introduction' => $user->introduction ?? '',
|
||||
'content_vector' => $embedding,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore user sync error: ' . $e->getMessage(), [
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可搜索的文本内容
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @param string $tags 用户标签(空格分隔)
|
||||
* @return string 可搜索的文本
|
||||
*/
|
||||
private static function buildSearchableContent(User $user, string $tags = ''): 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($tags)) {
|
||||
$parts[] = $tags;
|
||||
}
|
||||
if (!empty($user->introduction)) {
|
||||
$parts[] = $user->introduction;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步用户
|
||||
*
|
||||
* @param iterable $users 用户列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $users, bool $withVector = false): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($users as $user) {
|
||||
if (self::sync($user, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户索引
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $userid): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::deleteUserVector($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::clearAllUserVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引用户数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ManticoreBase::getIndexedUserCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成用户向量
|
||||
* 用于后台异步处理,将已索引用户的向量批量生成
|
||||
*
|
||||
* @param array $userIds 用户ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $userIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($userIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询用户信息
|
||||
$users = User::whereIn('userid', $userIds)
|
||||
->where('bot', 0)
|
||||
->whereNull('disable_at')
|
||||
->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个用户的内容(包含标签)
|
||||
$userContents = [];
|
||||
foreach ($users as $user) {
|
||||
$tags = self::getUserTags($user->userid);
|
||||
$searchableContent = self::buildSearchableContent($user, $tags);
|
||||
if (!empty($searchableContent)) {
|
||||
$userContents[$user->userid] = $searchableContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($userContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($userContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $userid) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$userid] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateUserVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreUser generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
300
app/Module/PromptPlaceholder.php
Normal file
300
app/Module/PromptPlaceholder.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use App\Models\UserTag;
|
||||
use App\Models\WebSocketDialog;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use DB;
|
||||
|
||||
/**
|
||||
* AI 提示词模块
|
||||
*
|
||||
* 提供用户上下文和条件性提示块的构建能力
|
||||
*/
|
||||
class PromptPlaceholder
|
||||
{
|
||||
/**
|
||||
* 构建条件性提示块(用户上下文 + 格式指南)
|
||||
*
|
||||
* @param int|null $userid
|
||||
* @param WebSocketDialog|null $dialog
|
||||
* @return string
|
||||
*/
|
||||
public static function buildOptionalPrompts($userid, ?WebSocketDialog $dialog = null): string
|
||||
{
|
||||
$blocks = [];
|
||||
|
||||
// 用户上下文块
|
||||
if ($userid && $userid > 0) {
|
||||
$userContext = self::buildUserContext($userid, $dialog);
|
||||
if ($userContext) {
|
||||
$blocks[] = <<<EOF
|
||||
<optional-user-context>
|
||||
以下是当前对话用户的背景信息,当需要了解用户身份、工作职责或任务情况时可参考:
|
||||
|
||||
{$userContext}
|
||||
|
||||
注意:此上下文仅供参考,用于理解用户背景和提供个性化帮助。如果与当前对话无关,请忽略。
|
||||
</optional-user-context>
|
||||
EOF;
|
||||
}
|
||||
}
|
||||
|
||||
// 格式指南块
|
||||
$blocks[] = <<<'EOF'
|
||||
<optional-format-guide>
|
||||
当你的回答中包含 DooTask 系统资源(任务、项目、文件等)时,建议使用以下链接格式使其可点击:
|
||||
- 任务: [任务名称](dootask://task/{task_id}/{parent_id}),其中 parent_id 为主任务ID,主任务时为 0
|
||||
- 项目: [项目名称](dootask://project/{project_id})
|
||||
- 文件: [文件名称](dootask://file/{file_id})
|
||||
- 联系人: [用户名](dootask://contact/{userid})
|
||||
- 消息: [消息预览](dootask://message/{dialog_id}/{msg_id})
|
||||
|
||||
注意:此格式指南不影响正常对话,仅在涉及上述资源时参考。如果与当前对话无关,请忽略。
|
||||
</optional-format-guide>
|
||||
EOF;
|
||||
|
||||
return implode("\n\n", $blocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整用户上下文
|
||||
*/
|
||||
private static function buildUserContext(int $userid, ?WebSocketDialog $dialog = null): string
|
||||
{
|
||||
$lines = [];
|
||||
|
||||
// 基础信息
|
||||
$basicInfo = self::getUserBasicInfo($userid);
|
||||
$nickname = $basicInfo['nickname'] ?? '';
|
||||
if ($nickname) {
|
||||
$basicLine = "与您对话的用户:{$nickname}";
|
||||
if ($basicInfo['profession'] ?? '') {
|
||||
$basicLine .= "({$basicInfo['profession']})";
|
||||
}
|
||||
$lines[] = "{$basicLine}(user_id: {$userid})";
|
||||
}
|
||||
|
||||
if ($basicInfo['department'] ?? '') {
|
||||
$lines[] = "所属部门:{$basicInfo['department']}";
|
||||
}
|
||||
|
||||
if ($basicInfo['introduction'] ?? '') {
|
||||
$lines[] = "个人简介:{$basicInfo['introduction']}";
|
||||
}
|
||||
|
||||
// 同事印象
|
||||
$tags = self::getUserTags($userid);
|
||||
if ($tags) {
|
||||
$lines[] = "同事印象:{$tags}";
|
||||
}
|
||||
|
||||
// 场景角色
|
||||
if ($dialog) {
|
||||
$role = self::getUserRole($userid, $dialog);
|
||||
if ($role) {
|
||||
$lines[] = $role;
|
||||
}
|
||||
}
|
||||
|
||||
// 进行中任务
|
||||
$inProgressTasks = self::getInProgressTasks($userid);
|
||||
if ($inProgressTasks) {
|
||||
$lines[] = "\n进行中的任务:\n{$inProgressTasks}";
|
||||
}
|
||||
|
||||
// 最近完成
|
||||
$completedTasks = self::getCompletedTasks($userid);
|
||||
if ($completedTasks) {
|
||||
$lines[] = "\n最近完成:\n{$completedTasks}";
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户基础信息
|
||||
*/
|
||||
private static function getUserBasicInfo(int $userid): array
|
||||
{
|
||||
$user = User::find($userid);
|
||||
if (!$user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'nickname' => $user->nickname ?: '',
|
||||
'profession' => $user->profession ?: '',
|
||||
'introduction' => $user->introduction ? mb_substr($user->introduction, 0, 100) : '',
|
||||
'department' => $user->getDepartmentName() ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户标签 Top 5
|
||||
*/
|
||||
private static function getUserTags(int $userid): string
|
||||
{
|
||||
$tags = UserTag::where('user_id', $userid)
|
||||
->withCount(['recognitions as recognition_total'])
|
||||
->orderByDesc('recognition_total')
|
||||
->orderBy('id')
|
||||
->take(5)
|
||||
->pluck('name')
|
||||
->toArray();
|
||||
|
||||
return implode('、', $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在场景中的角色
|
||||
*/
|
||||
private static function getUserRole(int $userid, WebSocketDialog $dialog): string
|
||||
{
|
||||
if ($dialog->type !== 'group') {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch ($dialog->group_type) {
|
||||
case 'project':
|
||||
$project = Project::whereDialogId($dialog->id)->first();
|
||||
if ($project) {
|
||||
$projectUser = ProjectUser::whereProjectId($project->id)->whereUserid($userid)->first();
|
||||
if ($projectUser?->owner) {
|
||||
return '该用户是此项目的负责人';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task':
|
||||
$task = ProjectTask::whereDialogId($dialog->id)->first();
|
||||
if ($task) {
|
||||
$taskUser = ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->first();
|
||||
if ($taskUser) {
|
||||
return $taskUser->owner ? '该用户是此任务的负责人' : '该用户是此任务的协助人';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'department':
|
||||
$department = UserDepartment::whereDialogId($dialog->id)->first();
|
||||
if ($department?->owner_userid === $userid) {
|
||||
return '该用户是此部门的负责人';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进行中的任务(缓存 3 分钟)
|
||||
*
|
||||
* 排序策略:逾期优先 → 最近活跃优先 → 负责人优先 → 高优先级优先 → 截止时间近优先
|
||||
*/
|
||||
private static function getInProgressTasks(int $userid): string
|
||||
{
|
||||
$cacheKey = "prompt:tasks:in_progress:{$userid}";
|
||||
|
||||
return Cache::remember($cacheKey, 180, function () use ($userid) {
|
||||
$now = Carbon::now();
|
||||
$threeDaysAgo = $now->copy()->subDays(3);
|
||||
|
||||
// orderByRaw 中的表名需要带前缀
|
||||
$prefix = DB::getTablePrefix();
|
||||
$t = $prefix . 'project_tasks';
|
||||
$du = $prefix . 'web_socket_dialog_users';
|
||||
|
||||
$tasks = ProjectTask::query()
|
||||
->select([
|
||||
'project_tasks.id',
|
||||
'project_tasks.name',
|
||||
'project_tasks.p_name',
|
||||
'project_tasks.end_at',
|
||||
'project_task_users.owner'
|
||||
])
|
||||
->join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->leftJoin('web_socket_dialog_users', function ($join) use ($userid) {
|
||||
$join->on('project_tasks.dialog_id', '=', 'web_socket_dialog_users.dialog_id')
|
||||
->where('web_socket_dialog_users.userid', '=', $userid);
|
||||
})
|
||||
->where('project_task_users.userid', $userid)
|
||||
->where('project_tasks.visibility', 1)
|
||||
->whereNull('project_tasks.complete_at')
|
||||
->whereNull('project_tasks.archived_at')
|
||||
->whereNull('project_tasks.deleted_at')
|
||||
->orderByRaw("CASE WHEN {$t}.end_at IS NOT NULL AND {$t}.end_at < ? THEN 0 ELSE 1 END", [$now])
|
||||
->orderByRaw("CASE WHEN {$du}.last_at >= ? THEN 0 ELSE 1 END", [$threeDaysAgo])
|
||||
->orderByDesc('web_socket_dialog_users.last_at')
|
||||
->orderByDesc('project_task_users.owner')
|
||||
->orderByDesc('project_tasks.p_level')
|
||||
->orderByRaw("CASE WHEN {$t}.end_at IS NULL THEN 1 ELSE 0 END")
|
||||
->orderBy('project_tasks.end_at')
|
||||
->take(20)
|
||||
->get();
|
||||
|
||||
return self::formatTaskList($tasks, $now);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近完成的任务(缓存 3 分钟)
|
||||
*/
|
||||
private static function getCompletedTasks(int $userid): string
|
||||
{
|
||||
$cacheKey = "prompt:tasks:completed:{$userid}";
|
||||
|
||||
return Cache::remember($cacheKey, 180, function () use ($userid) {
|
||||
$tasks = ProjectTask::query()
|
||||
->select([
|
||||
'project_tasks.id',
|
||||
'project_tasks.name'
|
||||
])
|
||||
->join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->where('project_task_users.userid', $userid)
|
||||
->where('project_tasks.visibility', 1)
|
||||
->whereNotNull('project_tasks.complete_at')
|
||||
->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(7))
|
||||
->whereNull('project_tasks.deleted_at')
|
||||
->orderByDesc('project_tasks.complete_at')
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
if ($tasks->isEmpty()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $tasks->map(fn($task) => "- {$task->name} (task:{$task->id})")->implode("\n");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化任务列表
|
||||
*/
|
||||
private static function formatTaskList($tasks, Carbon $now): string
|
||||
{
|
||||
if ($tasks->isEmpty()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $tasks->map(function ($task) use ($now) {
|
||||
$line = '- ';
|
||||
if ($task->p_name) {
|
||||
$line .= "[{$task->p_name}] ";
|
||||
}
|
||||
$line .= "{$task->name} (task_id:{$task->id})";
|
||||
if ($task->end_at && Carbon::parse($task->end_at)->lt($now)) {
|
||||
$line .= ' ⚠️逾期';
|
||||
}
|
||||
return $line;
|
||||
})->implode("\n");
|
||||
}
|
||||
}
|
||||
@@ -233,11 +233,12 @@ class TextExtractor
|
||||
/**
|
||||
* 获取文件内容
|
||||
* @param $filePath
|
||||
* @param int $fileMaxSize 最大文件大小,单位字节,默认1024KB
|
||||
* @param int $contentMaxSize 最大内容大小,单位字节,默认300KB
|
||||
* @param int $fileMaxSize 最大文件大小,单位KB,默认1024KB
|
||||
* @param int $contentMaxSize 最大内容大小,单位KB,默认300KB
|
||||
* @param bool $truncate 超过contentMaxSize时是否截取,默认true截取,false返回错误
|
||||
* @return array
|
||||
*/
|
||||
public static function extractFile($filePath, int $fileMaxSize = 1024, int $contentMaxSize = 300): array
|
||||
public static function extractFile($filePath, int $fileMaxSize = 1024, int $contentMaxSize = 300, bool $truncate = true): array
|
||||
{
|
||||
if (!file_exists($filePath) || !is_file($filePath)) {
|
||||
return Base::retError("Failed to read contents of {$filePath}");
|
||||
@@ -248,8 +249,13 @@ class TextExtractor
|
||||
try {
|
||||
$extractor = new self($filePath);
|
||||
$content = $extractor->extractContent();
|
||||
if (strlen($content) > $contentMaxSize * 1024) {
|
||||
return Base::retError("Content size exceeds " . Base::readableBytes($contentMaxSize * 1024) . ", unable to display content");
|
||||
$maxBytes = $contentMaxSize * 1024;
|
||||
if (strlen($content) > $maxBytes) {
|
||||
if ($truncate) {
|
||||
$content = mb_substr($content, 0, $maxBytes);
|
||||
} else {
|
||||
return Base::retError("Content size exceeds " . Base::readableBytes($maxBytes) . ", unable to display content");
|
||||
}
|
||||
}
|
||||
return Base::retSuccess("success", $content);
|
||||
} catch (Exception $e) {
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\ZincSearch;
|
||||
|
||||
use App\Module\Apps;
|
||||
use App\Module\Doo;
|
||||
|
||||
/**
|
||||
* ZincSearch 公共类
|
||||
*/
|
||||
class ZincSearchBase
|
||||
{
|
||||
private mixed $host;
|
||||
private mixed $port;
|
||||
private mixed $user;
|
||||
private mixed $pass;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->host = env('ZINCSEARCH_HOST', 'search');
|
||||
$this->port = env('ZINCSEARCH_PORT', '4080');
|
||||
$this->user = env('DB_USERNAME', '');
|
||||
$this->pass = env('DB_PASSWORD', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用请求方法
|
||||
*/
|
||||
private function request($path, $body = null, $method = 'POST')
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => Doo::translate("应用「ZincSearch」未安装")
|
||||
];
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, "http://{$this->host}:{$this->port}{$path}");
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, $this->user . ':' . $this->pass);
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
|
||||
$headers = ['Content-Type: application/json'];
|
||||
if ($method === 'BULK') {
|
||||
$headers = ['Content-Type: text/plain'];
|
||||
}
|
||||
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$result = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($error) {
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
$data = json_decode($result, true);
|
||||
return [
|
||||
'success' => $status >= 200 && $status < 300,
|
||||
'status' => $status,
|
||||
'data' => $data
|
||||
];
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 索引管理相关方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 创建索引
|
||||
*/
|
||||
public static function createIndex($index, $mappings = []): array
|
||||
{
|
||||
$body = json_encode([
|
||||
'name' => $index,
|
||||
'mappings' => $mappings
|
||||
]);
|
||||
return (new self())->request("/api/index", $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取索引信息
|
||||
*/
|
||||
public static function getIndex($index): array
|
||||
{
|
||||
return (new self())->request("/api/index/{$index}", null, 'GET');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断索引是否存在
|
||||
*/
|
||||
public static function indexExists($index): bool
|
||||
{
|
||||
$result = self::getIndex($index);
|
||||
return $result['success'] && isset($result['data']['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有索引
|
||||
*/
|
||||
public static function listIndices(): array
|
||||
{
|
||||
return (new self())->request("/api/index", null, 'GET');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除索引
|
||||
*/
|
||||
public static function deleteIndex($index): array
|
||||
{
|
||||
return (new self())->request("/api/index/{$index}", null, 'DELETE');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除所有索引
|
||||
*/
|
||||
public static function deleteAllIndices(): array
|
||||
{
|
||||
$instance = new self();
|
||||
$result = $instance->request("/api/index", null, 'GET');
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$indices = $result['data'] ?? [];
|
||||
$deleteResults = [];
|
||||
$success = true;
|
||||
|
||||
foreach ($indices as $index) {
|
||||
$indexName = $index['name'] ?? '';
|
||||
if (!empty($indexName)) {
|
||||
$deleteResult = $instance->request("/api/index/{$indexName}", null, 'DELETE');
|
||||
$deleteResults[$indexName] = $deleteResult;
|
||||
|
||||
if (!$deleteResult['success']) {
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'message' => $success ? '所有索引删除成功' : '部分索引删除失败',
|
||||
'details' => $deleteResults
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析文本
|
||||
*/
|
||||
public static function analyze($analyzer, $text): array
|
||||
{
|
||||
$body = json_encode([
|
||||
'analyzer' => $analyzer,
|
||||
'text' => $text
|
||||
]);
|
||||
return (new self())->request("/api/_analyze", $body);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 文档管理相关方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 写入单条文档
|
||||
*/
|
||||
public static function addDoc($index, $doc): array
|
||||
{
|
||||
$body = json_encode($doc);
|
||||
return (new self())->request("/api/{$index}/_doc", $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文档
|
||||
*/
|
||||
public static function updateDoc($index, $id, $doc): array
|
||||
{
|
||||
$body = json_encode($doc);
|
||||
return (new self())->request("/api/{$index}/_update/{$id}", $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档
|
||||
*/
|
||||
public static function deleteDoc($index, $id): array
|
||||
{
|
||||
return (new self())->request("/api/{$index}/_doc/{$id}", null, 'DELETE');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量写入文档
|
||||
*/
|
||||
public static function addDocs($index, $docs): array
|
||||
{
|
||||
$body = json_encode([
|
||||
'index' => $index,
|
||||
'records' => $docs
|
||||
]);
|
||||
return (new self())->request("/api/_bulkv2", $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用原始BULK API批量写入文档
|
||||
* 请求格式为Elasticsearch兼容格式
|
||||
*/
|
||||
public static function bulkDocs($data): array
|
||||
{
|
||||
return (new self())->request("/api/_bulk", $data, 'BULK');
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 搜索相关方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 查询文档
|
||||
*/
|
||||
public static function search($index, $query, $from = 0, $size = 10): array
|
||||
{
|
||||
$searchParams = [
|
||||
'search_type' => 'match',
|
||||
'query' => [
|
||||
'term' => $query
|
||||
],
|
||||
'from' => $from,
|
||||
'max_results' => $size
|
||||
];
|
||||
|
||||
$body = json_encode($searchParams);
|
||||
return (new self())->request("/api/{$index}/_search", $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 高级查询文档
|
||||
*/
|
||||
public static function advancedSearch($index, $searchParams): array
|
||||
{
|
||||
$body = json_encode($searchParams);
|
||||
return (new self())->request("/api/{$index}/_search", $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容ES查询文档
|
||||
*/
|
||||
public static function elasticSearch($index, $searchParams): array
|
||||
{
|
||||
$body = json_encode($searchParams);
|
||||
return (new self())->request("/es/{$index}/_search", $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多索引查询
|
||||
*/
|
||||
public static function multiSearch($queries): array
|
||||
{
|
||||
$body = json_encode($queries);
|
||||
return (new self())->request("/api/_msearch", $body);
|
||||
}
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\ZincSearch;
|
||||
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use App\Module\Apps;
|
||||
use Carbon\Carbon;
|
||||
use DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* ZincSearch 会话消息类
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 基础方法
|
||||
* - 清空所有数据: clear();
|
||||
*
|
||||
* 2. 搜索方法
|
||||
* - 关键词搜索: search('用户ID', '关键词');
|
||||
*
|
||||
* 3. 基本方法
|
||||
* - 单个同步: sync(WebSocketDialogMsg $dialogMsg);
|
||||
* - 批量同步: batchSync(WebSocketDialogMsg[] $dialogMsgs);
|
||||
* - 用户同步: userSync(WebSocketDialogUser $dialogUser);
|
||||
* - 删除消息: delete(WebSocketDialogMsg|WebSocketDialogUser|int $data);
|
||||
*/
|
||||
class ZincSearchDialogMsg
|
||||
{
|
||||
/**
|
||||
* 索引名称
|
||||
*/
|
||||
protected static string $indexNameMsg = 'dialogMsg';
|
||||
protected static string $indexNameUser = 'dialogUser';
|
||||
|
||||
// ==============================
|
||||
// 基础方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 确保索引存在
|
||||
*/
|
||||
private static function ensureIndex(): bool
|
||||
{
|
||||
if (!ZincSearchBase::indexExists(self::$indexNameMsg)) {
|
||||
$mappings = [
|
||||
'properties' => [
|
||||
// 拓展数据
|
||||
'dialog_userid' => ['type' => 'keyword', 'index' => true], // 对话ID+用户ID
|
||||
'to_userid' => ['type' => 'numeric', 'index' => true], // 此消息发给的用户ID
|
||||
|
||||
// 消息数据
|
||||
'id' => ['type' => 'numeric', 'index' => true],
|
||||
'dialog_id' => ['type' => 'numeric', 'index' => true],
|
||||
'dialog_type' => ['type' => 'keyword', 'index' => true],
|
||||
'session_id' => ['type' => 'numeric', 'index' => true],
|
||||
'userid' => ['type' => 'numeric', 'index' => true],
|
||||
'type' => ['type' => 'keyword', 'index' => true],
|
||||
'key' => ['type' => 'text', 'index' => true],
|
||||
'created_at' => ['type' => 'date', 'index' => true],
|
||||
'updated_at' => ['type' => 'date', 'index' => true],
|
||||
]
|
||||
];
|
||||
$result = ZincSearchBase::createIndex(self::$indexNameMsg, $mappings);
|
||||
return $result['success'] ?? false;
|
||||
}
|
||||
if (!ZincSearchBase::indexExists(self::$indexNameUser)) {
|
||||
$mappings = [
|
||||
'properties' => [
|
||||
// 拓展数据
|
||||
'dialog_userid' => ['type' => 'keyword', 'index' => true], // 对话ID+用户ID
|
||||
|
||||
// 用户数据
|
||||
'id' => ['type' => 'numeric', 'index' => true],
|
||||
'dialog_id' => ['type' => 'numeric', 'index' => true],
|
||||
'userid' => ['type' => 'numeric', 'index' => true],
|
||||
'top_at' => ['type' => 'date', 'index' => true],
|
||||
'last_at' => ['type' => 'date', 'index' => true],
|
||||
'mark_unread' => ['type' => 'numeric', 'index' => true],
|
||||
'silence' => ['type' => 'numeric', 'index' => true],
|
||||
'hide' => ['type' => 'numeric', 'index' => true],
|
||||
'color' => ['type' => 'keyword', 'index' => true],
|
||||
'created_at' => ['type' => 'date', 'index' => true],
|
||||
'updated_at' => ['type' => 'date', 'index' => true],
|
||||
]
|
||||
];
|
||||
$result = ZincSearchBase::createIndex(self::$indexNameUser, $mappings);
|
||||
return $result['success'] ?? false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有键值
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
// 检查索引是否存在然后删除
|
||||
if (ZincSearchBase::indexExists(self::$indexNameMsg)) {
|
||||
$deleteResult = ZincSearchBase::deleteIndex(self::$indexNameMsg);
|
||||
if (!($deleteResult['success'] ?? false)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (ZincSearchBase::indexExists(self::$indexNameUser)) {
|
||||
$deleteResult = ZincSearchBase::deleteIndex(self::$indexNameUser);
|
||||
if (!($deleteResult['success'] ?? false)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return self::ensureIndex();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 搜索方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 根据用户ID和消息关键词搜索会话
|
||||
*
|
||||
* @param string $userid 用户ID
|
||||
* @param string $keyword 消息关键词
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回结果数量
|
||||
* @return array
|
||||
*/
|
||||
public static function search(string $userid, string $keyword, int $from = 0, int $size = 20): array
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
// 如果搜索功能未安装,使用数据库查询
|
||||
return self::searchByMysql($userid, $keyword, $from, $size);
|
||||
}
|
||||
|
||||
$searchParams = [
|
||||
'query' => [
|
||||
'bool' => [
|
||||
'must' => [
|
||||
['term' => ['to_userid' => $userid]],
|
||||
['match_phrase' => ['key' => $keyword]]
|
||||
]
|
||||
]
|
||||
],
|
||||
'from' => $from,
|
||||
'size' => $size,
|
||||
'sort' => [
|
||||
['updated_at' => 'desc']
|
||||
]
|
||||
];
|
||||
try {
|
||||
$result = ZincSearchBase::elasticSearch(self::$indexNameMsg, $searchParams);
|
||||
$hits = $result['data']['hits']['hits'] ?? [];
|
||||
|
||||
// 收集所有的用户信息
|
||||
$dialogUserids = [];
|
||||
foreach ($hits as $hit) {
|
||||
$source = $hit['_source'];
|
||||
$dialogUserids[] = $source['dialog_userid'];
|
||||
}
|
||||
$userInfos = self::searchUser(array_unique($dialogUserids));
|
||||
|
||||
// 组合返回结果,将用户信息合并到消息中
|
||||
$msgs = [];
|
||||
foreach ($hits as $hit) {
|
||||
$msgInfo = $hit['_source'];
|
||||
$userInfo = $userInfos[$msgInfo['dialog_userid']] ?? [];
|
||||
if ($userInfo) {
|
||||
$msgs[] = [
|
||||
'id' => $msgInfo['dialog_id'],
|
||||
'search_msg_id' => $msgInfo['id'],
|
||||
'user_at' => Carbon::parse($msgInfo['updated_at'])->format('Y-m-d H:i:s'),
|
||||
|
||||
'mark_unread' => $userInfo['mark_unread'],
|
||||
'silence' => $userInfo['silence'],
|
||||
'hide' => $userInfo['hide'],
|
||||
'color' => $userInfo['color'],
|
||||
'top_at' => Carbon::parse($userInfo['top_at'])->format('Y-m-d H:i:s'),
|
||||
'last_at' => Carbon::parse($userInfo['last_at'])->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $msgs;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('search: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID和消息关键词搜索会话(MySQL 版本,主要用于未安装ZincSearch的情况)
|
||||
*
|
||||
* @param string $userid 用户ID
|
||||
* @param string $keyword 消息关键词
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回结果数量
|
||||
* @return array
|
||||
*/
|
||||
private static function searchByMysql(string $userid, string $keyword, int $from = 0, int $size = 20): array
|
||||
{
|
||||
$items = DB::table('web_socket_dialog_users as u')
|
||||
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at', 'm.id as search_msg_id'])
|
||||
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
|
||||
->join('web_socket_dialog_msgs as m', 'm.dialog_id', '=', 'd.id')
|
||||
->where('u.userid', $userid)
|
||||
->where('m.bot', 0)
|
||||
->whereNull('d.deleted_at')
|
||||
->where('m.key', 'like', "%{$keyword}%")
|
||||
->orderByDesc('m.id')
|
||||
->offset($from)
|
||||
->limit($size)
|
||||
->get()
|
||||
->all();
|
||||
$msgs = [];
|
||||
foreach ($items as $item) {
|
||||
$msgs[] = [
|
||||
'id' => $item->id,
|
||||
'search_msg_id' => $item->search_msg_id,
|
||||
'user_at' => Carbon::parse($item->user_at)->format('Y-m-d H:i:s'),
|
||||
|
||||
'mark_unread' => $item->mark_unread,
|
||||
'silence' => $item->silence,
|
||||
'hide' => $item->hide,
|
||||
'color' => $item->color,
|
||||
'top_at' => Carbon::parse($item->top_at)->format('Y-m-d H:i:s'),
|
||||
'last_at' => Carbon::parse($item->last_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
return $msgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据对话用户ID搜索用户信息
|
||||
* @param array $dialogUserids
|
||||
* @return array
|
||||
*/
|
||||
private static function searchUser(array $dialogUserids): array
|
||||
{
|
||||
if (empty($dialogUserids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$userInfos = [];
|
||||
|
||||
// 构建用户查询条件
|
||||
$userSearchParams = [
|
||||
'query' => [
|
||||
'bool' => [
|
||||
'should' => []
|
||||
]
|
||||
],
|
||||
'size' => count($dialogUserids) // 确保取到所有符合条件的记录
|
||||
];
|
||||
|
||||
// 添加所有 dialog_userid 到查询条件
|
||||
foreach ($dialogUserids as $dialogUserid) {
|
||||
$userSearchParams['query']['bool']['should'][] = [
|
||||
'term' => ['dialog_userid' => $dialogUserid]
|
||||
];
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
$userResult = ZincSearchBase::elasticSearch(self::$indexNameUser, $userSearchParams);
|
||||
$userHits = $userResult['data']['hits']['hits'] ?? [];
|
||||
|
||||
// 以 dialog_userid 为键保存用户信息
|
||||
foreach ($userHits as $userHit) {
|
||||
$userSource = $userHit['_source'];
|
||||
$userInfos[$userSource['dialog_userid']] = $userSource;
|
||||
}
|
||||
|
||||
return $userInfos;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 生成内容
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 生成 dialog_userid
|
||||
*
|
||||
* @param WebSocketDialogUser $dialogUser
|
||||
* @return string
|
||||
*/
|
||||
private static function generateDialogUserid(WebSocketDialogUser $dialogUser): string
|
||||
{
|
||||
return "{$dialogUser->dialog_id}_{$dialogUser->userid}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文档内容
|
||||
*
|
||||
* @param WebSocketDialogMsg $dialogMsg
|
||||
* @param WebSocketDialogUser $dialogUser
|
||||
* @return array
|
||||
*/
|
||||
private static function generateMsgData(WebSocketDialogMsg $dialogMsg, WebSocketDialogUser $dialogUser): array
|
||||
{
|
||||
return [
|
||||
'_id' => self::$indexNameMsg . "_" . $dialogMsg->id . "_" . $dialogUser->userid,
|
||||
'dialog_userid' => self::generateDialogUserid($dialogUser),
|
||||
'to_userid' => $dialogUser->userid,
|
||||
|
||||
'id' => $dialogMsg->id,
|
||||
'dialog_id' => $dialogMsg->dialog_id,
|
||||
'dialog_type' => $dialogMsg->dialog_type,
|
||||
'session_id' => $dialogMsg->session_id,
|
||||
'userid' => $dialogMsg->userid,
|
||||
'type' => $dialogMsg->type,
|
||||
'key' => $dialogMsg->key,
|
||||
'created_at' => $dialogMsg->created_at,
|
||||
'updated_at' => $dialogMsg->updated_at,
|
||||
];
|
||||
}
|
||||
private static function generateUserData(WebSocketDialogUser $dialogUser): array
|
||||
{
|
||||
return [
|
||||
'_id' => self::$indexNameUser . "_" . $dialogUser->id,
|
||||
'dialog_userid' => self::generateDialogUserid($dialogUser),
|
||||
|
||||
'id' => $dialogUser->id,
|
||||
'dialog_id' => $dialogUser->dialog_id,
|
||||
'userid' => $dialogUser->userid,
|
||||
'top_at' => $dialogUser->top_at,
|
||||
'last_at' => $dialogUser->last_at,
|
||||
'mark_unread' => $dialogUser->mark_unread,
|
||||
'silence' => $dialogUser->silence,
|
||||
'hide' => $dialogUser->hide,
|
||||
'color' => $dialogUser->color,
|
||||
'created_at' => $dialogUser->created_at,
|
||||
'updated_at' => $dialogUser->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 基本方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步消息(建议在异步进程中使用)
|
||||
*
|
||||
* @param WebSocketDialogMsg $dialogMsg
|
||||
* @return bool
|
||||
*/
|
||||
public static function sync(WebSocketDialogMsg $dialogMsg): bool
|
||||
{
|
||||
if (!self::ensureIndex()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($dialogMsg->bot) {
|
||||
// 如果是机器人消息,跳过
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取此会话的所有用户
|
||||
$dialogUsers = WebSocketDialogUser::whereDialogId($dialogMsg->dialog_id)->get();
|
||||
|
||||
if ($dialogUsers->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$msgs = [];
|
||||
$users = [];
|
||||
foreach ($dialogUsers as $dialogUser) {
|
||||
if (empty($dialogMsg->key)) {
|
||||
// 如果消息没有关键词,跳过
|
||||
continue;
|
||||
}
|
||||
if ($dialogUser->userid == 0) {
|
||||
// 跳过系统用户
|
||||
continue;
|
||||
}
|
||||
$msgs[] = self::generateMsgData($dialogMsg, $dialogUser);
|
||||
$users[$dialogUser->id] = self::generateUserData($dialogUser);
|
||||
}
|
||||
|
||||
if ($msgs) {
|
||||
// 批量写入消息
|
||||
ZincSearchBase::addDocs(self::$indexNameMsg, $msgs);
|
||||
}
|
||||
|
||||
if ($users) {
|
||||
// 批量写入用户
|
||||
ZincSearchBase::addDocs(self::$indexNameUser, array_values($users));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('sync: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步消息(建议在异步进程中使用)
|
||||
*
|
||||
* @param WebSocketDialogMsg[] $dialogMsgs
|
||||
* @return int 成功同步的消息数
|
||||
*/
|
||||
public static function batchSync($dialogMsgs): int
|
||||
{
|
||||
if (!self::ensureIndex()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
try {
|
||||
$msgs = [];
|
||||
$users = [];
|
||||
$userDialogs = [];
|
||||
|
||||
// 预处理:收集所有涉及的对话ID
|
||||
$dialogIds = [];
|
||||
foreach ($dialogMsgs as $dialogMsg) {
|
||||
$dialogIds[] = $dialogMsg->dialog_id;
|
||||
}
|
||||
$dialogIds = array_unique($dialogIds);
|
||||
|
||||
// 获取所有相关的用户-对话关系
|
||||
if (!empty($dialogIds)) {
|
||||
$dialogUsers = WebSocketDialogUser::whereIn('dialog_id', $dialogIds)->get();
|
||||
|
||||
// 按对话ID组织用户
|
||||
foreach ($dialogUsers as $dialogUser) {
|
||||
$userDialogs[$dialogUser->dialog_id][] = $dialogUser;
|
||||
}
|
||||
}
|
||||
|
||||
// 为每条消息准备所有相关用户的文档
|
||||
foreach ($dialogMsgs as $dialogMsg) {
|
||||
if (!isset($userDialogs[$dialogMsg->dialog_id])) {
|
||||
// 如果该会话没有用户,跳过
|
||||
continue;
|
||||
}
|
||||
if ($dialogMsg->bot) {
|
||||
// 如果是机器人消息,跳过
|
||||
continue;
|
||||
}
|
||||
/** @var WebSocketDialogUser $dialogUser */
|
||||
foreach ($userDialogs[$dialogMsg->dialog_id] as $dialogUser) {
|
||||
if (empty($dialogMsg->key)) {
|
||||
// 如果消息没有关键词,跳过
|
||||
continue;
|
||||
}
|
||||
if ($dialogUser->userid == 0) {
|
||||
// 跳过系统用户
|
||||
continue;
|
||||
}
|
||||
$msgs[] = self::generateMsgData($dialogMsg, $dialogUser);
|
||||
$users[$dialogUser->id] = self::generateUserData($dialogUser);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($msgs) {
|
||||
// 批量写入消息
|
||||
ZincSearchBase::addDocs(self::$indexNameMsg, $msgs);
|
||||
}
|
||||
|
||||
if ($users) {
|
||||
// 批量写入用户
|
||||
ZincSearchBase::addDocs(self::$indexNameUser, array_values($users));
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('batchSync: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步用户(建议在异步进程中使用)
|
||||
* @param WebSocketDialogUser $dialogUser
|
||||
* @return bool
|
||||
*/
|
||||
public static function userSync(WebSocketDialogUser $dialogUser): bool
|
||||
{
|
||||
if (!self::ensureIndex()) {
|
||||
return false;
|
||||
}
|
||||
$data = self::generateUserData($dialogUser);
|
||||
|
||||
// 生成查询用户条件
|
||||
$searchParams = [
|
||||
'query' => [
|
||||
'bool' => [
|
||||
'must' => [
|
||||
['term' => ['dialog_userid' => $data['dialog_userid']]]
|
||||
]
|
||||
]
|
||||
],
|
||||
'size' => 1
|
||||
];
|
||||
|
||||
try {
|
||||
// 查询用户是否存在
|
||||
$result = ZincSearchBase::elasticSearch(self::$indexNameUser, $searchParams);
|
||||
$hits = $result['data']['hits']['hits'] ?? [];
|
||||
|
||||
// 同步用户(存在更新、不存在添加)
|
||||
$result = ZincSearchBase::addDoc(self::$indexNameUser, $data);
|
||||
if (!isset($result['success'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 用户不存在,同步消息
|
||||
if (empty($hits)) {
|
||||
$lastId = 0; // 上次同步的最后ID
|
||||
$batchSize = 500; // 每批处理的消息数量
|
||||
|
||||
// 分批同步消息
|
||||
do {
|
||||
// 获取一批
|
||||
$dialogMsgs = WebSocketDialogMsg::whereDialogId($dialogUser->dialog_id)
|
||||
->where('id', '>', $lastId)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($dialogMsgs->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 同步数据
|
||||
ZincSearchDialogMsg::batchSync($dialogMsgs);
|
||||
|
||||
// 更新最后ID
|
||||
$lastId = $dialogMsgs->last()->id;
|
||||
} while (count($dialogMsgs) == $batchSize);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('userSync: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除(建议在异步进程中使用)
|
||||
*
|
||||
* @param WebSocketDialogMsg|WebSocketDialogUser|int $data
|
||||
* @return int
|
||||
*/
|
||||
public static function delete(mixed $data): int
|
||||
{
|
||||
$batchSize = 500; // 每批处理的文档数量
|
||||
$totalDeleted = 0; // 总共删除的文档数量
|
||||
$from = 0;
|
||||
|
||||
// 根据数据类型生成查询条件
|
||||
if ($data instanceof WebSocketDialogMsg) {
|
||||
$query = [
|
||||
'field' => 'id',
|
||||
'term' => (string) $data->id
|
||||
];
|
||||
} elseif ($data instanceof WebSocketDialogUser) {
|
||||
$query = [
|
||||
'field' => 'dialog_userid',
|
||||
'term' => self::generateDialogUserid($data),
|
||||
];
|
||||
} else {
|
||||
$query = [
|
||||
'field' => 'id',
|
||||
'term' => (string) $data
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// 根据消息ID查找相关文档
|
||||
$result = ZincSearchBase::advancedSearch(self::$indexNameMsg, [
|
||||
'search_type' => 'term',
|
||||
'query' => $query,
|
||||
'from' => $from,
|
||||
'max_results' => $batchSize
|
||||
]);
|
||||
$hits = $result['data']['hits']['hits'] ?? [];
|
||||
|
||||
// 如果没有更多文档,退出循环
|
||||
if (empty($hits)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 删除本批次找到的所有文档
|
||||
foreach ($hits as $hit) {
|
||||
if (isset($hit['_id'])) {
|
||||
ZincSearchBase::deleteDoc(self::$indexNameMsg, $hit['_id']);
|
||||
$totalDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果返回的文档数少于批次大小,说明已经没有更多文档了
|
||||
if (count($hits) < $batchSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 移动到下一批
|
||||
$from += $batchSize;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('delete: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $totalDeleted;
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\ZincSearch;
|
||||
|
||||
/**
|
||||
* ZincSearch 键值存储类
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 基础方法
|
||||
* - 确保索引存在: ensureIndex();
|
||||
* - 清空所有数据: clear();
|
||||
*
|
||||
* 2. 基本操作
|
||||
* - 设置键值: set('site_name', '我的网站');
|
||||
* - 设置复杂数据: set('site_config', ['logo' => 'logo.png', 'theme' => 'dark']);
|
||||
* - 合并现有数据: set('site_config', ['footer' => '版权所有'], true);
|
||||
* - 获取键值: $siteName = get('site_name');
|
||||
* - 获取键值带默认值: $theme = get('theme', 'light');
|
||||
* - 删除键值: delete('temporary_data');
|
||||
*
|
||||
* 3. 批量操作
|
||||
* - 批量设置: batchSet(['user_count' => 100, 'active_users' => 50]);
|
||||
* - 批量获取: $stats = batchGet(['user_count', 'active_users']);
|
||||
*/
|
||||
class ZincSearchKeyValue
|
||||
{
|
||||
/**
|
||||
* 索引名称
|
||||
*/
|
||||
protected static string $indexName = 'keyValue';
|
||||
|
||||
// ==============================
|
||||
// 基础方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 确保索引存在
|
||||
*/
|
||||
public static function ensureIndex(): bool
|
||||
{
|
||||
if (!ZincSearchBase::indexExists(self::$indexName)) {
|
||||
$mappings = [
|
||||
'properties' => [
|
||||
'key' => ['type' => 'keyword', 'index' => true],
|
||||
'value' => ['type' => 'text', 'index' => true],
|
||||
'created_at' => ['type' => 'date', 'index' => true],
|
||||
'updated_at' => ['type' => 'date', 'index' => true]
|
||||
]
|
||||
];
|
||||
$result = ZincSearchBase::createIndex(self::$indexName, $mappings);
|
||||
return $result['success'] ?? false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有键值
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
// 检查索引是否存在
|
||||
if (!ZincSearchBase::indexExists(self::$indexName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 删除再重建索引
|
||||
$deleteResult = ZincSearchBase::deleteIndex(self::$indexName);
|
||||
if (!($deleteResult['success'] ?? false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::ensureIndex();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 基本操作
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 设置键值
|
||||
*
|
||||
* @param string $key 键名
|
||||
* @param mixed $value 值
|
||||
* @param bool $merge 是否合并现有数据(如果值是数组)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function set(string $key, mixed $value, bool $merge = false): bool
|
||||
{
|
||||
if (!self::ensureIndex()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查键是否已存在
|
||||
if ($merge && is_array($value)) {
|
||||
$existingData = self::get($key);
|
||||
if (is_array($existingData)) {
|
||||
$value = array_merge($existingData, $value);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否存在相同键的文档 - 使用精确查询而不是普通搜索
|
||||
$searchParams = [
|
||||
'search_type' => 'term',
|
||||
'query' => [
|
||||
'field' => 'key',
|
||||
'term' => $key
|
||||
],
|
||||
'from' => 0,
|
||||
'max_results' => 1
|
||||
];
|
||||
|
||||
$result = ZincSearchBase::advancedSearch(self::$indexName, $searchParams);
|
||||
$docs = $result['data']['hits']['hits'] ?? [];
|
||||
$now = date('c');
|
||||
|
||||
if (!empty($docs)) {
|
||||
$docId = $docs[0]['_id'] ?? null;
|
||||
if ($docId) {
|
||||
// 更新现有文档
|
||||
$docData = [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'updated_at' => $now
|
||||
];
|
||||
$updateResult = ZincSearchBase::updateDoc(self::$indexName, $docId, $docData);
|
||||
return $updateResult['success'] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新文档
|
||||
$docData = [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now
|
||||
];
|
||||
$addResult = ZincSearchBase::addDoc(self::$indexName, $docData);
|
||||
return $addResult['success'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取键值
|
||||
*
|
||||
* @param string $key 键名
|
||||
* @param mixed $default 默认值
|
||||
* @return mixed 值或默认值
|
||||
*/
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (!self::ensureIndex() || empty($key)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
// 精确匹配键名
|
||||
$searchParams = [
|
||||
'search_type' => 'term',
|
||||
'query' => [
|
||||
'field' => 'key',
|
||||
'term' => $key
|
||||
],
|
||||
'from' => 0,
|
||||
'max_results' => 1
|
||||
];
|
||||
|
||||
$result = ZincSearchBase::advancedSearch(self::$indexName, $searchParams);
|
||||
if (!($result['success'] ?? false)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$hits = $result['data']['hits']['hits'] ?? [];
|
||||
if (empty($hits)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $hits[0]['_source']['value'] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除键值
|
||||
*
|
||||
* @param string $key 键名
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(string $key): bool
|
||||
{
|
||||
if (!self::ensureIndex() || empty($key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 查找文档ID
|
||||
$searchParams = [
|
||||
'search_type' => 'term',
|
||||
'query' => [
|
||||
'field' => 'key',
|
||||
'term' => $key
|
||||
],
|
||||
'from' => 0,
|
||||
'max_results' => 1
|
||||
];
|
||||
|
||||
$result = ZincSearchBase::advancedSearch(self::$indexName, $searchParams);
|
||||
if (!($result['success'] ?? false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hits = $result['data']['hits']['hits'] ?? [];
|
||||
if (empty($hits)) {
|
||||
return true; // 不存在视为删除成功
|
||||
}
|
||||
|
||||
$docId = $hits[0]['_id'] ?? null;
|
||||
if (empty($docId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deleteResult = ZincSearchBase::deleteDoc(self::$indexName, $docId);
|
||||
return $deleteResult['success'] ?? false;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量操作
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量设置键值对
|
||||
*
|
||||
* @param array $keyValues 键值对数组
|
||||
* @return bool 是否全部成功
|
||||
*/
|
||||
public static function batchSet(array $keyValues): bool
|
||||
{
|
||||
if (!self::ensureIndex() || empty($keyValues)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$docs = [];
|
||||
$now = date('c');
|
||||
|
||||
foreach ($keyValues as $key => $value) {
|
||||
$docs[] = [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now
|
||||
];
|
||||
}
|
||||
|
||||
$result = ZincSearchBase::addDocs(self::$indexName, $docs);
|
||||
return $result['success'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取键值
|
||||
*
|
||||
* @param array $keys 键名数组
|
||||
* @return array 键值对数组
|
||||
*/
|
||||
public static function batchGet(array $keys): array
|
||||
{
|
||||
if (!self::ensureIndex() || empty($keys)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
// 遍历查询每个键
|
||||
foreach ($keys as $key) {
|
||||
$results[$key] = self::get($key);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,46 @@
|
||||
namespace App\Observers;
|
||||
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AbstractObserver
|
||||
{
|
||||
/**
|
||||
* 任务去重窗口时间(秒)
|
||||
* 同一个 action+id 在此时间内只投递一次
|
||||
*/
|
||||
private const DEDUP_WINDOW = 10;
|
||||
|
||||
/**
|
||||
* 投递异步任务(带去重)
|
||||
*
|
||||
* @param $task
|
||||
* @return void
|
||||
*/
|
||||
public static function taskDeliver($task)
|
||||
{
|
||||
if (app()->bound('swoole')) {
|
||||
Task::deliver($task);
|
||||
if (!app()->bound('swoole')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 对 ManticoreSyncTask 进行去重
|
||||
if ($task instanceof \App\Tasks\ManticoreSyncTask) {
|
||||
$action = $task->getAction();
|
||||
$dataId = $task->getDataId();
|
||||
|
||||
if ($action && $dataId) {
|
||||
$cacheKey = "manticore_task:{$action}:{$dataId}";
|
||||
|
||||
// 如果已有相同任务在等待,跳过本次投递
|
||||
if (Cache::has($cacheKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记任务已投递
|
||||
Cache::put($cacheKey, true, self::DEDUP_WINDOW);
|
||||
}
|
||||
}
|
||||
|
||||
Task::deliver($task);
|
||||
}
|
||||
}
|
||||
|
||||
95
app/Observers/FileObserver.php
Normal file
95
app/Observers/FileObserver.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
class FileObserver extends AbstractObserver
|
||||
{
|
||||
/**
|
||||
* Handle the File "created" event.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return void
|
||||
*/
|
||||
public function created(File $file)
|
||||
{
|
||||
// 文件夹不需要同步
|
||||
if ($file->type === 'folder') {
|
||||
return;
|
||||
}
|
||||
self::taskDeliver(new ManticoreSyncTask('file_sync', $file->toArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the File "updated" event.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return void
|
||||
*/
|
||||
public function updated(File $file)
|
||||
{
|
||||
// 检查共享设置是否变化(影响子文件的 pshare)
|
||||
if ($file->type === 'folder' && $file->isDirty('share')) {
|
||||
// 共享文件夹的 share 字段变化,需要批量更新子文件的 pshare
|
||||
// 注意:updateShare 方法会批量更新,但不会触发 Observer
|
||||
$newPshare = $file->share ? $file->id : 0;
|
||||
$childFileIds = File::where('pids', 'like', "%,{$file->id},%")
|
||||
->where('type', '!=', 'folder')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
if (!empty($childFileIds)) {
|
||||
self::taskDeliver(new ManticoreSyncTask('file_pshare_update', [
|
||||
'file_ids' => $childFileIds,
|
||||
'pshare' => $newPshare,
|
||||
]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 文件夹不需要同步内容
|
||||
if ($file->type === 'folder') {
|
||||
return;
|
||||
}
|
||||
self::taskDeliver(new ManticoreSyncTask('file_sync', $file->toArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the File "deleted" event.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return void
|
||||
*/
|
||||
public function deleted(File $file)
|
||||
{
|
||||
self::taskDeliver(new ManticoreSyncTask('file_delete', $file->toArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the File "restored" event.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return void
|
||||
*/
|
||||
public function restored(File $file)
|
||||
{
|
||||
// 文件夹不需要同步
|
||||
if ($file->type === 'folder') {
|
||||
return;
|
||||
}
|
||||
self::taskDeliver(new ManticoreSyncTask('file_sync', $file->toArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the File "force deleted" event.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return void
|
||||
*/
|
||||
public function forceDeleted(File $file)
|
||||
{
|
||||
self::taskDeliver(new ManticoreSyncTask('file_delete', $file->toArray()));
|
||||
}
|
||||
}
|
||||
|
||||
54
app/Observers/FileUserObserver.php
Normal file
54
app/Observers/FileUserObserver.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\FileUser;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
/**
|
||||
* FileUser 观察者
|
||||
*/
|
||||
class FileUserObserver extends AbstractObserver
|
||||
{
|
||||
/**
|
||||
* Handle the FileUser "created" event.
|
||||
*
|
||||
* @param \App\Models\FileUser $fileUser
|
||||
* @return void
|
||||
*/
|
||||
public function created(FileUser $fileUser)
|
||||
{
|
||||
// 更新文件权限
|
||||
self::taskDeliver(new ManticoreSyncTask('update_file_allowed_users', [
|
||||
'file_id' => $fileUser->file_id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the FileUser "updated" event.
|
||||
*
|
||||
* @param \App\Models\FileUser $fileUser
|
||||
* @return void
|
||||
*/
|
||||
public function updated(FileUser $fileUser)
|
||||
{
|
||||
// 更新文件权限
|
||||
self::taskDeliver(new ManticoreSyncTask('update_file_allowed_users', [
|
||||
'file_id' => $fileUser->file_id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the FileUser "deleted" event.
|
||||
*
|
||||
* @param \App\Models\FileUser $fileUser
|
||||
* @return void
|
||||
*/
|
||||
public function deleted(FileUser $fileUser)
|
||||
{
|
||||
// 更新文件权限
|
||||
self::taskDeliver(new ManticoreSyncTask('update_file_allowed_users', [
|
||||
'file_id' => $fileUser->file_id,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ namespace App\Observers;
|
||||
use App\Models\Deleted;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
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 ManticoreSyncTask('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 ManticoreSyncTask('project_delete', ['project_id' => $project->id]));
|
||||
} else {
|
||||
self::taskDeliver(new ManticoreSyncTask('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 ManticoreSyncTask('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 ManticoreSyncTask('project_sync', $project->toArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +88,7 @@ class ProjectObserver
|
||||
*/
|
||||
public function forceDeleted(Project $project)
|
||||
{
|
||||
//
|
||||
self::taskDeliver(new ManticoreSyncTask('project_delete', ['project_id' => $project->id]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
69
app/Observers/ProjectTaskContentObserver.php
Normal file
69
app/Observers/ProjectTaskContentObserver.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskContent;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
class ProjectTaskContentObserver extends AbstractObserver
|
||||
{
|
||||
/**
|
||||
* Handle the ProjectTaskContent "created" event.
|
||||
* 任务内容创建时,触发任务索引更新
|
||||
*
|
||||
* @param \App\Models\ProjectTaskContent $content
|
||||
* @return void
|
||||
*/
|
||||
public function created(ProjectTaskContent $content)
|
||||
{
|
||||
$this->syncTaskToManticore($content->task_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ProjectTaskContent "updated" event.
|
||||
* 任务内容更新时,触发任务索引更新
|
||||
*
|
||||
* @param \App\Models\ProjectTaskContent $content
|
||||
* @return void
|
||||
*/
|
||||
public function updated(ProjectTaskContent $content)
|
||||
{
|
||||
// 只有内容变化时才需要更新
|
||||
if ($content->isDirty('content')) {
|
||||
$this->syncTaskToManticore($content->task_id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ProjectTaskContent "deleted" event.
|
||||
* 任务内容删除时,触发任务索引更新
|
||||
*
|
||||
* @param \App\Models\ProjectTaskContent $content
|
||||
* @return void
|
||||
*/
|
||||
public function deleted(ProjectTaskContent $content)
|
||||
{
|
||||
$this->syncTaskToManticore($content->task_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发任务同步到 Manticore
|
||||
*
|
||||
* @param int|null $taskId 任务ID
|
||||
* @return void
|
||||
*/
|
||||
private function syncTaskToManticore(?int $taskId)
|
||||
{
|
||||
if (!$taskId || $taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$task = ProjectTask::find($taskId);
|
||||
if (!$task || $task->archived_at || $task->deleted_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::taskDeliver(new ManticoreSyncTask('task_sync', $task->toArray()));
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
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 ManticoreSyncTask('task_sync', $projectTask->toArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +40,28 @@ class ProjectTaskObserver
|
||||
Deleted::forget('projectTask', $projectTask->id, self::userids($projectTask));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有搜索相关字段变化或权限相关字段变化
|
||||
// visibility 变化会影响 allowed_users 来源
|
||||
// parent_id 变化会影响子任务继承
|
||||
// project_id 变化会影响 visibility=1 的任务权限
|
||||
$searchableFields = ['name', 'desc', 'archived_at', 'project_id', 'visibility', 'parent_id'];
|
||||
$isDirty = false;
|
||||
foreach ($searchableFields as $field) {
|
||||
if ($projectTask->isDirty($field)) {
|
||||
$isDirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isDirty) {
|
||||
if ($projectTask->archived_at) {
|
||||
self::taskDeliver(new ManticoreSyncTask('task_delete', ['task_id' => $projectTask->id]));
|
||||
} else {
|
||||
// 重新同步任务(会重新计算 allowed_users)
|
||||
self::taskDeliver(new ManticoreSyncTask('task_sync', $projectTask->toArray()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +73,7 @@ class ProjectTaskObserver
|
||||
public function deleted(ProjectTask $projectTask)
|
||||
{
|
||||
Deleted::record('projectTask', $projectTask->id, self::userids($projectTask));
|
||||
self::taskDeliver(new ManticoreSyncTask('task_delete', ['task_id' => $projectTask->id]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +85,7 @@ class ProjectTaskObserver
|
||||
public function restored(ProjectTask $projectTask)
|
||||
{
|
||||
Deleted::forget('projectTask', $projectTask->id, self::userids($projectTask));
|
||||
self::taskDeliver(new ManticoreSyncTask('task_sync', $projectTask->toArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +96,7 @@ class ProjectTaskObserver
|
||||
*/
|
||||
public function forceDeleted(ProjectTask $projectTask)
|
||||
{
|
||||
//
|
||||
self::taskDeliver(new ManticoreSyncTask('task_delete', ['task_id' => $projectTask->id]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,8 +5,9 @@ namespace App\Observers;
|
||||
use App\Models\Deleted;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
class ProjectTaskUserObserver
|
||||
class ProjectTaskUserObserver extends AbstractObserver
|
||||
{
|
||||
/**
|
||||
* Handle the ProjectTaskUser "created" event.
|
||||
@@ -20,6 +21,17 @@ class ProjectTaskUserObserver
|
||||
if ($projectTaskUser->task_pid) {
|
||||
Deleted::forget('projectTask', $projectTaskUser->task_pid, $projectTaskUser->userid);
|
||||
}
|
||||
|
||||
// 更新任务权限
|
||||
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
|
||||
'task_id' => $projectTaskUser->task_id,
|
||||
]));
|
||||
// 如果是子任务,也更新父任务
|
||||
if ($projectTaskUser->task_pid) {
|
||||
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
|
||||
'task_id' => $projectTaskUser->task_pid,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +42,18 @@ class ProjectTaskUserObserver
|
||||
*/
|
||||
public function updated(ProjectTaskUser $projectTaskUser)
|
||||
{
|
||||
//
|
||||
// userid 变更时需要更新任务权限(移交场景)
|
||||
if ($projectTaskUser->isDirty('userid')) {
|
||||
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
|
||||
'task_id' => $projectTaskUser->task_id,
|
||||
]));
|
||||
// 如果是子任务,也更新父任务
|
||||
if ($projectTaskUser->task_pid) {
|
||||
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
|
||||
'task_id' => $projectTaskUser->task_pid,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +67,11 @@ class ProjectTaskUserObserver
|
||||
if (!ProjectUser::whereProjectId($projectTaskUser->project_id)->whereUserid($projectTaskUser->userid)->exists()) {
|
||||
Deleted::record('projectTask', $projectTaskUser->task_id, $projectTaskUser->userid);
|
||||
}
|
||||
|
||||
// 更新任务权限
|
||||
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
|
||||
'task_id' => $projectTaskUser->task_id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
78
app/Observers/ProjectTaskVisibilityUserObserver.php
Normal file
78
app/Observers/ProjectTaskVisibilityUserObserver.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
/**
|
||||
* ProjectTaskVisibilityUser 观察者
|
||||
*
|
||||
* 用于处理任务 visibility=3(指定成员可见)时的成员变更同步
|
||||
*/
|
||||
class ProjectTaskVisibilityUserObserver extends AbstractObserver
|
||||
{
|
||||
/**
|
||||
* Handle the ProjectTaskVisibilityUser "created" event.
|
||||
*
|
||||
* @param \App\Models\ProjectTaskVisibilityUser $visibilityUser
|
||||
* @return void
|
||||
*/
|
||||
public function created(ProjectTaskVisibilityUser $visibilityUser)
|
||||
{
|
||||
// 更新任务权限
|
||||
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
|
||||
'task_id' => $visibilityUser->task_id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ProjectTaskVisibilityUser "updated" event.
|
||||
*
|
||||
* @param \App\Models\ProjectTaskVisibilityUser $visibilityUser
|
||||
* @return void
|
||||
*/
|
||||
public function updated(ProjectTaskVisibilityUser $visibilityUser)
|
||||
{
|
||||
// 更新任务权限
|
||||
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
|
||||
'task_id' => $visibilityUser->task_id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ProjectTaskVisibilityUser "deleted" event.
|
||||
*
|
||||
* @param \App\Models\ProjectTaskVisibilityUser $visibilityUser
|
||||
* @return void
|
||||
*/
|
||||
public function deleted(ProjectTaskVisibilityUser $visibilityUser)
|
||||
{
|
||||
// 更新任务权限
|
||||
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
|
||||
'task_id' => $visibilityUser->task_id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ namespace App\Observers;
|
||||
|
||||
use App\Models\Deleted;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
class ProjectUserObserver
|
||||
class ProjectUserObserver extends AbstractObserver
|
||||
{
|
||||
/**
|
||||
* Handle the ProjectUser "created" event.
|
||||
@@ -16,6 +17,15 @@ class ProjectUserObserver
|
||||
public function created(ProjectUser $projectUser)
|
||||
{
|
||||
Deleted::forget('project', $projectUser->project_id, $projectUser->userid);
|
||||
|
||||
// 更新项目权限
|
||||
self::taskDeliver(new ManticoreSyncTask('update_project_allowed_users', [
|
||||
'project_id' => $projectUser->project_id,
|
||||
]));
|
||||
// 异步级联更新该项目下所有 visibility=1 的任务
|
||||
self::taskDeliver(new ManticoreSyncTask('cascade_project_users', [
|
||||
'project_id' => $projectUser->project_id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +36,15 @@ class ProjectUserObserver
|
||||
*/
|
||||
public function updated(ProjectUser $projectUser)
|
||||
{
|
||||
//
|
||||
// userid 变更时需要更新项目权限和级联任务权限(移交场景)
|
||||
if ($projectUser->isDirty('userid')) {
|
||||
self::taskDeliver(new ManticoreSyncTask('update_project_allowed_users', [
|
||||
'project_id' => $projectUser->project_id,
|
||||
]));
|
||||
self::taskDeliver(new ManticoreSyncTask('cascade_project_users', [
|
||||
'project_id' => $projectUser->project_id,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +56,15 @@ class ProjectUserObserver
|
||||
public function deleted(ProjectUser $projectUser)
|
||||
{
|
||||
Deleted::record('project', $projectUser->project_id, $projectUser->userid);
|
||||
|
||||
// 更新项目权限
|
||||
self::taskDeliver(new ManticoreSyncTask('update_project_allowed_users', [
|
||||
'project_id' => $projectUser->project_id,
|
||||
]));
|
||||
// 异步级联更新该项目下所有 visibility=1 的任务
|
||||
self::taskDeliver(new ManticoreSyncTask('cascade_project_users', [
|
||||
'project_id' => $projectUser->project_id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
127
app/Observers/UserObserver.php
Normal file
127
app/Observers/UserObserver.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Apps;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
class UserObserver extends AbstractObserver
|
||||
{
|
||||
/**
|
||||
* 搜索相关字段(Manticore 同步)
|
||||
*/
|
||||
private static array $searchableFields = [
|
||||
'nickname', 'email', 'profession', 'introduction', 'disable_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* 需要监控并触发 user_update hook 的字段
|
||||
*/
|
||||
private static array $hookMonitoredFields = [
|
||||
'email', 'tel', 'nickname', 'profession',
|
||||
'birthday', 'address', 'introduction', 'department'
|
||||
];
|
||||
|
||||
/**
|
||||
* Handle the User "created" event.
|
||||
*
|
||||
* @param \App\Models\User $user
|
||||
* @return void
|
||||
*/
|
||||
public function created(User $user)
|
||||
{
|
||||
// 机器人账号不同步
|
||||
if ($user->bot) {
|
||||
return;
|
||||
}
|
||||
self::taskDeliver(new ManticoreSyncTask('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;
|
||||
}
|
||||
|
||||
// 检查是否有搜索相关字段变化(Manticore 同步)
|
||||
$isDirty = false;
|
||||
foreach (self::$searchableFields as $field) {
|
||||
if ($user->isDirty($field)) {
|
||||
$isDirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isDirty) {
|
||||
// 如果用户被禁用,删除索引;否则更新索引
|
||||
if ($user->disable_at) {
|
||||
self::taskDeliver(new ManticoreSyncTask('user_delete', ['userid' => $user->userid]));
|
||||
} else {
|
||||
self::taskDeliver(new ManticoreSyncTask('user_sync', $user->toArray()));
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 onboard/offboard 场景(disable_at 变化)
|
||||
if ($user->isDirty('disable_at')) {
|
||||
$originalDisableAt = $user->getOriginal('disable_at');
|
||||
$currentDisableAt = $user->disable_at;
|
||||
|
||||
if ($originalDisableAt && !$currentDisableAt) {
|
||||
// disable_at 从有值变为 null → 取消离职 (restore)
|
||||
Apps::dispatchUserHook($user, 'user_onboard', 'restore');
|
||||
} elseif (!$originalDisableAt && $currentDisableAt) {
|
||||
// disable_at 从 null 变为有值 → 离职 (offboarded)
|
||||
Apps::dispatchUserHook($user, 'user_offboard', 'offboarded');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 排除仅 identity 变化的场景
|
||||
if ($user->isDirty('identity')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测监控字段变更,触发 user_update hook
|
||||
$changedFields = [];
|
||||
foreach (self::$hookMonitoredFields as $field) {
|
||||
if ($user->isDirty($field)) {
|
||||
$changedFields[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($changedFields)) {
|
||||
// 判断是用户自己修改还是管理员修改
|
||||
$currentUserid = User::userid();
|
||||
$eventType = ($currentUserid > 0 && $currentUserid === $user->userid)
|
||||
? 'profile_update'
|
||||
: 'admin_update';
|
||||
Apps::dispatchUserHook($user, 'user_update', $eventType, $changedFields);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the User "deleted" event.
|
||||
*
|
||||
* @param \App\Models\User $user
|
||||
* @return void
|
||||
*/
|
||||
public function deleted(User $user)
|
||||
{
|
||||
// Manticore 索引删除
|
||||
self::taskDeliver(new ManticoreSyncTask('user_delete', ['userid' => $user->userid]));
|
||||
|
||||
// 触发 user_offboard (delete) hook
|
||||
if (!$user->bot) {
|
||||
Apps::dispatchUserHook($user, 'user_offboard', 'delete');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Tasks\ZincSearchSyncTask;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
class WebSocketDialogMsgObserver extends AbstractObserver
|
||||
{
|
||||
@@ -15,7 +17,10 @@ class WebSocketDialogMsgObserver extends AbstractObserver
|
||||
*/
|
||||
public function created(WebSocketDialogMsg $webSocketDialogMsg)
|
||||
{
|
||||
self::taskDeliver(new ZincSearchSyncTask('sync', $webSocketDialogMsg->toArray()));
|
||||
// Manticore 同步(仅在安装 Manticore 且符合索引条件时)
|
||||
if (Apps::isInstalled('search') && ManticoreMsg::shouldIndex($webSocketDialogMsg)) {
|
||||
self::taskDeliver(new ManticoreSyncTask('msg_sync', ['msg_id' => $webSocketDialogMsg->id]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +31,10 @@ class WebSocketDialogMsgObserver extends AbstractObserver
|
||||
*/
|
||||
public function updated(WebSocketDialogMsg $webSocketDialogMsg)
|
||||
{
|
||||
self::taskDeliver(new ZincSearchSyncTask('sync', $webSocketDialogMsg->toArray()));
|
||||
// Manticore 同步(更新可能使消息符合或不再符合索引条件,由 sync 方法处理)
|
||||
if (Apps::isInstalled('search')) {
|
||||
self::taskDeliver(new ManticoreSyncTask('msg_sync', ['msg_id' => $webSocketDialogMsg->id]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +45,10 @@ class WebSocketDialogMsgObserver extends AbstractObserver
|
||||
*/
|
||||
public function deleted(WebSocketDialogMsg $webSocketDialogMsg)
|
||||
{
|
||||
self::taskDeliver(new ZincSearchSyncTask('delete', $webSocketDialogMsg->toArray()));
|
||||
// Manticore 删除
|
||||
if (Apps::isInstalled('search')) {
|
||||
self::taskDeliver(new ManticoreSyncTask('msg_delete', ['msg_id' => $webSocketDialogMsg->id]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +70,9 @@ class WebSocketDialogMsgObserver extends AbstractObserver
|
||||
*/
|
||||
public function forceDeleted(WebSocketDialogMsg $webSocketDialogMsg)
|
||||
{
|
||||
//
|
||||
// Manticore 删除
|
||||
if (Apps::isInstalled('search')) {
|
||||
self::taskDeliver(new ManticoreSyncTask('msg_delete', ['msg_id' => $webSocketDialogMsg->id]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace App\Observers;
|
||||
use App\Models\Deleted;
|
||||
use App\Models\UserBot;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use App\Tasks\ZincSearchSyncTask;
|
||||
use App\Module\Apps;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class WebSocketDialogUserObserver extends AbstractObserver
|
||||
@@ -31,7 +32,14 @@ class WebSocketDialogUserObserver extends AbstractObserver
|
||||
}
|
||||
}
|
||||
Deleted::forget('dialog', $webSocketDialogUser->dialog_id, $webSocketDialogUser->userid);
|
||||
self::taskDeliver(new ZincSearchSyncTask('userSync', $webSocketDialogUser->toArray()));
|
||||
|
||||
// Manticore: 更新对话下所有消息的 allowed_users
|
||||
if (Apps::isInstalled('search')) {
|
||||
self::taskDeliver(new ManticoreSyncTask('update_dialog_allowed_users', [
|
||||
'dialog_id' => $webSocketDialogUser->dialog_id
|
||||
]));
|
||||
}
|
||||
|
||||
//
|
||||
$dialog = $webSocketDialogUser->webSocketDialog;
|
||||
if ($dialog) {
|
||||
@@ -47,7 +55,7 @@ class WebSocketDialogUserObserver extends AbstractObserver
|
||||
*/
|
||||
public function updated(WebSocketDialogUser $webSocketDialogUser)
|
||||
{
|
||||
self::taskDeliver(new ZincSearchSyncTask('userSync', $webSocketDialogUser->toArray()));
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +67,14 @@ class WebSocketDialogUserObserver extends AbstractObserver
|
||||
public function deleted(WebSocketDialogUser $webSocketDialogUser)
|
||||
{
|
||||
Deleted::record('dialog', $webSocketDialogUser->dialog_id, $webSocketDialogUser->userid);
|
||||
self::taskDeliver(new ZincSearchSyncTask('deleteUser', $webSocketDialogUser->toArray()));
|
||||
|
||||
// Manticore: 更新对话下所有消息的 allowed_users
|
||||
if (Apps::isInstalled('search')) {
|
||||
self::taskDeliver(new ManticoreSyncTask('update_dialog_allowed_users', [
|
||||
'dialog_id' => $webSocketDialogUser->dialog_id
|
||||
]));
|
||||
}
|
||||
|
||||
//
|
||||
$dialog = $webSocketDialogUser->webSocketDialog;
|
||||
if ($dialog) {
|
||||
|
||||
@@ -2,17 +2,31 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\FileUser;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskContent;
|
||||
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;
|
||||
use App\Observers\FileObserver;
|
||||
use App\Observers\FileUserObserver;
|
||||
use App\Observers\ProjectObserver;
|
||||
use App\Observers\ProjectTaskContentObserver;
|
||||
use App\Observers\ProjectTaskObserver;
|
||||
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;
|
||||
@@ -40,10 +54,17 @@ class EventServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
File::observe(FileObserver::class);
|
||||
FileUser::observe(FileUserObserver::class);
|
||||
Project::observe(ProjectObserver::class);
|
||||
ProjectTask::observe(ProjectTaskObserver::class);
|
||||
ProjectTaskContent::observe(ProjectTaskContentObserver::class);
|
||||
ProjectTaskUser::observe(ProjectTaskUserObserver::class);
|
||||
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);
|
||||
|
||||
134
app/Tasks/AiTaskAnalyzeTask.php
Normal file
134
app/Tasks/AiTaskAnalyzeTask.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tasks;
|
||||
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskAiEvent;
|
||||
use App\Module\AiTaskSuggestion;
|
||||
|
||||
/**
|
||||
* AI 任务分析异步任务
|
||||
* 处理单个任务的所有 AI 事件
|
||||
*/
|
||||
class AiTaskAnalyzeTask extends AbstractTask
|
||||
{
|
||||
protected int $taskId;
|
||||
|
||||
public function __construct(int $taskId)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->taskId = $taskId;
|
||||
}
|
||||
|
||||
public function start()
|
||||
{
|
||||
$task = ProjectTask::with('project')->find($this->taskId);
|
||||
if (!$task || $task->deleted_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取该任务的所有待处理事件
|
||||
$events = ProjectTaskAiEvent::where('task_id', $this->taskId)
|
||||
->whereIn('status', [
|
||||
ProjectTaskAiEvent::STATUS_PENDING,
|
||||
ProjectTaskAiEvent::STATUS_FAILED,
|
||||
])
|
||||
->get()
|
||||
->keyBy('event_type');
|
||||
|
||||
$suggestions = [];
|
||||
|
||||
// 遍历所有事件类型
|
||||
foreach (ProjectTaskAiEvent::getEventTypes() as $eventType) {
|
||||
$event = $events->get($eventType);
|
||||
|
||||
// 如果没有记录,跳过
|
||||
if (!$event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果是失败状态但不能重试,跳过
|
||||
if ($event->status === ProjectTaskAiEvent::STATUS_FAILED && !$event->canRetry()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用原子操作标记为处理中(防止并发重复处理)
|
||||
$updated = ProjectTaskAiEvent::where('id', $event->id)
|
||||
->whereIn('status', [ProjectTaskAiEvent::STATUS_PENDING, ProjectTaskAiEvent::STATUS_FAILED])
|
||||
->update(['status' => ProjectTaskAiEvent::STATUS_PROCESSING]);
|
||||
|
||||
if (!$updated) {
|
||||
// 已被其他进程处理
|
||||
continue;
|
||||
}
|
||||
$event->status = ProjectTaskAiEvent::STATUS_PROCESSING;
|
||||
|
||||
try {
|
||||
// 检查是否满足执行条件
|
||||
$shouldExecute = AiTaskSuggestion::shouldExecute($task, $eventType);
|
||||
|
||||
if (!$shouldExecute) {
|
||||
$event->markSkipped('不满足执行条件');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 执行对应的分析
|
||||
$result = $this->executeAnalysis($task, $eventType);
|
||||
|
||||
if ($result === null) {
|
||||
$event->markSkipped('未生成有效建议');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 收集建议
|
||||
$suggestions[] = $result;
|
||||
$event->markCompleted($result);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$event->markFailed($e->getMessage());
|
||||
\Log::error("AiTaskAnalyzeTask error: task={$this->taskId}, type={$eventType}, error={$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有建议,发送消息
|
||||
if (!empty($suggestions)) {
|
||||
$msgId = AiTaskSuggestion::sendSuggestionMessage($task, $suggestions);
|
||||
|
||||
// 更新所有事件的 msg_id
|
||||
if ($msgId) {
|
||||
ProjectTaskAiEvent::where('task_id', $this->taskId)
|
||||
->where('status', ProjectTaskAiEvent::STATUS_COMPLETED)
|
||||
->update(['msg_id' => $msgId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行具体的分析
|
||||
* @param ProjectTask $task 任务对象
|
||||
* @param string $eventType 事件类型
|
||||
*/
|
||||
private function executeAnalysis(ProjectTask $task, string $eventType): ?array
|
||||
{
|
||||
switch ($eventType) {
|
||||
case ProjectTaskAiEvent::EVENT_DESCRIPTION:
|
||||
return AiTaskSuggestion::generateDescription($task);
|
||||
|
||||
case ProjectTaskAiEvent::EVENT_SUBTASKS:
|
||||
return AiTaskSuggestion::generateSubtasks($task);
|
||||
|
||||
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
|
||||
return AiTaskSuggestion::generateAssignee($task);
|
||||
|
||||
case ProjectTaskAiEvent::EVENT_SIMILAR:
|
||||
return AiTaskSuggestion::findSimilarTasks($task);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function end()
|
||||
{
|
||||
}
|
||||
}
|
||||
125
app/Tasks/AiTaskLoopTask.php
Normal file
125
app/Tasks/AiTaskLoopTask.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tasks;
|
||||
|
||||
use App\Module\Apps;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskAiEvent;
|
||||
use App\Module\Base;
|
||||
use Carbon\Carbon;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
|
||||
/**
|
||||
* AI 任务建议定时任务
|
||||
* 扫描新建任务并投递分析任务
|
||||
*/
|
||||
class AiTaskLoopTask extends AbstractTask
|
||||
{
|
||||
/**
|
||||
* 单次处理任务数量上限
|
||||
*/
|
||||
const BATCH_SIZE = 5;
|
||||
|
||||
/**
|
||||
* 任务创建后多久开始分析(秒)
|
||||
*/
|
||||
const DELAY_SECONDS = 10;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function start()
|
||||
{
|
||||
// 检查 AI 插件是否安装
|
||||
if (!Apps::isInstalled('ai')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查系统级 AI 自动分析开关
|
||||
if (Base::settingFind('system', 'task_ai_auto_analyze', 'open') === 'close') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询待处理的任务
|
||||
$tasks = $this->findPendingTasks();
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
// 检查项目级 AI 自动分析开关
|
||||
if ($task->project && $task->project->ai_auto_analyze === 'close') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 为任务创建事件记录
|
||||
$this->createEventRecords($task);
|
||||
|
||||
// 投递异步分析任务
|
||||
Task::deliver(new AiTaskAnalyzeTask($task->id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找待处理的任务
|
||||
*/
|
||||
private function findPendingTasks(): \Illuminate\Support\Collection
|
||||
{
|
||||
$delayTime = Carbon::now()->subSeconds(self::DELAY_SECONDS);
|
||||
|
||||
// 子查询:已经有 AI 事件记录的任务
|
||||
$processedTaskIds = ProjectTaskAiEvent::select('task_id')
|
||||
->distinct()
|
||||
->pluck('task_id');
|
||||
|
||||
// 查询新建任务(未处理过的)
|
||||
$newTasks = ProjectTask::with('project')
|
||||
->where('parent_id', 0) // 只处理主任务
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('archived_at')
|
||||
->where('created_at', '<=', $delayTime) // 创建超过延迟时间
|
||||
->where('created_at', '>=', Carbon::now()->subDays(1)) // 只处理1天内的
|
||||
->whereNotIn('id', $processedTaskIds)
|
||||
->orderBy('created_at', 'asc')
|
||||
->take(self::BATCH_SIZE)
|
||||
->get();
|
||||
|
||||
// 查询需要重试的任务(优先处理较早失败的)
|
||||
$retryTaskIds = ProjectTaskAiEvent::where('status', ProjectTaskAiEvent::STATUS_FAILED)
|
||||
->where('retry_count', '<', ProjectTaskAiEvent::MAX_RETRY)
|
||||
->select('task_id')
|
||||
->distinct()
|
||||
->orderBy('updated_at', 'asc')
|
||||
->take(self::BATCH_SIZE - $newTasks->count())
|
||||
->pluck('task_id');
|
||||
|
||||
$retryTasks = ProjectTask::with('project')
|
||||
->whereIn('id', $retryTaskIds)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
return $newTasks->merge($retryTasks)->take(self::BATCH_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为任务创建事件记录
|
||||
*/
|
||||
private function createEventRecords(ProjectTask $task): void
|
||||
{
|
||||
foreach (ProjectTaskAiEvent::getEventTypes() as $eventType) {
|
||||
ProjectTaskAiEvent::firstOrCreate(
|
||||
[
|
||||
'task_id' => $task->id,
|
||||
'event_type' => $eventType,
|
||||
],
|
||||
[
|
||||
'status' => ProjectTaskAiEvent::STATUS_PENDING,
|
||||
'retry_count' => 0,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function end()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Ihttp;
|
||||
use App\Module\TextExtractor;
|
||||
use App\Module\PromptPlaceholder;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use DB;
|
||||
@@ -489,10 +490,6 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
if ($dialog->session_id) {
|
||||
$extras['context_key'] = 'session_' . $dialog->session_id;
|
||||
}
|
||||
// 设置文心一言的API密钥
|
||||
if ($type === 'wenxin') {
|
||||
$extras['api_key'] .= ':' . $setting['wenxin_secret'];
|
||||
}
|
||||
// 群聊清理上下文(群聊不使用上下文)
|
||||
if ($dialog->type === 'group') {
|
||||
$extras['before_clear'] = 1;
|
||||
@@ -675,6 +672,9 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
$prompt[] = implode("\n", $contextLines);
|
||||
}
|
||||
|
||||
// 4. 条件性提示块(用户上下文 + 格式指南)
|
||||
$prompt[] = PromptPlaceholder::buildOptionalPrompts($userid, $dialog);
|
||||
|
||||
$extras['system_message'] = implode("\n----\n", array_filter($prompt));
|
||||
}
|
||||
|
||||
|
||||
@@ -65,16 +65,10 @@ class LoopTask extends AbstractTask
|
||||
$task->end_at = $task->start_at->clone()->addSeconds($diffSecond);
|
||||
}
|
||||
// 处理子任务
|
||||
$subTasks = ProjectTask::whereParentId($item->id)->get();
|
||||
if (!$subTasks->isEmpty()) {
|
||||
foreach ($subTasks as $subTask) {
|
||||
$newSubTask = $subTask->copyTask();
|
||||
$newSubTask->parent_id = $task->id;
|
||||
$newSubTask->start_at = $task->start_at;
|
||||
$newSubTask->end_at = $task->end_at;
|
||||
$newSubTask->save();
|
||||
}
|
||||
}
|
||||
$item->copySubTasks($task, [
|
||||
'reset_complete' => true,
|
||||
'sync_time' => true,
|
||||
]);
|
||||
//
|
||||
$task->refreshLoop(true);
|
||||
$task->addLog("创建任务来自周期任务ID:{$item->id}", [], $task->userid);
|
||||
|
||||
283
app/Tasks/ManticoreSyncTask.php
Normal file
283
app/Tasks/ManticoreSyncTask.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tasks;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\User;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreBase;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use App\Module\Manticore\ManticoreUser;
|
||||
use App\Module\Manticore\ManticoreProject;
|
||||
use App\Module\Manticore\ManticoreTask;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* 通用 Manticore Search 同步任务(MVA 权限方案)
|
||||
*
|
||||
* 支持文件、用户、项目、任务的同步操作
|
||||
* 使用 MVA (Multi-Value Attribute) 内联权限过滤
|
||||
*/
|
||||
class ManticoreSyncTask extends AbstractTask
|
||||
{
|
||||
private $action;
|
||||
|
||||
private $data;
|
||||
|
||||
public function __construct($action = null, $data = null)
|
||||
{
|
||||
parent::__construct(...func_get_args());
|
||||
$this->action = $action;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务动作类型(用于去重)
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getAction(): ?string
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据ID(用于去重)
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function getDataId(): ?int
|
||||
{
|
||||
if (!is_array($this->data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据不同的 action 类型提取对应的 ID
|
||||
return $this->data['id']
|
||||
?? $this->data['userid']
|
||||
?? $this->data['file_id']
|
||||
?? $this->data['project_id']
|
||||
?? $this->data['task_id']
|
||||
?? $this->data['msg_id']
|
||||
?? $this->data['dialog_id']
|
||||
?? null;
|
||||
}
|
||||
|
||||
public function start()
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($this->action) {
|
||||
// ==============================
|
||||
// 文件同步动作
|
||||
// ==============================
|
||||
case 'file_sync':
|
||||
$file = File::find($this->data['id'] ?? 0);
|
||||
if ($file) {
|
||||
ManticoreFile::sync($file);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file_delete':
|
||||
$fileId = $this->data['id'] ?? 0;
|
||||
if ($fileId > 0) {
|
||||
ManticoreFile::delete($fileId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file_pshare_update':
|
||||
$fileIds = $this->data['file_ids'] ?? [];
|
||||
$pshare = $this->data['pshare'] ?? 0;
|
||||
if (!empty($fileIds)) {
|
||||
ManticoreBase::batchUpdatePshare($fileIds, $pshare);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update_file_allowed_users':
|
||||
// 更新文件权限
|
||||
$fileId = $this->data['file_id'] ?? 0;
|
||||
if ($fileId > 0) {
|
||||
ManticoreFile::updateAllowedUsers($fileId);
|
||||
}
|
||||
break;
|
||||
|
||||
// ==============================
|
||||
// 用户同步动作
|
||||
// ==============================
|
||||
case 'user_sync':
|
||||
$user = User::find($this->data['userid'] ?? 0);
|
||||
if ($user) {
|
||||
ManticoreUser::sync($user);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user_delete':
|
||||
$userid = $this->data['userid'] ?? 0;
|
||||
if ($userid > 0) {
|
||||
ManticoreUser::delete($userid);
|
||||
}
|
||||
break;
|
||||
|
||||
// ==============================
|
||||
// 项目同步动作
|
||||
// ==============================
|
||||
case 'project_sync':
|
||||
$project = Project::find($this->data['id'] ?? 0);
|
||||
if ($project) {
|
||||
ManticoreProject::sync($project);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'project_delete':
|
||||
$projectId = $this->data['project_id'] ?? 0;
|
||||
if ($projectId > 0) {
|
||||
ManticoreProject::delete($projectId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update_project_allowed_users':
|
||||
// 更新项目权限
|
||||
$projectId = $this->data['project_id'] ?? 0;
|
||||
if ($projectId > 0) {
|
||||
ManticoreProject::updateAllowedUsers($projectId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cascade_project_users':
|
||||
// 项目成员变更时,级联更新该项目下所有 visibility=1 的任务
|
||||
// 异步执行,避免阻塞
|
||||
$projectId = $this->data['project_id'] ?? 0;
|
||||
if ($projectId > 0) {
|
||||
ManticoreTask::cascadeUpdateByProject($projectId);
|
||||
}
|
||||
break;
|
||||
|
||||
// ==============================
|
||||
// 任务同步动作
|
||||
// ==============================
|
||||
case 'task_sync':
|
||||
$task = ProjectTask::find($this->data['id'] ?? 0);
|
||||
if ($task) {
|
||||
ManticoreTask::sync($task);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task_delete':
|
||||
$taskId = $this->data['task_id'] ?? 0;
|
||||
if ($taskId > 0) {
|
||||
ManticoreTask::delete($taskId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update_task_allowed_users':
|
||||
// 更新任务权限
|
||||
$taskId = $this->data['task_id'] ?? 0;
|
||||
if ($taskId > 0) {
|
||||
ManticoreTask::updateAllowedUsers($taskId);
|
||||
// 级联更新子任务
|
||||
ManticoreTask::cascadeToChildren($taskId);
|
||||
}
|
||||
break;
|
||||
|
||||
// ==============================
|
||||
// 消息同步动作
|
||||
// ==============================
|
||||
case 'msg_sync':
|
||||
$msg = WebSocketDialogMsg::find($this->data['msg_id'] ?? 0);
|
||||
if ($msg) {
|
||||
ManticoreMsg::sync($msg);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'msg_delete':
|
||||
$msgId = $this->data['msg_id'] ?? 0;
|
||||
if ($msgId > 0) {
|
||||
ManticoreMsg::delete($msgId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update_dialog_allowed_users':
|
||||
// 更新对话消息权限
|
||||
$dialogId = $this->data['dialog_id'] ?? 0;
|
||||
if ($dialogId > 0) {
|
||||
ManticoreMsg::updateDialogAllowedUsers($dialogId);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 增量更新
|
||||
$this->incrementalUpdate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量更新(定时执行 - 兜底机制)
|
||||
*
|
||||
* 命令本身会持续处理直到完成,定时器只是确保命令在运行
|
||||
* 如果命令正在运行(有锁),则跳过本次触发
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function incrementalUpdate()
|
||||
{
|
||||
// 兜底触发:每 2 分钟检查一次,如果命令没在运行则启动
|
||||
$time = intval(Cache::get("ManticoreSyncTask:CheckTime"));
|
||||
if (time() - $time < 2 * 60) {
|
||||
return;
|
||||
}
|
||||
Cache::put("ManticoreSyncTask:CheckTime", time(), Carbon::now()->addMinutes(5));
|
||||
|
||||
// 执行增量全文索引同步
|
||||
$this->runIncrementalSync();
|
||||
|
||||
// 执行向量生成
|
||||
$this->runVectorGeneration();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行增量全文索引同步(兜底触发)
|
||||
*
|
||||
* 命令内部有锁机制,如果已在运行会自动跳过
|
||||
* 命令会持续处理直到无新数据,然后自动退出
|
||||
*/
|
||||
private function runIncrementalSync(): void
|
||||
{
|
||||
// 启动各类型的增量同步命令
|
||||
@shell_exec("php /var/www/artisan manticore:sync-files --i 2>&1 &");
|
||||
@shell_exec("php /var/www/artisan manticore:sync-users --i 2>&1 &");
|
||||
@shell_exec("php /var/www/artisan manticore:sync-projects --i 2>&1 &");
|
||||
@shell_exec("php /var/www/artisan manticore:sync-tasks --i 2>&1 &");
|
||||
@shell_exec("php /var/www/artisan manticore:sync-msgs --i 2>&1 &");
|
||||
|
||||
// 启动失败重试命令
|
||||
@shell_exec("php /var/www/artisan manticore:retry-failures 2>&1 &");
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行向量生成(兜底触发)
|
||||
*
|
||||
* 命令内部有锁机制,如果已在运行会自动跳过
|
||||
* 命令会持续处理直到无待处理数据,然后自动退出
|
||||
*/
|
||||
private function runVectorGeneration(): void
|
||||
{
|
||||
if (!Apps::isInstalled("ai")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 启动向量生成命令
|
||||
@shell_exec("php /var/www/artisan manticore:generate-vectors --type=all --batch=50 2>&1 &");
|
||||
}
|
||||
|
||||
public function end()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class PushUmengMsg extends AbstractTask
|
||||
}
|
||||
|
||||
// 消息ID
|
||||
$msgId = isset($this->array['id']) ? intval($this->array['id']) : 0;
|
||||
$msgId = isset($this->array['extra']['msg_id']) ? intval($this->array['extra']['msg_id']) : 0;
|
||||
|
||||
// 处理用户列表
|
||||
$userids = is_array($this->userid) ? $this->userid : [$this->userid];
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tasks;
|
||||
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\ZincSearch\ZincSearchDialogMsg;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* 同步聊天数据到ZincSearch
|
||||
*/
|
||||
class ZincSearchSyncTask extends AbstractTask
|
||||
{
|
||||
private $action;
|
||||
|
||||
private $data;
|
||||
|
||||
public function __construct($action = null, $data = null)
|
||||
{
|
||||
parent::__construct(...func_get_args());
|
||||
$this->action = $action;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function start()
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
// 如果没有安装搜索模块,则不执行
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($this->action) {
|
||||
case 'sync':
|
||||
// 同步消息数据
|
||||
ZincSearchDialogMsg::sync(WebSocketDialogMsg::fillInstance($this->data));
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
// 删除消息数据
|
||||
ZincSearchDialogMsg::delete(WebSocketDialogMsg::fillInstance($this->data));
|
||||
break;
|
||||
|
||||
case 'userSync':
|
||||
// 同步用户数据
|
||||
ZincSearchDialogMsg::userSync(WebSocketDialogUser::fillInstance($this->data));
|
||||
break;
|
||||
|
||||
case 'deleteUser':
|
||||
// 删除用户数据
|
||||
ZincSearchDialogMsg::delete(WebSocketDialogUser::fillInstance($this->data));
|
||||
break;
|
||||
|
||||
default:
|
||||
// 增量更新
|
||||
$this->incrementalUpdate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量更新
|
||||
* @return void
|
||||
*/
|
||||
private function incrementalUpdate()
|
||||
{
|
||||
// 120分钟执行一次
|
||||
$time = intval(Cache::get("ZincSearchSyncTask:Time"));
|
||||
if (time() - $time < 120 * 60) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行开始,120分钟后缓存标记失效
|
||||
Cache::put("ZincSearchSyncTask:Time", time(), Carbon::now()->addMinutes(120));
|
||||
|
||||
// 开始执行同步
|
||||
@shell_exec("php /var/www/artisan zinc:sync-user-msg --i");
|
||||
|
||||
// 执行完成,5分钟后缓存标记失效(5分钟任务可重复执行)
|
||||
Cache::put("ZincSearchSyncTask:Time", time(), Carbon::now()->addMinutes(5));
|
||||
}
|
||||
|
||||
public function end()
|
||||
{
|
||||
}
|
||||
}
|
||||
12
bin/version.js
vendored
12
bin/version.js
vendored
File diff suppressed because one or more lines are too long
81
cmd
81
cmd
@@ -19,6 +19,12 @@ WORK_DIR="$(pwd)"
|
||||
INPUT_ARGS=$@
|
||||
COMPOSE="docker-compose"
|
||||
|
||||
# TTY 参数检测
|
||||
TTY_FLAG=""
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
TTY_FLAG="-it"
|
||||
fi
|
||||
|
||||
# 缓存执行
|
||||
if [ -z "$CACHED_EXECUTION" ] && [ "$1" == "update" ]; then
|
||||
if ! cat "$0" > ._cmd 2>/dev/null; then
|
||||
@@ -88,7 +94,7 @@ rand_string() {
|
||||
if [[ `uname` == 'Linux' ]]; then
|
||||
echo "$(date +%s%N | md5sum | cut -c 1-${lan})"
|
||||
else
|
||||
echo "$(docker run -it --rm nginx:alpine sh -c "date +%s%N | md5sum | cut -c 1-${lan}")"
|
||||
echo "$(docker run $TTY_FLAG --rm nginx:alpine sh -c "date +%s%N | md5sum | cut -c 1-${lan}")"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -241,7 +247,7 @@ container_exec() {
|
||||
error "没有找到 ${container} 容器!"
|
||||
exit 1
|
||||
fi
|
||||
docker exec -it "$name" /bin/sh -c "$cmd"
|
||||
docker exec $TTY_FLAG "$name" /bin/sh -c "$cmd"
|
||||
}
|
||||
|
||||
# 备份数据库、还原数据库
|
||||
@@ -298,8 +304,22 @@ mysql_snapshot() {
|
||||
remove_by_network() {
|
||||
local app_id=$(env_get APP_ID)
|
||||
local network_name="dootask-networks-${app_id}"
|
||||
for container_id in $(docker ps -q --filter network="$network_name"); do
|
||||
docker rm -f "$container_id" 1>/dev/null
|
||||
|
||||
# 批量删除所有状态的容器(包括已停止的)
|
||||
local container_ids=$(docker ps -aq --filter network="$network_name")
|
||||
if [ -n "$container_ids" ]; then
|
||||
echo "$container_ids" | xargs -r docker rm -f 1>/dev/null
|
||||
fi
|
||||
|
||||
# 等待网络完全清空(最多等待10秒)
|
||||
local retry=0
|
||||
while [ $retry -lt 10 ]; do
|
||||
local count=$(docker network inspect "$network_name" --format '{{len .Containers}}' 2>/dev/null | tr -d '[:space:]')
|
||||
if [ -z "$count" ] || [ "$count" = "0" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
((retry++))
|
||||
done
|
||||
}
|
||||
|
||||
@@ -341,11 +361,11 @@ https_auto() {
|
||||
if [[ "$restart_nginx" == "y" ]]; then
|
||||
$COMPOSE up -d
|
||||
fi
|
||||
docker run -it --rm -v $(pwd):/work nginx:alpine sh /work/bin/https install
|
||||
docker run $TTY_FLAG --rm -v $(pwd):/work nginx:alpine sh /work/bin/https install
|
||||
if [[ 0 -eq $? ]]; then
|
||||
container_exec nginx "nginx -s reload"
|
||||
fi
|
||||
new_job="* 6 * * * docker run -it --rm -v $(pwd):/work nginx:alpine sh /work/bin/https renew"
|
||||
new_job="* 6 * * * docker run --rm -v $(pwd):/work nginx:alpine sh /work/bin/https renew"
|
||||
current_crontab=$(crontab -l 2>/dev/null)
|
||||
if ! echo "$current_crontab" | grep -v "https renew"; then
|
||||
echo "任务已存在,无需添加。"
|
||||
@@ -371,12 +391,13 @@ env_set() {
|
||||
local val=$2
|
||||
local exist=`cat ${WORK_DIR}/.env | grep "^$key="`
|
||||
if [ -z "$exist" ]; then
|
||||
echo "" >> $WORK_DIR/.env
|
||||
echo "$key=$val" >> $WORK_DIR/.env
|
||||
else
|
||||
if [[ `uname` == 'Linux' ]]; then
|
||||
sed -i "/^${key}=/c\\${key}=${val}" ${WORK_DIR}/.env
|
||||
else
|
||||
docker run -it --rm -v ${WORK_DIR}:/www nginx:alpine sh -c "sed -i "/^${key}=/c\\${key}=${val}" /www/.env"
|
||||
docker run $TTY_FLAG --rm -v ${WORK_DIR}:/www nginx:alpine sh -c "sed -i "/^${key}=/c\\${key}=${val}" /www/.env"
|
||||
fi
|
||||
if [ $? -ne 0 ]; then
|
||||
error "设置env参数失败!"
|
||||
@@ -402,7 +423,7 @@ env_init() {
|
||||
if [ -z "$(env_get UPDATE_TIME)" ]; then
|
||||
env_set DB_HOST "mariadb"
|
||||
env_set REDIS_HOST "redis"
|
||||
docker run -it --rm -v ${WORK_DIR}:/www nginx:alpine sh -c "sed -i 's|/etc/nginx/conf.d/site/|/var/www/docker/nginx/site/|g' /www/docker/nginx/site/*.conf &> /dev/null"
|
||||
docker run $TTY_FLAG --rm -v ${WORK_DIR}:/www nginx:alpine sh -c "sed -i 's|/etc/nginx/conf.d/site/|/var/www/docker/nginx/site/|g' /www/docker/nginx/site/*.conf &> /dev/null"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -481,10 +502,36 @@ DooTask 管理脚本
|
||||
EOF
|
||||
}
|
||||
|
||||
# 检测APP_ID是否与其他实例冲突
|
||||
check_instance() {
|
||||
local app_id=$(env_get APP_ID)
|
||||
local container_name="dootask-php-${app_id}"
|
||||
local mount_path=$(docker inspect "$container_name" --format '{{range .Mounts}}{{if eq .Destination "/var/www"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
|
||||
if [[ -n "$mount_path" ]] && [[ "$mount_path" != "$WORK_DIR" ]]; then
|
||||
error "APP_ID(${app_id})已被其他实例使用:${mount_path}"
|
||||
error "请先清空 .env 中的 APP_ID 和 APP_IPPR 再重新安装"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检测端口是否被占用
|
||||
# 参数1: 端口号, 参数2: 当前端口号(可选,相同则跳过检测)
|
||||
check_port() {
|
||||
local port=$1
|
||||
local current_port=$2
|
||||
if [[ "$port" -gt 0 ]] && [[ "$port" != "$current_port" ]]; then
|
||||
if ! docker run --rm -p "${port}:80" --entrypoint true nginx:alpine 2>/dev/null; then
|
||||
error "端口 ${port} 已被占用,请指定其他端口"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 安装函数
|
||||
handle_install() {
|
||||
check_sudo
|
||||
|
||||
check_instance
|
||||
|
||||
local relock=$(arg_get relock)
|
||||
local port=$(arg_get port)
|
||||
|
||||
@@ -541,8 +588,17 @@ handle_install() {
|
||||
done
|
||||
|
||||
# 设置端口
|
||||
local old_port=$(env_get APP_PORT)
|
||||
[[ "$port" -gt 0 ]] && env_set APP_PORT "$port"
|
||||
|
||||
# 检测端口占用(首次安装或端口变更时)
|
||||
local new_port=$(env_get APP_PORT)
|
||||
if [ -z "$(docker_name nginx)" ] || [[ "$new_port" != "$old_port" ]]; then
|
||||
check_port "$new_port"
|
||||
local ssl_port=$(env_get APP_SSL_PORT)
|
||||
check_port "$ssl_port"
|
||||
fi
|
||||
|
||||
# 启动PHP容器
|
||||
$COMPOSE up php -d
|
||||
|
||||
@@ -743,6 +799,7 @@ case "$1" in
|
||||
;;
|
||||
"port")
|
||||
shift 1
|
||||
check_port "$1" "$(env_get APP_PORT)"
|
||||
env_set APP_PORT "$1"
|
||||
$COMPOSE up -d
|
||||
success "修改成功"
|
||||
@@ -791,7 +848,7 @@ case "$1" in
|
||||
elif [[ "$cli" == "dev" ]]; then
|
||||
por="-p 8880:8880"
|
||||
fi
|
||||
docker run -it --rm -v ${WORK_DIR}/resources/mobile:/work -w /work ${por} kuaifan/eeui-cli:0.0.1 eeui ${cli}
|
||||
docker run $TTY_FLAG --rm -v ${WORK_DIR}/resources/mobile:/work -w /work ${por} kuaifan/eeui-cli:0.0.1 eeui ${cli}
|
||||
;;
|
||||
"npm")
|
||||
shift 1
|
||||
@@ -799,12 +856,12 @@ case "$1" in
|
||||
pushd electron || exit
|
||||
npm "$@"
|
||||
popd || exit
|
||||
docker run --rm -it -v ${WORK_DIR}/resources/mobile:/work -w /work --entrypoint=/bin/bash node:16 -c "npm $@"
|
||||
docker run $TTY_FLAG --rm -v ${WORK_DIR}/resources/mobile:/work -w /work --entrypoint=/bin/bash node:16 -c "npm $@"
|
||||
;;
|
||||
"doc")
|
||||
shift 1
|
||||
container_exec php "php app/Http/Controllers/Api/apidoc.php"
|
||||
docker run -it --rm -v ${WORK_DIR}:/home/node/apidoc kuaifan/apidoc -i app/Http/Controllers/Api -o public/docs
|
||||
docker run $TTY_FLAG --rm -v ${WORK_DIR}:/home/node/apidoc kuaifan/apidoc -i app/Http/Controllers/Api -o public/docs
|
||||
container_exec php "php app/Http/Controllers/Api/apidoc.php restore"
|
||||
;;
|
||||
"debug")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Module\Base;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ReverseDoneUseridsInTodoDoneMsgs extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$this->reverseDoneUserids('2025-12-19 00:00:00');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
$this->reverseDoneUserids('2025-12-19 00:00:00');
|
||||
}
|
||||
|
||||
private function reverseDoneUserids(string $after)
|
||||
{
|
||||
DB::table('web_socket_dialog_msgs')
|
||||
->select(['id', 'msg'])
|
||||
->where('type', 'todo')
|
||||
->where('created_at', '>', $after)
|
||||
->orderBy('id')
|
||||
->chunkById(200, function ($rows) {
|
||||
foreach ($rows as $row) {
|
||||
$msg = Base::json2array($row->msg);
|
||||
if (empty($msg) || !is_array($msg)) {
|
||||
continue;
|
||||
}
|
||||
if (($msg['action'] ?? '') !== 'done') {
|
||||
continue;
|
||||
}
|
||||
$data = $msg['data'] ?? null;
|
||||
if (!is_array($data)) {
|
||||
continue;
|
||||
}
|
||||
$doneUserids = $data['done_userids'] ?? null;
|
||||
if (!is_array($doneUserids) || count($doneUserids) < 2) {
|
||||
continue;
|
||||
}
|
||||
$data['done_userids'] = array_reverse($doneUserids);
|
||||
$msg['data'] = $data;
|
||||
DB::table('web_socket_dialog_msgs')
|
||||
->where('id', $row->id)
|
||||
->update(['msg' => Base::array2json($msg)]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddIndexesToWebSocketDialogMsgs extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('web_socket_dialog_msgs', function (Blueprint $table) {
|
||||
$table->index(['dialog_id', 'deleted_at', 'id']);
|
||||
$table->index(['reply_id', 'deleted_at']);
|
||||
});
|
||||
|
||||
Schema::table('web_socket_dialog_msg_reads', function (Blueprint $table) {
|
||||
$table->index(['userid', 'silence', 'read_at'], 'idx_ws_msg_reads_userid_silence_read_at');
|
||||
$table->index(['dialog_id', 'userid', 'mention', 'read_at'], 'idx_ws_msg_reads_dialog_user_mention_read_at');
|
||||
});
|
||||
|
||||
Schema::table('user_checkin_records', function (Blueprint $table) {
|
||||
$table->index(['userid', 'mac', 'date'], 'idx_user_checkin_records_userid_mac_date');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// No-op: do not drop indexes automatically.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('project_users', function (Blueprint $table) {
|
||||
$table->index(['userid', 'project_id']);
|
||||
});
|
||||
|
||||
Schema::table('project_tasks', function (Blueprint $table) {
|
||||
$table->index(['project_id', 'archived_at', 'deleted_at', 'id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// No-op: do not drop indexes automatically.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateManticoreSyncFailuresTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('manticore_sync_failures')) {
|
||||
return;
|
||||
}
|
||||
Schema::create('manticore_sync_failures', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('data_type', 20)->comment('数据类型: msg/file/task/project/user');
|
||||
$table->bigInteger('data_id')->comment('数据ID');
|
||||
$table->string('action', 20)->comment('操作类型: sync/delete');
|
||||
$table->string('error_message', 500)->nullable()->comment('错误信息');
|
||||
$table->integer('retry_count')->default(0)->comment('重试次数');
|
||||
$table->timestamp('last_retry_at')->nullable()->comment('最后重试时间');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['data_type', 'data_id', 'action'], 'uk_type_id_action');
|
||||
$table->index(['last_retry_at', 'retry_count'], 'idx_retry');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('manticore_sync_failures');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateProjectTaskAiEventsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('project_task_ai_events')) {
|
||||
return;
|
||||
}
|
||||
Schema::create('project_task_ai_events', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('task_id')->comment('任务ID');
|
||||
$table->string('event_type', 50)->comment('事件类型: description/subtasks/assignee/similar');
|
||||
$table->string('status', 20)->default('pending')->comment('状态: pending/processing/completed/failed/skipped');
|
||||
$table->tinyInteger('retry_count')->unsigned()->default(0)->comment('重试次数');
|
||||
$table->json('result')->nullable()->comment('执行结果');
|
||||
$table->text('error')->nullable()->comment('错误信息');
|
||||
$table->bigInteger('msg_id')->nullable()->default(0)->comment('消息ID');
|
||||
$table->timestamp('executed_at')->nullable()->comment('执行时间');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['task_id', 'event_type'], 'uk_task_event');
|
||||
$table->index('status', 'idx_status');
|
||||
$table->index('created_at', 'idx_created');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('project_task_ai_events');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddAiAutoAnalyzeToProjectsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->string('ai_auto_analyze', 20)->default('open')->after('archive_days');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropColumn('ai_auto_analyze');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class SettingsTableSeeder extends Seeder
|
||||
array (
|
||||
'name' => 'priority',
|
||||
'desc' => '',
|
||||
'setting' => '[{"name":"\\u91cd\\u8981\\u4e14\\u7d27\\u6025","color":"#ED4014","days":1,"priority":1},{"name":"\\u91cd\\u8981\\u4e0d\\u7d27\\u6025","color":"#F16B62","days":3,"priority":2},{"name":"\\u7d27\\u6025\\u4e0d\\u91cd\\u8981","color":"#19C919","days":5,"priority":3},{"name":"\\u4e0d\\u91cd\\u8981\\u4e0d\\u7d27\\u6025","color":"#2D8CF0","days":0,"priority":4}]',
|
||||
'setting' => '[{"name":"\\u91cd\\u8981\\u4e14\\u7d27\\u6025","color":"#ED4014","days":1,"priority":1,"is_default":1},{"name":"\\u91cd\\u8981\\u4e0d\\u7d27\\u6025","color":"#F16B62","days":3,"priority":2,"is_default":0},{"name":"\\u7d27\\u6025\\u4e0d\\u91cd\\u8981","color":"#19C919","days":5,"priority":3,"is_default":0},{"name":"\\u4e0d\\u91cd\\u8981\\u4e0d\\u7d27\\u6025","color":"#2D8CF0","days":0,"priority":4,"is_default":0}]',
|
||||
'created_at' => seeders_at('2021-07-01 08:04:30'),
|
||||
'updated_at' => seeders_at('2021-07-01 09:20:26'),
|
||||
),
|
||||
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
appstore:
|
||||
container_name: "dootask-appstore-${APP_ID}"
|
||||
privileged: true
|
||||
image: "dootask/appstore:0.3.7"
|
||||
image: "dootask/appstore:0.4.0"
|
||||
volumes:
|
||||
- shared_data:/usr/share/dootask
|
||||
- ${HOST_DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
|
||||
413
electron/build.js
vendored
413
electron/build.js
vendored
@@ -12,7 +12,7 @@ const utils = require('./lib/utils');
|
||||
const config = require('../package.json')
|
||||
const env = require('dotenv').config({ path: './.env' })
|
||||
const argv = process.argv;
|
||||
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY, PUBLISH_KEY} = process.env;
|
||||
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY, UPLOAD_TOKEN, UPLOAD_URL} = process.env;
|
||||
|
||||
const electronDir = path.resolve(__dirname, "public");
|
||||
const nativeCachePath = path.resolve(__dirname, ".native");
|
||||
@@ -320,7 +320,7 @@ function axiosAutoTry(data) {
|
||||
if (typeof data.retryNumber == 'number' && data.retryNumber > 0) {
|
||||
data.retryNumber--;
|
||||
if (typeof data.onRetry === "function") {
|
||||
data.onRetry()
|
||||
data.onRetry(error)
|
||||
}
|
||||
if (error.code == 'ECONNABORTED' || error.code == 'ECONNRESET') {
|
||||
// 中止,超时
|
||||
@@ -350,192 +350,167 @@ function axiosAutoTry(data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传app应用
|
||||
* @param url
|
||||
* 官网发布器
|
||||
*/
|
||||
function androidUpload(url) {
|
||||
if (!PUBLISH_KEY) {
|
||||
console.error("缺少 PUBLISH_KEY 环境变量");
|
||||
process.exit()
|
||||
class WebsitePublisher {
|
||||
constructor({baseUrl, token, version}) {
|
||||
this.baseUrl = baseUrl
|
||||
this.token = token
|
||||
this.version = version
|
||||
}
|
||||
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
|
||||
if (!fs.existsSync(releaseDir)) {
|
||||
console.error("发布文件未找到");
|
||||
process.exit()
|
||||
}
|
||||
fs.readdir(releaseDir, async (err, files) => {
|
||||
if (err) {
|
||||
console.warn(err)
|
||||
} else {
|
||||
const uploadOras = {}
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(releaseDir, filename)
|
||||
if (/\.apk$/.test(filename) && fs.existsSync(localFile)) {
|
||||
const fileStat = fs.statSync(localFile)
|
||||
if (fileStat.isFile()) {
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
const formData = new FormData()
|
||||
formData.append("file", fs.createReadStream(localFile));
|
||||
formData.append("action", "draft");
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: url,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Publish-Version': config.version,
|
||||
'Publish-Key': PUBLISH_KEY,
|
||||
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
|
||||
},
|
||||
onUploadProgress: progress => {
|
||||
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
|
||||
uploadOras[filename].text = `Upload [${complete}] ${filename}`
|
||||
},
|
||||
},
|
||||
onRetry: _ => {
|
||||
uploadOras[filename].warn(`Upload [retry] ${filename}`)
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200) {
|
||||
uploadOras[filename].fail(`Upload [fail:${status}] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (!utils.isJson(data)) {
|
||||
uploadOras[filename].fail(`Upload [fail:not json] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (data.ret !== 1) {
|
||||
uploadOras[filename].fail(`Upload [fail:ret ${data.ret}] ${filename}`)
|
||||
return
|
||||
}
|
||||
uploadOras[filename].succeed(`Upload [100%] ${filename}`)
|
||||
}).catch(_ => {
|
||||
uploadOras[filename].fail(`Upload [fail] ${filename}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件
|
||||
* @param localFile 本地文件路径
|
||||
* @param options { platform, arch } 可选,有则为安装包
|
||||
*/
|
||||
async uploadPackage(localFile, options = {}) {
|
||||
const filename = path.basename(localFile)
|
||||
let spinner = ora(`Upload [0%] ${filename}`).start()
|
||||
const formData = new FormData()
|
||||
formData.append("version", this.version)
|
||||
if (options.platform) {
|
||||
formData.append("platform", options.platform)
|
||||
if (options.arch) {
|
||||
formData.append("arch", options.arch)
|
||||
}
|
||||
}
|
||||
});
|
||||
formData.append("file", fs.createReadStream(localFile))
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: `${this.baseUrl}/api/upload/package`,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
|
||||
},
|
||||
onUploadProgress: progress => {
|
||||
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
|
||||
spinner.text = `Upload [${complete}] ${filename}`
|
||||
},
|
||||
},
|
||||
onRetry: (err) => {
|
||||
const reason = err?.response?.status || err?.code || err?.message || ''
|
||||
spinner.warn(`Upload [retry] ${filename}${reason ? ': ' + reason : ''}`)
|
||||
spinner = ora(`Upload [0%] ${filename}`).start()
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200) {
|
||||
spinner.fail(`Upload [fail:${status}] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (!utils.isJson(data)) {
|
||||
spinner.fail(`Upload [fail:not json] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (!data.success) {
|
||||
spinner.fail(`Upload [fail] ${filename}: ${data.message || JSON.stringify(data)}`)
|
||||
return
|
||||
}
|
||||
spinner.succeed(`Upload [100%] ${filename}`)
|
||||
}).catch(err => {
|
||||
spinner.fail(`Upload [fail] ${filename}: ${err.code || err.message || err}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 changelog
|
||||
*/
|
||||
async uploadChangelog(content) {
|
||||
const spinner = ora('Uploading changelog...').start()
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: `${this.baseUrl}/api/upload/changelog`,
|
||||
data: { content },
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200 || !data.success) {
|
||||
spinner.fail('Changelog upload failed')
|
||||
return
|
||||
}
|
||||
spinner.succeed('Changelog uploaded')
|
||||
}).catch(err => {
|
||||
spinner.fail(`Changelog upload failed: ${err.code || err.message || err}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知发布完成
|
||||
*/
|
||||
async release() {
|
||||
const spinner = ora('Publishing release...').start()
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: `${this.baseUrl}/api/upload/release`,
|
||||
data: { version: this.version },
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200 || !data.success) {
|
||||
spinner.fail(`Release failed: ${data?.message || status}`)
|
||||
return
|
||||
}
|
||||
spinner.succeed('Release published')
|
||||
}).catch(err => {
|
||||
spinner.fail(`Release failed: ${err.code || err.message || err}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 安装包扩展名
|
||||
const INSTALLER_EXTS = ['.dmg', '.exe', '.msi', '.appimage', '.deb', '.rpm', '.apk']
|
||||
|
||||
/**
|
||||
* 通知发布完成
|
||||
* @param url
|
||||
* 创建 WebsitePublisher 实例(如果环境变量齐全)
|
||||
*/
|
||||
async function published(url) {
|
||||
if (!PUBLISH_KEY) {
|
||||
console.error("缺少 PUBLISH_KEY 环境变量");
|
||||
process.exit()
|
||||
function createPublisher() {
|
||||
if (!UPLOAD_TOKEN || !UPLOAD_URL) {
|
||||
return null
|
||||
}
|
||||
const spinner = ora('完成发布...').start();
|
||||
const formData = new FormData()
|
||||
formData.append("action", "release");
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: url,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Publish-Version': config.version,
|
||||
'Publish-Key': PUBLISH_KEY,
|
||||
},
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200) {
|
||||
spinner.fail('发布失败, status: ' + status)
|
||||
return
|
||||
}
|
||||
if (!utils.isJson(data)) {
|
||||
spinner.fail('发布失败, not json')
|
||||
return
|
||||
}
|
||||
if (data.ret !== 1) {
|
||||
spinner.fail(`发布失败, ${JSON.stringify(data)}`)
|
||||
return
|
||||
}
|
||||
spinner.succeed('发布完成')
|
||||
}).catch(_ => {
|
||||
spinner.fail('发布失败')
|
||||
return new WebsitePublisher({
|
||||
baseUrl: UPLOAD_URL.replace(/\/+$/, ''),
|
||||
token: UPLOAD_TOKEN,
|
||||
version: config.version
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用发布
|
||||
* @param url
|
||||
* @param key
|
||||
* @param version
|
||||
* @param output
|
||||
* 从文件名判断是否为安装包
|
||||
*/
|
||||
function genericPublish({url, key, version, output}) {
|
||||
if (!/https?:\/\//i.test(url)) {
|
||||
console.warn("发布地址无效: " + url)
|
||||
return
|
||||
}
|
||||
const filePath = path.resolve(__dirname, output)
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn("发布文件未找到: " + filePath)
|
||||
return
|
||||
}
|
||||
fs.readdir(filePath, async (err, files) => {
|
||||
if (err) {
|
||||
console.warn(err)
|
||||
} else {
|
||||
const uploadOras = {}
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(filePath, filename)
|
||||
if (fs.existsSync(localFile)) {
|
||||
const fileStat = fs.statSync(localFile)
|
||||
if (fileStat.isFile()) {
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
const formData = new FormData()
|
||||
formData.append("file", fs.createReadStream(localFile));
|
||||
formData.append("action", "draft");
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: url,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Publish-Version': version,
|
||||
'Publish-Key': key,
|
||||
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
|
||||
},
|
||||
onUploadProgress: progress => {
|
||||
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
|
||||
uploadOras[filename].text = `Upload [${complete}] ${filename}`
|
||||
},
|
||||
},
|
||||
onRetry: _ => {
|
||||
uploadOras[filename].warn(`Upload [retry] ${filename}`)
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200) {
|
||||
uploadOras[filename].fail(`Upload [fail:${status}] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (!utils.isJson(data)) {
|
||||
uploadOras[filename].fail(`Upload [fail:not json] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (data.ret !== 1) {
|
||||
uploadOras[filename].fail(`Upload [fail:ret ${data.ret}] ${filename}`)
|
||||
return
|
||||
}
|
||||
uploadOras[filename].succeed(`Upload [100%] ${filename}`)
|
||||
}).catch(_ => {
|
||||
uploadOras[filename].fail(`Upload [fail] ${filename}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
function isInstaller(filename) {
|
||||
return INSTALLER_EXTS.some(ext => filename.toLowerCase().endsWith(ext))
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件名提取 arch
|
||||
*/
|
||||
function parseArchFromFilename(filename) {
|
||||
if (/-arm64[.-]/i.test(filename)) return 'arm64'
|
||||
if (/-x64[.-]/i.test(filename)) return 'x64'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 将构建平台名映射为 API platform
|
||||
*/
|
||||
function mapPlatform(buildPlatform) {
|
||||
if (buildPlatform.includes('mac')) return 'mac'
|
||||
if (buildPlatform.includes('win')) return 'win'
|
||||
if (buildPlatform.includes('linux')) return 'linux'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -703,13 +678,27 @@ async function startBuild(data) {
|
||||
appConfig.build.directories.output = `${output}-generic`;
|
||||
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
|
||||
child_process.execSync(`npm run ${platform}`, {stdio: "inherit", cwd: "electron"});
|
||||
if (publish === true && PUBLISH_KEY) {
|
||||
genericPublish({
|
||||
url: appConfig.build.publish.url,
|
||||
key: PUBLISH_KEY,
|
||||
version: config.version,
|
||||
output: appConfig.build.directories.output
|
||||
})
|
||||
if (publish === true) {
|
||||
const publisher = createPublisher()
|
||||
if (publisher) {
|
||||
const outputDir = path.resolve(__dirname, appConfig.build.directories.output)
|
||||
if (fs.existsSync(outputDir)) {
|
||||
const apiPlatform = mapPlatform(platform)
|
||||
const files = fs.readdirSync(outputDir)
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(outputDir, filename)
|
||||
const fileStat = fs.statSync(localFile)
|
||||
if (!fileStat.isFile()) continue
|
||||
|
||||
if (isInstaller(filename) && apiPlatform) {
|
||||
const arch = parseArchFromFilename(filename)
|
||||
await publisher.uploadPackage(localFile, { platform: apiPlatform, arch })
|
||||
} else {
|
||||
await publisher.uploadPackage(localFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// package.json Recovery
|
||||
recoveryPackage(true)
|
||||
@@ -746,18 +735,56 @@ if (["dev"].includes(argv[2])) {
|
||||
})
|
||||
} else if (["android-upload"].includes(argv[2])) {
|
||||
// 上传安卓文件(GitHub Actions)
|
||||
config.app.forEach(({publish}) => {
|
||||
if (publish.provider === 'generic') {
|
||||
androidUpload(publish.url)
|
||||
(async () => {
|
||||
const publisher = createPublisher()
|
||||
if (publisher) {
|
||||
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
|
||||
if (fs.existsSync(releaseDir)) {
|
||||
const files = fs.readdirSync(releaseDir)
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(releaseDir, filename)
|
||||
if (/\.apk$/.test(filename) && fs.existsSync(localFile) && fs.statSync(localFile).isFile()) {
|
||||
await publisher.uploadPackage(localFile, { platform: 'android' })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error("发布文件未找到")
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
console.error("缺少 UPLOAD_TOKEN 或 UPLOAD_URL 环境变量")
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
} else if (["published"].includes(argv[2])) {
|
||||
// 发布完成(GitHub Actions)
|
||||
config.app.forEach(async ({publish}) => {
|
||||
if (publish.provider === 'generic') {
|
||||
await published(publish.url)
|
||||
})()
|
||||
} else if (["release"].includes(argv[2])) {
|
||||
// 通知官网发布完成(GitHub Actions)
|
||||
(async () => {
|
||||
const publisher = createPublisher()
|
||||
if (publisher) {
|
||||
await publisher.release()
|
||||
} else {
|
||||
console.error("缺少 UPLOAD_TOKEN 或 UPLOAD_URL 环境变量")
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
})()
|
||||
} else if (["upload-changelog"].includes(argv[2])) {
|
||||
// 上传 changelog(GitHub Actions)
|
||||
(async () => {
|
||||
const publisher = createPublisher()
|
||||
if (publisher) {
|
||||
const changelogPath = path.resolve(__dirname, "../CHANGELOG.md")
|
||||
if (fs.existsSync(changelogPath)) {
|
||||
const content = fs.readFileSync(changelogPath, 'utf8')
|
||||
await publisher.uploadChangelog(content)
|
||||
} else {
|
||||
console.error("CHANGELOG.md 未找到")
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
console.error("缺少 UPLOAD_TOKEN 或 UPLOAD_URL 环境变量")
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
} else if (["all", "win", "mac"].includes(argv[2])) {
|
||||
// 自动编译(GitHub Actions)
|
||||
platforms.filter(p => {
|
||||
@@ -907,8 +934,8 @@ if (["dev"].includes(argv[2])) {
|
||||
|
||||
// 发布判断环境变量
|
||||
if (answers.publish) {
|
||||
if (!PUBLISH_KEY && (!GITHUB_TOKEN || !utils.strExists(GITHUB_REPOSITORY, "/"))) {
|
||||
console.error("发布需要 PUBLISH_KEY 或 GitHub Token 和 Repository, 请检查环境变量!");
|
||||
if (!(UPLOAD_TOKEN && UPLOAD_URL) && !(GITHUB_TOKEN && utils.strExists(GITHUB_REPOSITORY, "/"))) {
|
||||
console.error("发布需要 UPLOAD_TOKEN + UPLOAD_URL 或 GITHUB_TOKEN + GITHUB_REPOSITORY, 请检查环境变量!");
|
||||
process.exit()
|
||||
}
|
||||
}
|
||||
|
||||
8
electron/electron-down.js
vendored
8
electron/electron-down.js
vendored
@@ -2,7 +2,7 @@ const {BrowserWindow, screen, shell, ipcMain} = require('electron')
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const loger = require("electron-log");
|
||||
const {default: electronDl, download, CancelError} = require("@dootask/electron-dl");
|
||||
const {default: electronDl, download, CancelError, InterruptedError} = require("@dootask/electron-dl");
|
||||
const utils = require("./lib/utils");
|
||||
const {DownloadManager, DownloadStore} = require("./lib/download-manager");
|
||||
|
||||
@@ -116,10 +116,12 @@ async function createDownload(window_, url, options = {}) {
|
||||
try {
|
||||
return await download(window_, url, options);
|
||||
} catch (error) {
|
||||
// electron-dl rejects with CancelError when a download is cancelled; treat it as expected.
|
||||
// electron-dl rejects with CancelError/InterruptedError; treat them as expected.
|
||||
const isCancelError = (typeof CancelError === 'function' && error instanceof CancelError)
|
||||
|| error?.name === 'CancelError';
|
||||
if (!isCancelError) {
|
||||
const isInterruptedError = (typeof InterruptedError === 'function' && error instanceof InterruptedError)
|
||||
|| error?.name === 'InterruptedError';
|
||||
if (!isCancelError && !isInterruptedError) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
|
||||
2
electron/electron-menu.js
vendored
2
electron/electron-menu.js
vendored
@@ -23,11 +23,13 @@ const electronMenu = {
|
||||
reload: "重新加载",
|
||||
print: "打印",
|
||||
openInBrowser: "在浏览器中打开",
|
||||
openInDefaultBrowser: "默认浏览器打开",
|
||||
saveImageAs: "图片存储为...",
|
||||
copyImage: "复制图片",
|
||||
copyEmailAddress: "复制电子邮件地址",
|
||||
copyLinkAddress: "复制链接地址",
|
||||
copyImageAddress: "复制图片地址",
|
||||
moveToNewWindow: "将标签页移至新窗口",
|
||||
failedToSaveImage: "图片保存失败",
|
||||
theImageFailedToSave: "图片无法保存",
|
||||
},
|
||||
|
||||
2075
electron/electron.js
vendored
2075
electron/electron.js
vendored
File diff suppressed because it is too large
Load Diff
356
electron/lib/favicon-cache.js
vendored
Normal file
356
electron/lib/favicon-cache.js
vendored
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Favicon 缓存模块
|
||||
*
|
||||
* 实现双层缓存机制(仿 Chrome):
|
||||
* - 第一层:域名缓存 - 快速响应,在导航开始时立即查询
|
||||
* - 第二层:URL 缓存 - 精确匹配,在获取到 favicon URL 后查询
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { app, net } = require('electron')
|
||||
|
||||
// 缓存目录
|
||||
let cacheDir = null
|
||||
|
||||
// 内存缓存(加速读取)
|
||||
const memoryCache = {
|
||||
domains: new Map(), // domain -> base64
|
||||
urls: new Map() // url -> base64
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化缓存目录
|
||||
*/
|
||||
function ensureCacheDir() {
|
||||
if (cacheDir) return cacheDir
|
||||
|
||||
cacheDir = path.join(app.getPath('userData'), 'favicon-cache')
|
||||
|
||||
// 确保缓存目录存在
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 URL 或域名转换为安全的文件名
|
||||
* @param {string} str - URL 或域名
|
||||
* @param {string} prefix - 前缀 ('domain_' 或 'url_')
|
||||
* @returns {string} 安全的文件名
|
||||
*/
|
||||
function toSafeFileName(str, prefix) {
|
||||
// 移除协议
|
||||
let safe = str.replace(/^https?:\/\//, '')
|
||||
// 将特殊字符替换为下划线
|
||||
safe = safe.replace(/[^a-zA-Z0-9.-]/g, '_')
|
||||
// 移除连续的下划线
|
||||
safe = safe.replace(/_+/g, '_')
|
||||
// 移除首尾下划线
|
||||
safe = safe.replace(/^_|_$/g, '')
|
||||
// 限制长度(避免文件名过长)
|
||||
if (safe.length > 200) {
|
||||
safe = safe.substring(0, 200)
|
||||
}
|
||||
return prefix + safe
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 提取域名
|
||||
* @param {string} url
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function extractDomain(url) {
|
||||
if (!url) return null
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
return urlObj.hostname
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取域名缓存的文件路径
|
||||
* @param {string} domain
|
||||
* @returns {string}
|
||||
*/
|
||||
function getDomainCachePath(domain) {
|
||||
ensureCacheDir()
|
||||
return path.join(cacheDir, toSafeFileName(domain, 'domain_'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 URL 缓存的文件路径
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
function getUrlCachePath(url) {
|
||||
ensureCacheDir()
|
||||
return path.join(cacheDir, toSafeFileName(url, 'url_'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据域名获取缓存的 favicon(第一层缓存)
|
||||
* @param {string} domain - 域名
|
||||
* @returns {string|null} - base64 数据或 null
|
||||
*/
|
||||
function getByDomain(domain) {
|
||||
if (!domain) return null
|
||||
|
||||
// 先查内存缓存
|
||||
if (memoryCache.domains.has(domain)) {
|
||||
return memoryCache.domains.get(domain)
|
||||
}
|
||||
|
||||
// 再查文件缓存
|
||||
const filePath = getDomainCachePath(domain)
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const data = fs.readFileSync(filePath, 'utf8')
|
||||
// 写入内存缓存
|
||||
memoryCache.domains.set(domain, data)
|
||||
return data
|
||||
}
|
||||
} catch {
|
||||
// 忽略读取错误
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 URL 获取缓存的 favicon(第二层缓存)
|
||||
* @param {string} url - favicon URL
|
||||
* @returns {string|null} - base64 数据或 null
|
||||
*/
|
||||
function getByUrl(url) {
|
||||
if (!url) return null
|
||||
|
||||
// 先查内存缓存
|
||||
if (memoryCache.urls.has(url)) {
|
||||
return memoryCache.urls.get(url)
|
||||
}
|
||||
|
||||
// 再查文件缓存
|
||||
const filePath = getUrlCachePath(url)
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const data = fs.readFileSync(filePath, 'utf8')
|
||||
// 写入内存缓存
|
||||
memoryCache.urls.set(url, data)
|
||||
return data
|
||||
}
|
||||
} catch {
|
||||
// 忽略读取错误
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 favicon 到缓存
|
||||
* @param {string} faviconUrl - favicon 的 URL
|
||||
* @param {string} pageUrl - 页面的 URL(用于提取域名)
|
||||
* @param {string} base64Data - base64 编码的 favicon 数据
|
||||
*/
|
||||
function save(faviconUrl, pageUrl, base64Data) {
|
||||
if (!base64Data) return
|
||||
|
||||
const domain = extractDomain(pageUrl)
|
||||
|
||||
// 保存到 URL 缓存
|
||||
if (faviconUrl) {
|
||||
const urlPath = getUrlCachePath(faviconUrl)
|
||||
memoryCache.urls.set(faviconUrl, base64Data)
|
||||
fs.writeFile(urlPath, base64Data, 'utf8', () => {})
|
||||
}
|
||||
|
||||
// 保存到域名缓存
|
||||
if (domain) {
|
||||
const domainPath = getDomainCachePath(domain)
|
||||
memoryCache.domains.set(domain, base64Data)
|
||||
fs.writeFile(domainPath, base64Data, 'utf8', () => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并缓存 favicon
|
||||
* 先查缓存,无缓存则下载并保存
|
||||
* @param {string} faviconUrl - favicon URL
|
||||
* @param {string} pageUrl - 页面 URL
|
||||
* @param {number} timeout - 超时时间(毫秒)
|
||||
* @returns {Promise<string|null>} - base64 数据或 null
|
||||
*/
|
||||
async function fetchAndCache(faviconUrl, pageUrl, timeout = 5000) {
|
||||
if (!faviconUrl || typeof faviconUrl !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果已经是 base64,直接保存并返回
|
||||
if (faviconUrl.startsWith('data:')) {
|
||||
save(null, pageUrl, faviconUrl)
|
||||
return faviconUrl
|
||||
}
|
||||
|
||||
// 先查 URL 缓存
|
||||
const cached = getByUrl(faviconUrl)
|
||||
if (cached) {
|
||||
// 更新域名缓存(确保域名缓存是最新的)
|
||||
const domain = extractDomain(pageUrl)
|
||||
if (domain && !memoryCache.domains.has(domain)) {
|
||||
save(null, pageUrl, cached)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
// 下载 favicon
|
||||
const base64Data = await downloadFavicon(faviconUrl, timeout)
|
||||
|
||||
if (base64Data) {
|
||||
// 保存到缓存
|
||||
save(faviconUrl, pageUrl, base64Data)
|
||||
}
|
||||
|
||||
return base64Data
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 favicon 并转换为 base64
|
||||
* @param {string} faviconUrl
|
||||
* @param {number} timeout
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
function downloadFavicon(faviconUrl, timeout = 5000) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const request = net.request(faviconUrl)
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
request.abort()
|
||||
resolve(null)
|
||||
}, timeout)
|
||||
|
||||
const chunks = []
|
||||
|
||||
request.on('response', (response) => {
|
||||
const contentType = response.headers['content-type']
|
||||
const isImage = contentType && (
|
||||
contentType.includes('image/') ||
|
||||
contentType.includes('icon')
|
||||
)
|
||||
|
||||
if (response.statusCode !== 200 || !isImage) {
|
||||
clearTimeout(timeoutId)
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
chunks.push(chunk)
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
clearTimeout(timeoutId)
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks)
|
||||
if (buffer.length < 10) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
let mimeType = 'image/png'
|
||||
if (contentType) {
|
||||
const match = contentType.match(/^([^;]+)/)
|
||||
if (match) {
|
||||
mimeType = match[1].trim()
|
||||
}
|
||||
}
|
||||
const base64 = buffer.toString('base64')
|
||||
resolve(`data:${mimeType};base64,${base64}`)
|
||||
} catch {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
|
||||
response.on('error', () => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve(null)
|
||||
})
|
||||
})
|
||||
|
||||
request.on('error', () => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
request.end()
|
||||
} catch {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓存
|
||||
*/
|
||||
function clearCache() {
|
||||
memoryCache.domains.clear()
|
||||
memoryCache.urls.clear()
|
||||
|
||||
if (cacheDir && fs.existsSync(cacheDir)) {
|
||||
try {
|
||||
const files = fs.readdirSync(cacheDir)
|
||||
for (const file of files) {
|
||||
fs.unlinkSync(path.join(cacheDir, file))
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的缓存文件
|
||||
* 默认清理 30 天未访问的文件
|
||||
* @param {number} maxAgeDays - 最大保留天数,默认 30
|
||||
*/
|
||||
function cleanExpiredCache(maxAgeDays = 30) {
|
||||
ensureCacheDir()
|
||||
|
||||
if (!cacheDir || !fs.existsSync(cacheDir)) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(cacheDir)
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDir, file)
|
||||
try {
|
||||
const stats = fs.statSync(filePath)
|
||||
// 使用访问时间(atime)判断是否过期
|
||||
if (now - stats.atimeMs > maxAgeMs) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
} catch {
|
||||
// 忽略单个文件的错误
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractDomain,
|
||||
getByDomain,
|
||||
getByUrl,
|
||||
save,
|
||||
fetchAndCache,
|
||||
clearCache,
|
||||
cleanExpiredCache
|
||||
}
|
||||
611
electron/lib/mcp.js
vendored
611
electron/lib/mcp.js
vendored
@@ -4,11 +4,11 @@
|
||||
* DooTask 的 Electron 客户端集成了 Model Context Protocol (MCP) 服务,
|
||||
* 允许 AI 助手(如 Claude)直接与 DooTask 任务进行交互。
|
||||
*
|
||||
* 提供的工具(共 25 个):
|
||||
* 提供的工具(共 27 个):
|
||||
*
|
||||
* === 用户管理 ===
|
||||
* - get_users_basic - 批量获取用户基础信息(1-50个),便于匹配负责人/协助人
|
||||
* - search_user - 按关键词搜索用户,支持按项目/对话范围筛选,用于不知道用户ID时的查找
|
||||
* - search_users - 按关键词搜索用户,支持按项目/对话范围筛选,用于不知道用户ID时的查找
|
||||
*
|
||||
* === 任务管理 ===
|
||||
* - list_tasks - 获取当前用户相关的任务列表(负责/协助/关注),支持按状态/项目/时间筛选
|
||||
@@ -27,9 +27,10 @@
|
||||
* - update_project - 修改项目信息(名称、描述等)
|
||||
*
|
||||
* === 文件管理(个人文件系统) ===
|
||||
* - list_files - 浏览个人文件系统,获取指定文件夹下的文件和子文件夹列表
|
||||
* - search_files - 搜索用户文件系统中的文件(自己创建的和共享给自己的)
|
||||
* - get_file_detail - 获取文件详情,支持通过文件ID或分享码访问,返回 content_url 可配合 WebFetch 读取
|
||||
* - list_files - 浏览个人文件系统,获取指定文件夹下的文件和子文件夹列表
|
||||
* - search_files - 搜索用户文件系统中的文件(自己创建的和共享给自己的)
|
||||
* - get_file_detail - 获取文件详情,可选提取文本内容
|
||||
* - fetch_file_content - 通过文件路径获取文本内容
|
||||
*
|
||||
* === 工作报告 ===
|
||||
* - list_received_reports - 获取我接收的汇报列表,支持按类型/状态/部门/时间筛选
|
||||
@@ -40,8 +41,12 @@
|
||||
* - mark_reports_read - 批量标记汇报为已读或未读状态
|
||||
*
|
||||
* === 消息通知 ===
|
||||
* - send_message_to_user - 给指定用户发送私信
|
||||
* - get_message_list - 两种模式:获取对话消息列表 或 按关键词搜索消息
|
||||
* - search_dialogs - 按名称搜索群聊或联系人,返回 dialog_id/userid
|
||||
* - send_message - 发送消息到对话(支持 dialog_id 或 userid)
|
||||
* - get_message_list - 获取对话消息记录(支持 dialog_id 或 userid)
|
||||
*
|
||||
* === 智能搜索 ===
|
||||
* - intelligent_search - 统一搜索工具,可搜索任务、项目、文件、联系人、消息
|
||||
*
|
||||
* 配置方法:
|
||||
* {
|
||||
@@ -70,7 +75,8 @@
|
||||
* - "查看我的文件列表"
|
||||
* - "搜索包含'设计稿'的文件"
|
||||
* - "显示文件123的详细信息"
|
||||
* - "帮我分析这个文档的内容"
|
||||
* - "帮我阅读这个Word文档的内容"
|
||||
* - "分析这份PDF报告的要点"
|
||||
*
|
||||
* 工作报告:
|
||||
* - "查看未读的工作汇报"
|
||||
@@ -250,7 +256,7 @@ class DooTaskMCP {
|
||||
// 用户管理:获取用户基础信息
|
||||
this.mcp.addTool({
|
||||
name: 'get_users_basic',
|
||||
description: '根据用户ID列表批量获取用户基础信息(昵称、邮箱、头像等)。适用于分配任务前确认成员身份,支持1-50个用户。',
|
||||
description: '批量获取用户基础信息(昵称、邮箱、头像等),支持1-50个用户。',
|
||||
parameters: z.object({
|
||||
userids: z.array(z.number())
|
||||
.min(1)
|
||||
@@ -277,9 +283,10 @@ class DooTaskMCP {
|
||||
userid: user.userid,
|
||||
nickname: user.nickname || '',
|
||||
email: user.email || '',
|
||||
avatar: user.avatar || '',
|
||||
identity: user.identity || '',
|
||||
department: user.department || '',
|
||||
userimg: user.userimg || '',
|
||||
profession: user.profession || '',
|
||||
department: user.department || [],
|
||||
department_name: user.department_name || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -296,8 +303,8 @@ class DooTaskMCP {
|
||||
|
||||
// 用户管理:搜索用户
|
||||
this.mcp.addTool({
|
||||
name: 'search_user',
|
||||
description: '按关键词搜索用户(昵称、邮箱、拼音),支持按项目/对话范围筛选。与 get_users_basic 不同,此工具用于不知道具体用户ID时的查找场景。',
|
||||
name: 'search_users',
|
||||
description: '按关键词搜索用户,支持按项目/对话范围筛选。用于不知道具体用户ID时的查找。',
|
||||
parameters: z.object({
|
||||
keyword: z.string()
|
||||
.min(1)
|
||||
@@ -464,7 +471,7 @@ class DooTaskMCP {
|
||||
}
|
||||
|
||||
const tasks = result.data.data.map(task => ({
|
||||
id: task.id,
|
||||
task_id: task.id,
|
||||
name: task.name,
|
||||
desc: task.desc || '无描述',
|
||||
dialog_id: task.dialog_id,
|
||||
@@ -475,9 +482,8 @@ class DooTaskMCP {
|
||||
project_name: task.project_name || '',
|
||||
column_name: task.column_name || '',
|
||||
parent_id: task.parent_id,
|
||||
owners: task.taskUser?.filter(u => u.owner === 1).map(u => ({
|
||||
owners: task.task_user?.filter(u => u.owner === 1).map(u => ({
|
||||
userid: u.userid,
|
||||
username: u.username || u.nickname || `用户${u.userid}`
|
||||
})) || [],
|
||||
sub_num: task.sub_num || 0,
|
||||
sub_complete: task.sub_complete || 0,
|
||||
@@ -502,9 +508,10 @@ class DooTaskMCP {
|
||||
// 获取任务详情
|
||||
this.mcp.addTool({
|
||||
name: 'get_task',
|
||||
description: '获取单个任务的完整详细信息,包括任务描述、完整内容(content)、负责人、协助人员、标签、时间等。比 list_tasks 返回更详细的信息。',
|
||||
description: '获取任务的完整详情,包括描述、内容、负责人、协助人、标签等。',
|
||||
parameters: z.object({
|
||||
task_id: z.number()
|
||||
.min(1)
|
||||
.describe('任务ID'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
@@ -539,7 +546,7 @@ class DooTaskMCP {
|
||||
fullContent = htmlToMarkdown(fullContent);
|
||||
|
||||
const taskDetail = {
|
||||
id: task.id,
|
||||
task_id: task.id,
|
||||
name: task.name,
|
||||
desc: task.desc || '无描述',
|
||||
dialog_id: task.dialog_id,
|
||||
@@ -556,15 +563,13 @@ class DooTaskMCP {
|
||||
flow_item_id: task.flow_item_id,
|
||||
flow_item_name: task.flow_item_name,
|
||||
visibility: task.visibility === 1 ? '公开' : '指定人员',
|
||||
owners: task.taskUser?.filter(u => u.owner === 1).map(u => ({
|
||||
owners: task.task_user?.filter(u => u.owner === 1).map(u => ({
|
||||
userid: u.userid,
|
||||
username: u.username || u.nickname || `用户${u.userid}`
|
||||
})) || [],
|
||||
assistants: task.taskUser?.filter(u => u.owner === 0).map(u => ({
|
||||
assistants: task.task_user?.filter(u => u.owner === 0).map(u => ({
|
||||
userid: u.userid,
|
||||
username: u.username || u.nickname || `用户${u.userid}`
|
||||
})) || [],
|
||||
tags: task.taskTag?.map(t => t.name) || [],
|
||||
tags: task.task_tag?.map(t => t.name) || [],
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
};
|
||||
@@ -581,9 +586,10 @@ class DooTaskMCP {
|
||||
// 标记任务完成
|
||||
this.mcp.addTool({
|
||||
name: 'complete_task',
|
||||
description: '快速标记任务完成(自动使用当前时间)。如需指定完成时间或取消完成,请使用 update_task。注意:主任务必须在所有子任务完成后才能标记完成。',
|
||||
description: '快速标记任务完成。主任务需所有子任务完成后才能标记。',
|
||||
parameters: z.object({
|
||||
task_id: z.number()
|
||||
.min(1)
|
||||
.describe('要标记完成的任务ID'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
@@ -615,11 +621,13 @@ class DooTaskMCP {
|
||||
// 创建任务
|
||||
this.mcp.addTool({
|
||||
name: 'create_task',
|
||||
description: '在指定项目中创建新任务。必需参数:项目ID、任务名称。可选:负责人、协助人、开始/结束时间、看板列等。',
|
||||
description: '在指定项目中创建新任务。',
|
||||
parameters: z.object({
|
||||
project_id: z.number()
|
||||
.min(1)
|
||||
.describe('项目ID'),
|
||||
name: z.string()
|
||||
.min(1)
|
||||
.describe('任务名称'),
|
||||
content: z.string()
|
||||
.optional()
|
||||
@@ -646,7 +654,6 @@ class DooTaskMCP {
|
||||
name: params.name,
|
||||
};
|
||||
|
||||
// 添加可选参数,将 Markdown 转换为 HTML
|
||||
if (params.content) requestData.content = markdownToHtml(params.content);
|
||||
if (params.owner) requestData.owner = params.owner;
|
||||
if (params.assist) requestData.assist = params.assist;
|
||||
@@ -684,9 +691,10 @@ class DooTaskMCP {
|
||||
// 更新任务
|
||||
this.mcp.addTool({
|
||||
name: 'update_task',
|
||||
description: '更新任务的任意属性(名称、内容、负责人、协助人、时间、完成状态、看板列等)。只需提供要修改的字段。',
|
||||
description: '更新任务属性,只需提供要修改的字段。',
|
||||
parameters: z.object({
|
||||
task_id: z.number()
|
||||
.min(1)
|
||||
.describe('任务ID'),
|
||||
name: z.string()
|
||||
.optional()
|
||||
@@ -718,7 +726,6 @@ class DooTaskMCP {
|
||||
task_id: params.task_id,
|
||||
};
|
||||
|
||||
// 添加要更新的字段,将 Markdown 转换为 HTML
|
||||
if (params.name !== undefined) requestData.name = params.name;
|
||||
if (params.content !== undefined) requestData.content = markdownToHtml(params.content);
|
||||
if (params.owner !== undefined) requestData.owner = params.owner;
|
||||
@@ -771,13 +778,14 @@ class DooTaskMCP {
|
||||
description: '为指定主任务新增子任务,自动继承主任务所属项目与看板列配置。',
|
||||
parameters: z.object({
|
||||
task_id: z.number()
|
||||
.min(1)
|
||||
.describe('主任务ID'),
|
||||
name: z.string()
|
||||
.min(1)
|
||||
.describe('子任务名称'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const result = await this.request('GET', 'project/task/addsub', {
|
||||
const result = await this.request('POST', 'project/task/addsub', {
|
||||
task_id: params.task_id,
|
||||
name: params.name,
|
||||
});
|
||||
@@ -815,6 +823,7 @@ class DooTaskMCP {
|
||||
description: '获取指定任务的附件列表,包含文件名称、大小、下载地址等信息。',
|
||||
parameters: z.object({
|
||||
task_id: z.number()
|
||||
.min(1)
|
||||
.describe('任务ID'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
@@ -829,7 +838,7 @@ class DooTaskMCP {
|
||||
const files = Array.isArray(result.data) ? result.data : [];
|
||||
|
||||
const normalized = files.map(file => ({
|
||||
id: file.id,
|
||||
file_id: file.id,
|
||||
name: file.name,
|
||||
ext: file.ext,
|
||||
size: file.size,
|
||||
@@ -858,6 +867,7 @@ class DooTaskMCP {
|
||||
description: '删除或还原任务。默认执行删除,可通过 action=recovery 将任务从回收站恢复。',
|
||||
parameters: z.object({
|
||||
task_id: z.number()
|
||||
.min(1)
|
||||
.describe('任务ID'),
|
||||
action: z.enum(['delete', 'recovery'])
|
||||
.optional()
|
||||
@@ -866,7 +876,7 @@ class DooTaskMCP {
|
||||
execute: async (params) => {
|
||||
const action = params.action || 'delete';
|
||||
|
||||
const result = await this.request('GET', 'project/task/remove', {
|
||||
const result = await this.request('POST', 'project/task/remove', {
|
||||
task_id: params.task_id,
|
||||
type: action,
|
||||
});
|
||||
@@ -928,7 +938,7 @@ class DooTaskMCP {
|
||||
}
|
||||
|
||||
const projects = result.data.data.map(project => ({
|
||||
id: project.id,
|
||||
project_id: project.id,
|
||||
name: project.name,
|
||||
desc: project.desc || '无描述',
|
||||
dialog_id: project.dialog_id,
|
||||
@@ -957,35 +967,43 @@ class DooTaskMCP {
|
||||
description: '获取指定项目的完整详细信息,包括项目描述、所有看板列、成员列表及权限等。比 list_projects 返回更详细的信息。',
|
||||
parameters: z.object({
|
||||
project_id: z.number()
|
||||
.min(1)
|
||||
.describe('项目ID'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const result = await this.request('GET', 'project/one', {
|
||||
project_id: params.project_id,
|
||||
});
|
||||
// 并行获取项目详情和列信息
|
||||
const [projectResult, columnsResult] = await Promise.all([
|
||||
this.request('GET', 'project/one', {
|
||||
project_id: params.project_id,
|
||||
}),
|
||||
this.request('GET', 'project/column/lists', {
|
||||
project_id: params.project_id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
if (projectResult.error) {
|
||||
throw new Error(projectResult.error);
|
||||
}
|
||||
|
||||
const project = result.data;
|
||||
const project = projectResult.data;
|
||||
|
||||
// columns 需要单独获取
|
||||
const columns = columnsResult.error ? [] : (columnsResult.data?.data || []);
|
||||
|
||||
const projectDetail = {
|
||||
id: project.id,
|
||||
project_id: project.id,
|
||||
name: project.name,
|
||||
desc: project.desc || '无描述',
|
||||
dialog_id: project.dialog_id,
|
||||
archived_at: project.archived_at || '未归档',
|
||||
owner_userid: project.owner_userid,
|
||||
owner_username: project.owner_username,
|
||||
columns: project.projectColumn?.map(col => ({
|
||||
id: col.id,
|
||||
columns: columns.map(col => ({
|
||||
column_id: col.id,
|
||||
name: col.name,
|
||||
sort: col.sort,
|
||||
})) || [],
|
||||
members: project.projectUser?.map(user => ({
|
||||
})),
|
||||
members: project.project_user?.map(user => ({
|
||||
userid: user.userid,
|
||||
username: user.username,
|
||||
owner: user.owner === 1 ? '管理员' : '成员',
|
||||
})) || [],
|
||||
created_at: project.created_at,
|
||||
@@ -1040,7 +1058,7 @@ class DooTaskMCP {
|
||||
requestData.personal = params.personal ? 1 : 0;
|
||||
}
|
||||
|
||||
const result = await this.request('GET', 'project/add', requestData);
|
||||
const result = await this.request('POST', 'project/add', requestData);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
@@ -1072,6 +1090,7 @@ class DooTaskMCP {
|
||||
description: '修改项目信息(名称、描述、归档策略等)。若未传 name 将自动沿用项目当前名称。',
|
||||
parameters: z.object({
|
||||
project_id: z.number()
|
||||
.min(1)
|
||||
.describe('项目ID'),
|
||||
name: z.string()
|
||||
.optional()
|
||||
@@ -1147,49 +1166,108 @@ class DooTaskMCP {
|
||||
}
|
||||
});
|
||||
|
||||
// 发送消息给用户
|
||||
// 搜索对话
|
||||
this.mcp.addTool({
|
||||
name: 'send_message_to_user',
|
||||
description: '给指定用户发送私信,可选择 Markdown 或 HTML 格式,并支持静默发送。',
|
||||
name: 'search_dialogs',
|
||||
description: '按名称搜索群聊或联系人对话。',
|
||||
parameters: z.object({
|
||||
user_id: z.number()
|
||||
.describe('接收方用户ID'),
|
||||
keyword: z.string()
|
||||
.min(1)
|
||||
.describe('搜索关键词'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const result = await this.request('GET', 'dialog/search', {
|
||||
key: params.keyword,
|
||||
dialog_only: 1,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const dialogs = Array.isArray(result.data) ? result.data : [];
|
||||
|
||||
const simplified = dialogs.map(dialog => {
|
||||
const item = {
|
||||
type: dialog.type,
|
||||
name: dialog.name,
|
||||
last_at: dialog.last_at,
|
||||
};
|
||||
// 根据类型返回不同的 ID 字段
|
||||
if (typeof dialog.id === 'string' && dialog.id.startsWith('u:')) {
|
||||
// 还没有对话的用户,返回 userid
|
||||
item.userid = parseInt(dialog.id.slice(2), 10);
|
||||
} else {
|
||||
// 已有对话,返回 dialog_id
|
||||
item.dialog_id = dialog.id;
|
||||
// 如果是用户类型且有 dialog_user,也返回 userid
|
||||
if (dialog.type === 'user' && dialog.dialog_user?.userid) {
|
||||
item.userid = dialog.dialog_user.userid;
|
||||
}
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
count: simplified.length,
|
||||
dialogs: simplified,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 发送消息到对话
|
||||
this.mcp.addTool({
|
||||
name: 'send_message',
|
||||
description: '发送消息到指定对话(私聊或群聊)。',
|
||||
parameters: z.object({
|
||||
dialog_id: z.number()
|
||||
.optional()
|
||||
.describe('对话ID,群聊或已有私聊时使用'),
|
||||
userid: z.number()
|
||||
.optional()
|
||||
.describe('用户ID,私聊时使用'),
|
||||
text: z.string()
|
||||
.min(1)
|
||||
.describe('消息内容'),
|
||||
text_type: z.enum(['md', 'html'])
|
||||
.optional()
|
||||
.describe('消息类型,默认md,可选md/html'),
|
||||
.describe('消息格式,默认 md'),
|
||||
silence: z.boolean()
|
||||
.optional()
|
||||
.describe('是否静默发送(不触发提醒)'),
|
||||
.describe('静默发送,不触发提醒'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const dialogResult = await this.request('GET', 'dialog/open/user', {
|
||||
userid: params.user_id,
|
||||
});
|
||||
let dialogId = params.dialog_id;
|
||||
|
||||
if (dialogResult.error) {
|
||||
throw new Error(dialogResult.error);
|
||||
// 如果没有 dialog_id,通过 userid 获取/创建对话
|
||||
if (!dialogId && params.userid) {
|
||||
const dialogResult = await this.request('GET', 'dialog/open/user', {
|
||||
userid: params.userid,
|
||||
});
|
||||
if (dialogResult.error) {
|
||||
throw new Error(dialogResult.error);
|
||||
}
|
||||
dialogId = dialogResult.data?.id;
|
||||
if (!dialogId) {
|
||||
throw new Error('无法创建对话');
|
||||
}
|
||||
}
|
||||
|
||||
const dialogData = dialogResult.data || {};
|
||||
const dialogId = dialogData.id;
|
||||
|
||||
if (!dialogId) {
|
||||
throw new Error('未能获取会话ID,无法发送消息');
|
||||
throw new Error('请提供 dialog_id 或 userid');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
dialog_id: dialogId,
|
||||
text: params.text,
|
||||
text_type: params.text_type || 'md',
|
||||
};
|
||||
|
||||
if (params.text_type) {
|
||||
payload.text_type = params.text_type;
|
||||
} else {
|
||||
payload.text_type = 'md';
|
||||
}
|
||||
if (params.silence !== undefined) {
|
||||
payload.silence = params.silence ? 'yes' : 'no';
|
||||
}
|
||||
@@ -1206,95 +1284,78 @@ class DooTaskMCP {
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
dialog_id: dialogId,
|
||||
data: sendResult.data,
|
||||
message: sendResult.data,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 获取消息列表或搜索消息
|
||||
// 获取对话消息列表
|
||||
this.mcp.addTool({
|
||||
name: 'get_message_list',
|
||||
description: '获取对话的消息记录或搜索消息内容。支持两种模式:1)获取指定对话(dialog_id)的完整消息历史,可按类型筛选、分页加载,用于了解对话上下文和历史讨论;2)按关键词(keyword)搜索消息,支持单对话或全局搜索。可用于查看任务讨论、项目沟通、决策背景等信息。',
|
||||
description: '获取指定对话的消息记录。',
|
||||
parameters: z.object({
|
||||
dialog_id: z.number()
|
||||
.optional()
|
||||
.describe('对话ID,获取消息列表时必填'),
|
||||
keyword: z.string()
|
||||
.describe('对话ID'),
|
||||
userid: z.number()
|
||||
.optional()
|
||||
.describe('搜索关键词,提供时执行消息搜索'),
|
||||
.describe('用户ID,获取与该用户的私聊记录'),
|
||||
msg_id: z.number()
|
||||
.optional()
|
||||
.describe('围绕某条消息加载相关内容'),
|
||||
position_id: z.number()
|
||||
.optional()
|
||||
.describe('以position_id为中心加载消息'),
|
||||
.describe('围绕某条消息加载'),
|
||||
prev_id: z.number()
|
||||
.optional()
|
||||
.describe('获取此消息之前的历史'),
|
||||
next_id: z.number()
|
||||
.optional()
|
||||
.describe('获取此消息之后的新消息'),
|
||||
.describe('获取此消息之后的记录'),
|
||||
msg_type: z.enum(['tag', 'todo', 'link', 'text', 'image', 'file', 'record', 'meeting'])
|
||||
.optional()
|
||||
.describe('按消息类型筛选'),
|
||||
.describe('按类型筛选'),
|
||||
take: z.number()
|
||||
.optional()
|
||||
.describe('获取条数,列表模式最大100,搜索模式受接口限制'),
|
||||
.describe('数量,最大100'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const keyword = params.keyword?.trim();
|
||||
let dialogId = params.dialog_id;
|
||||
|
||||
if (keyword) {
|
||||
const searchPayload = {
|
||||
key: keyword,
|
||||
};
|
||||
if (params.dialog_id) {
|
||||
searchPayload.dialog_id = params.dialog_id;
|
||||
// 如果没有 dialog_id,通过 userid 查找对话
|
||||
if (!dialogId && params.userid) {
|
||||
const dialogResult = await this.request('GET', 'dialog/open/user', {
|
||||
userid: params.userid,
|
||||
});
|
||||
// 如果获取失败或没有对话,返回空列表
|
||||
if (dialogResult.error || !dialogResult.data?.id) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
userid: params.userid,
|
||||
count: 0,
|
||||
messages: [],
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
if (params.take && params.take > 0) {
|
||||
const takeValue = params.take;
|
||||
searchPayload.take = params.dialog_id
|
||||
? Math.min(takeValue, 200)
|
||||
: Math.min(takeValue, 50);
|
||||
}
|
||||
|
||||
const searchResult = await this.request('GET', 'dialog/msg/search', searchPayload);
|
||||
|
||||
if (searchResult.error) {
|
||||
throw new Error(searchResult.error);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
mode: params.dialog_id ? 'position_search' : 'global_search',
|
||||
keyword: keyword,
|
||||
dialog_id: params.dialog_id || null,
|
||||
data: searchResult.data,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
dialogId = dialogResult.data.id;
|
||||
}
|
||||
|
||||
if (!params.dialog_id) {
|
||||
throw new Error('请提供 dialog_id 以获取消息列表,或提供 keyword 执行搜索');
|
||||
if (!dialogId) {
|
||||
throw new Error('请提供 dialog_id 或 userid');
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
dialog_id: params.dialog_id,
|
||||
dialog_id: dialogId,
|
||||
};
|
||||
|
||||
if (params.msg_id !== undefined) requestData.msg_id = params.msg_id;
|
||||
if (params.position_id !== undefined) requestData.position_id = params.position_id;
|
||||
if (params.prev_id !== undefined) requestData.prev_id = params.prev_id;
|
||||
if (params.next_id !== undefined) requestData.next_id = params.next_id;
|
||||
if (params.msg_type !== undefined) requestData.msg_type = params.msg_type;
|
||||
if (params.take !== undefined) {
|
||||
const takeValue = params.take > 0 ? params.take : 1;
|
||||
requestData.take = Math.min(takeValue, 100);
|
||||
requestData.take = Math.min(Math.max(params.take, 1), 100);
|
||||
}
|
||||
|
||||
const result = await this.request('GET', 'dialog/msg/list', requestData);
|
||||
@@ -1310,12 +1371,8 @@ class DooTaskMCP {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
dialog_id: params.dialog_id,
|
||||
dialog_id: dialogId,
|
||||
count: messages.length,
|
||||
time: data.time,
|
||||
dialog: data.dialog,
|
||||
top: data.top,
|
||||
todo: data.todo,
|
||||
messages: messages,
|
||||
}, null, 2)
|
||||
}]
|
||||
@@ -1326,7 +1383,7 @@ class DooTaskMCP {
|
||||
// 工作报告:获取我接收的汇报列表
|
||||
this.mcp.addTool({
|
||||
name: 'list_received_reports',
|
||||
description: '获取我接收的工作汇报列表,支持按类型、已读状态、部门、时间筛选和搜索。适用于管理者查看团队成员提交的工作汇报。',
|
||||
description: '获取我接收的工作汇报列表,支持按类型、状态、部门、时间筛选。',
|
||||
parameters: z.object({
|
||||
search: z.string()
|
||||
.optional()
|
||||
@@ -1408,11 +1465,10 @@ class DooTaskMCP {
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: report.id,
|
||||
report_id: report.id,
|
||||
title: report.title,
|
||||
type: report.type === 'daily' ? '日报' : '周报',
|
||||
sender_id: report.userid,
|
||||
sender_name: report.user ? (report.user.nickname || report.user.email) : '',
|
||||
is_read: myReceive && myReceive.pivot ? (myReceive.pivot.read === 1) : false,
|
||||
receive_at: report.receive_at || report.created_at,
|
||||
created_at: report.created_at,
|
||||
@@ -1436,7 +1492,7 @@ class DooTaskMCP {
|
||||
// 工作报告:获取汇报详情
|
||||
this.mcp.addTool({
|
||||
name: 'get_report_detail',
|
||||
description: '获取指定工作汇报的详细信息,包括完整内容、汇报人、接收人列表、AI分析等。返回的 content 字段为 Markdown 格式。支持通过报告ID或分享码访问。',
|
||||
description: '获取工作汇报详情,包括内容、汇报人、接收人等。支持报告ID或分享码。',
|
||||
parameters: z.object({
|
||||
report_id: z.number()
|
||||
.optional()
|
||||
@@ -1469,13 +1525,12 @@ class DooTaskMCP {
|
||||
const markdownContent = htmlToMarkdown(report.content || '');
|
||||
|
||||
const reportDetail = {
|
||||
id: report.id,
|
||||
report_id: report.id,
|
||||
title: report.title,
|
||||
type: report.type === 'daily' ? '日报' : '周报',
|
||||
type_value: report.type_val || report.type,
|
||||
content: markdownContent,
|
||||
sender_id: report.userid,
|
||||
sender_name: report.user ? (report.user.nickname || report.user.email) : '',
|
||||
receivers: Array.isArray(report.receives_user)
|
||||
? report.receives_user.map(u => ({
|
||||
userid: u.userid,
|
||||
@@ -1483,7 +1538,11 @@ class DooTaskMCP {
|
||||
is_read: u.pivot ? (u.pivot.read === 1) : false,
|
||||
}))
|
||||
: [],
|
||||
ai_analysis: report.ai_analysis || null,
|
||||
ai_analysis: report.ai_analysis ? {
|
||||
text: report.ai_analysis.text,
|
||||
model: report.ai_analysis.model,
|
||||
updated_at: report.ai_analysis.updated_at,
|
||||
} : null,
|
||||
created_at: report.created_at,
|
||||
updated_at: report.updated_at,
|
||||
};
|
||||
@@ -1500,7 +1559,7 @@ class DooTaskMCP {
|
||||
// 工作报告:生成汇报模板
|
||||
this.mcp.addTool({
|
||||
name: 'generate_report_template',
|
||||
description: '基于用户的任务完成情况自动生成工作汇报模板,包括已完成工作、未完成工作等内容。支持生成当前周期或历史周期的汇报。返回的 content 字段为 Markdown 格式。',
|
||||
description: '基于任务完成情况自动生成工作汇报模板。',
|
||||
parameters: z.object({
|
||||
type: z.enum(['weekly', 'daily'])
|
||||
.describe('汇报类型: weekly(周报), daily(日报)'),
|
||||
@@ -1596,7 +1655,7 @@ class DooTaskMCP {
|
||||
success: true,
|
||||
message: '工作汇报创建成功',
|
||||
report: {
|
||||
id: report.id,
|
||||
report_id: report.id,
|
||||
title: report.title,
|
||||
type: report.type === 'daily' ? '日报' : '周报',
|
||||
created_at: report.created_at,
|
||||
@@ -1675,7 +1734,7 @@ class DooTaskMCP {
|
||||
const reports = Array.isArray(data.data) ? data.data : [];
|
||||
|
||||
const simplified = reports.map(report => ({
|
||||
id: report.id,
|
||||
report_id: report.id,
|
||||
title: report.title,
|
||||
type: report.type === 'daily' ? '日报' : '周报',
|
||||
receivers: Array.isArray(report.receives) ? report.receives : [],
|
||||
@@ -1743,7 +1802,7 @@ class DooTaskMCP {
|
||||
// 文件管理:获取文件列表
|
||||
this.mcp.addTool({
|
||||
name: 'list_files',
|
||||
description: '获取用户文件列表(个人文件系统),支持按父级文件夹筛选。pid=0或不传表示获取根目录,pid>0获取指定文件夹下的内容。可以浏览文件夹结构,查看所有文件和子文件夹。',
|
||||
description: '获取用户文件列表,支持按父级文件夹筛选。',
|
||||
parameters: z.object({
|
||||
pid: z.number()
|
||||
.optional()
|
||||
@@ -1763,7 +1822,7 @@ class DooTaskMCP {
|
||||
const files = Array.isArray(result.data) ? result.data : [];
|
||||
|
||||
const simplified = files.map(file => ({
|
||||
id: file.id,
|
||||
file_id: file.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
ext: file.ext || '',
|
||||
@@ -1816,7 +1875,7 @@ class DooTaskMCP {
|
||||
const files = Array.isArray(result.data) ? result.data : [];
|
||||
|
||||
const simplified = files.map(file => ({
|
||||
id: file.id,
|
||||
file_id: file.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
ext: file.ext || '',
|
||||
@@ -1845,16 +1904,38 @@ class DooTaskMCP {
|
||||
// 文件管理:获取文件详情
|
||||
this.mcp.addTool({
|
||||
name: 'get_file_detail',
|
||||
description: '获取指定文件的详细信息,包括类型、大小、共享状态、创建者等。支持通过文件ID或分享码访问。返回的 content_url 可以配合 WebFetch 工具读取文件内容进行分析。',
|
||||
description: '获取文件详情,包括类型、大小、正文内容、共享状态等。',
|
||||
parameters: z.object({
|
||||
file_id: z.union([z.number(), z.string()])
|
||||
.describe('文件ID(数字)或分享码(字符串)'),
|
||||
.describe('文件ID 或分享码'),
|
||||
with_content: z.boolean()
|
||||
.optional()
|
||||
.describe('是否提取文本内容'),
|
||||
text_offset: z.number()
|
||||
.optional()
|
||||
.describe('文本起始位置'),
|
||||
text_limit: z.number()
|
||||
.optional()
|
||||
.describe('获取长度,默认50000,最大200000'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const result = await this.request('GET', 'file/one', {
|
||||
const requestData = {
|
||||
id: params.file_id,
|
||||
with_url: 'yes',
|
||||
});
|
||||
};
|
||||
|
||||
// 如果需要提取文本内容
|
||||
if (params.with_content) {
|
||||
requestData.with_text = 'yes';
|
||||
if (params.text_offset !== undefined) {
|
||||
requestData.text_offset = params.text_offset;
|
||||
}
|
||||
if (params.text_limit !== undefined) {
|
||||
requestData.text_limit = Math.min(params.text_limit, 200000);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.request('GET', 'file/one', requestData);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
@@ -1863,7 +1944,7 @@ class DooTaskMCP {
|
||||
const file = result.data;
|
||||
|
||||
const fileDetail = {
|
||||
id: file.id,
|
||||
file_id: file.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
ext: file.ext || '',
|
||||
@@ -1877,6 +1958,19 @@ class DooTaskMCP {
|
||||
updated_at: file.updated_at,
|
||||
};
|
||||
|
||||
// 如果有文本内容
|
||||
if (file.text_content) {
|
||||
if (file.text_content.error) {
|
||||
fileDetail.text_error = file.text_content.error;
|
||||
} else {
|
||||
fileDetail.text_content = file.text_content.content;
|
||||
fileDetail.text_total_length = file.text_content.total_length;
|
||||
fileDetail.text_offset = file.text_content.offset;
|
||||
fileDetail.text_limit = file.text_content.limit;
|
||||
fileDetail.text_has_more = file.text_content.has_more;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
@@ -1885,6 +1979,241 @@ class DooTaskMCP {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 文件管理:通过路径获取文件内容
|
||||
this.mcp.addTool({
|
||||
name: 'fetch_file_content',
|
||||
description: '通过文件路径获取文本内容。',
|
||||
parameters: z.object({
|
||||
path: z.string()
|
||||
.describe('系统内文件路径或URL'),
|
||||
offset: z.number()
|
||||
.optional()
|
||||
.describe('起始位置'),
|
||||
limit: z.number()
|
||||
.optional()
|
||||
.describe('获取长度,默认50000,最大200000'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const requestData = {
|
||||
path: params.path,
|
||||
};
|
||||
|
||||
if (params.offset !== undefined) {
|
||||
requestData.offset = params.offset;
|
||||
}
|
||||
if (params.limit !== undefined) {
|
||||
requestData.limit = Math.min(params.limit, 200000);
|
||||
}
|
||||
|
||||
const result = await this.request('GET', 'file/fetch', requestData);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result.data, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 智能搜索:统一搜索工具
|
||||
this.mcp.addTool({
|
||||
name: 'intelligent_search',
|
||||
description: '统一搜索工具,可搜索任务、项目、文件、联系人、消息。支持语义搜索。',
|
||||
parameters: z.object({
|
||||
keyword: z.string()
|
||||
.min(1)
|
||||
.describe('搜索关键词'),
|
||||
types: z.array(z.enum(['task', 'project', 'file', 'contact', 'message']))
|
||||
.optional()
|
||||
.describe('搜索类型数组,可选值: task(任务), project(项目), file(文件), contact(联系人), message(消息)。不传则搜索全部类型'),
|
||||
search_type: z.enum(['text', 'vector', 'hybrid'])
|
||||
.optional()
|
||||
.describe('搜索模式: text(文本匹配), vector(语义搜索), hybrid(混合搜索,默认)'),
|
||||
take: z.number()
|
||||
.optional()
|
||||
.describe('每种类型获取数量,默认10,最大50'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const keyword = params.keyword;
|
||||
const searchType = params.search_type || 'hybrid';
|
||||
const take = params.take && params.take > 0 ? Math.min(params.take, 50) : 10;
|
||||
const types = params.types && params.types.length > 0
|
||||
? params.types
|
||||
: ['task', 'project', 'file', 'contact', 'message'];
|
||||
|
||||
const results = {
|
||||
tasks: [],
|
||||
projects: [],
|
||||
files: [],
|
||||
contacts: [],
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const searchPromises = [];
|
||||
|
||||
// 搜索任务
|
||||
if (types.includes('task')) {
|
||||
searchPromises.push(
|
||||
this.request('GET', 'search/task', {
|
||||
key: keyword,
|
||||
search_type: searchType,
|
||||
take: take,
|
||||
}).then((result) => {
|
||||
if (!result.error && Array.isArray(result.data)) {
|
||||
results.tasks = result.data.map((task) => ({
|
||||
task_id: task.id,
|
||||
name: task.name,
|
||||
desc: task.desc || '',
|
||||
content_preview: task.content_preview || '',
|
||||
status: task.complete_at ? '已完成' : '未完成',
|
||||
project_id: task.project_id,
|
||||
parent_id: task.parent_id || 0,
|
||||
project_name: task.project_name || '',
|
||||
end_at: task.end_at || '',
|
||||
relevance: task.relevance || 0,
|
||||
}));
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[intelligent_search] task search failed:', err?.message || err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 搜索项目
|
||||
if (types.includes('project')) {
|
||||
searchPromises.push(
|
||||
this.request('GET', 'search/project', {
|
||||
key: keyword,
|
||||
search_type: searchType,
|
||||
take: take,
|
||||
}).then((result) => {
|
||||
if (!result.error && Array.isArray(result.data)) {
|
||||
results.projects = result.data.map((project) => ({
|
||||
project_id: project.id,
|
||||
name: project.name,
|
||||
desc: project.desc || '',
|
||||
desc_preview: project.desc_preview || '',
|
||||
archived: !!project.archived_at,
|
||||
relevance: project.relevance || 0,
|
||||
}));
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[intelligent_search] project search failed:', err?.message || err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 搜索文件
|
||||
if (types.includes('file')) {
|
||||
searchPromises.push(
|
||||
this.request('GET', 'search/file', {
|
||||
key: keyword,
|
||||
search_type: searchType,
|
||||
take: take,
|
||||
}).then((result) => {
|
||||
if (!result.error && Array.isArray(result.data)) {
|
||||
results.files = result.data.map((file) => ({
|
||||
file_id: file.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
ext: file.ext || '',
|
||||
size: file.size || 0,
|
||||
content_preview: file.content_preview || '',
|
||||
relevance: file.relevance || 0,
|
||||
}));
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[intelligent_search] file search failed:', err?.message || err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 搜索联系人
|
||||
if (types.includes('contact')) {
|
||||
searchPromises.push(
|
||||
this.request('GET', 'search/contact', {
|
||||
key: keyword,
|
||||
search_type: searchType,
|
||||
take: take,
|
||||
}).then((result) => {
|
||||
if (!result.error && Array.isArray(result.data)) {
|
||||
results.contacts = result.data.map((user) => ({
|
||||
userid: user.userid,
|
||||
nickname: user.nickname || '',
|
||||
email: user.email || '',
|
||||
profession: user.profession || '',
|
||||
introduction_preview: user.introduction_preview || '',
|
||||
relevance: user.relevance || 0,
|
||||
}));
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[intelligent_search] contact search failed:', err?.message || err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 搜索消息
|
||||
if (types.includes('message')) {
|
||||
searchPromises.push(
|
||||
this.request('GET', 'search/message', {
|
||||
key: keyword,
|
||||
search_type: searchType,
|
||||
take: take,
|
||||
}).then((result) => {
|
||||
if (!result.error && Array.isArray(result.data)) {
|
||||
results.messages = result.data.map((msg) => ({
|
||||
msg_id: msg.id,
|
||||
dialog_id: msg.dialog_id,
|
||||
userid: msg.userid,
|
||||
nickname: msg.user?.nickname || '',
|
||||
type: msg.type || '',
|
||||
content_preview: msg.content_preview || msg.msg || '',
|
||||
created_at: msg.created_at || '',
|
||||
relevance: msg.relevance || 0,
|
||||
}));
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[intelligent_search] message search failed:', err?.message || err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 等待所有搜索完成
|
||||
await Promise.all(searchPromises);
|
||||
|
||||
const totalCount = results.tasks.length
|
||||
+ results.projects.length
|
||||
+ results.files.length
|
||||
+ results.contacts.length
|
||||
+ results.messages.length;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
keyword,
|
||||
search_type: searchType,
|
||||
types_searched: types,
|
||||
results,
|
||||
total_count: totalCount,
|
||||
summary: {
|
||||
tasks: results.tasks.length,
|
||||
projects: results.projects.length,
|
||||
files: results.files.length,
|
||||
contacts: results.contacts.length,
|
||||
messages: results.messages.length,
|
||||
},
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 启动 MCP 服务器
|
||||
|
||||
8
electron/lib/navigation.js
vendored
8
electron/lib/navigation.js
vendored
@@ -123,17 +123,17 @@ function navigateTopLevel(webContents, direction) {
|
||||
return false
|
||||
}
|
||||
if (direction === 'back') {
|
||||
if (!webContents.canGoBack()) {
|
||||
if (!webContents.navigationHistory.canGoBack()) {
|
||||
return false
|
||||
}
|
||||
webContents.goBack()
|
||||
webContents.navigationHistory.goBack()
|
||||
return true
|
||||
}
|
||||
if (direction === 'forward') {
|
||||
if (!webContents.canGoForward()) {
|
||||
if (!webContents.navigationHistory.canGoForward()) {
|
||||
return false
|
||||
}
|
||||
webContents.goForward()
|
||||
webContents.navigationHistory.goForward()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
15
electron/lib/other.js
vendored
Normal file
15
electron/lib/other.js
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
// 平台检测常量
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWin = process.platform === 'win32'
|
||||
|
||||
// URL 和调用验证正则
|
||||
const allowedUrls = /^(?:https?|mailto|tel|callto):/i;
|
||||
const allowedCalls = /^(?:mailto|tel|callto):/i;
|
||||
|
||||
module.exports = {
|
||||
isMac,
|
||||
isWin,
|
||||
|
||||
allowedUrls,
|
||||
allowedCalls
|
||||
}
|
||||
439
electron/lib/pdf-export.js
vendored
Normal file
439
electron/lib/pdf-export.js
vendored
Normal file
@@ -0,0 +1,439 @@
|
||||
const path = require('path')
|
||||
const {BrowserWindow, ipcMain} = require('electron')
|
||||
const loger = require("electron-log");
|
||||
const crc = require("crc");
|
||||
const zlib = require('zlib');
|
||||
|
||||
const PDFDocument = require('pdf-lib').PDFDocument;
|
||||
const config = require('../package.json');
|
||||
|
||||
const MICRON_TO_PIXEL = 264.58 //264.58 micron = 1 pixel
|
||||
const PIXELS_PER_INCH = 100.117 // Usually it is 100 pixels per inch but this give better results
|
||||
const PNG_CHUNK_IDAT = 1229209940;
|
||||
const LARGE_IMAGE_AREA = 30000000;
|
||||
|
||||
const pdfExport = {
|
||||
//NOTE: Key length must not be longer than 79 bytes (not checked)
|
||||
writePngWithText(origBuff, key, text, compressed, base64encoded) {
|
||||
let isDpi = key == 'dpi';
|
||||
let inOffset = 0;
|
||||
let outOffset = 0;
|
||||
let data = text;
|
||||
let dataLen = isDpi ? 9 : key.length + data.length + 1; //we add 1 zeros with non-compressed data, for pHYs it's 2 of 4-byte-int + 1 byte
|
||||
|
||||
//prepare compressed data to get its size
|
||||
if (compressed) {
|
||||
data = zlib.deflateRawSync(encodeURIComponent(text));
|
||||
dataLen = key.length + data.length + 2; //we add 2 zeros with compressed data
|
||||
}
|
||||
|
||||
let outBuff = Buffer.allocUnsafe(origBuff.length + dataLen + 4); //4 is the header size "zTXt", "tEXt" or "pHYs"
|
||||
|
||||
try {
|
||||
let magic1 = origBuff.readUInt32BE(inOffset);
|
||||
inOffset += 4;
|
||||
let magic2 = origBuff.readUInt32BE(inOffset);
|
||||
inOffset += 4;
|
||||
|
||||
if (magic1 != 0x89504e47 && magic2 != 0x0d0a1a0a) {
|
||||
throw new Error("PNGImageDecoder0");
|
||||
}
|
||||
|
||||
outBuff.writeUInt32BE(magic1, outOffset);
|
||||
outOffset += 4;
|
||||
outBuff.writeUInt32BE(magic2, outOffset);
|
||||
outOffset += 4;
|
||||
} catch (e) {
|
||||
loger.error(e.message, {stack: e.stack});
|
||||
throw new Error("PNGImageDecoder1");
|
||||
}
|
||||
|
||||
try {
|
||||
while (inOffset < origBuff.length) {
|
||||
let length = origBuff.readInt32BE(inOffset);
|
||||
inOffset += 4;
|
||||
let type = origBuff.readInt32BE(inOffset)
|
||||
inOffset += 4;
|
||||
|
||||
if (type == PNG_CHUNK_IDAT) {
|
||||
// Insert zTXt chunk before IDAT chunk
|
||||
outBuff.writeInt32BE(dataLen, outOffset);
|
||||
outOffset += 4;
|
||||
|
||||
let typeSignature = isDpi ? 'pHYs' : (compressed ? "zTXt" : "tEXt");
|
||||
outBuff.write(typeSignature, outOffset);
|
||||
|
||||
outOffset += 4;
|
||||
|
||||
if (isDpi) {
|
||||
let dpm = Math.round(parseInt(text) / 0.0254) || 3937; //One inch is equal to exactly 0.0254 meters. 3937 is 100dpi
|
||||
|
||||
outBuff.writeInt32BE(dpm, outOffset);
|
||||
outBuff.writeInt32BE(dpm, outOffset + 4);
|
||||
outBuff.writeInt8(1, outOffset + 8);
|
||||
outOffset += 9;
|
||||
|
||||
data = Buffer.allocUnsafe(9);
|
||||
data.writeInt32BE(dpm, 0);
|
||||
data.writeInt32BE(dpm, 4);
|
||||
data.writeInt8(1, 8);
|
||||
} else {
|
||||
outBuff.write(key, outOffset);
|
||||
outOffset += key.length;
|
||||
outBuff.writeInt8(0, outOffset);
|
||||
outOffset++;
|
||||
|
||||
if (compressed) {
|
||||
outBuff.writeInt8(0, outOffset);
|
||||
outOffset++;
|
||||
data.copy(outBuff, outOffset);
|
||||
} else {
|
||||
outBuff.write(data, outOffset);
|
||||
}
|
||||
|
||||
outOffset += data.length;
|
||||
}
|
||||
|
||||
let crcVal = 0xffffffff;
|
||||
crcVal = crc.crcjam(typeSignature, crcVal);
|
||||
crcVal = crc.crcjam(data, crcVal);
|
||||
|
||||
// CRC
|
||||
outBuff.writeInt32BE(crcVal ^ 0xffffffff, outOffset);
|
||||
outOffset += 4;
|
||||
|
||||
// Writes the IDAT chunk after the zTXt
|
||||
outBuff.writeInt32BE(length, outOffset);
|
||||
outOffset += 4;
|
||||
outBuff.writeInt32BE(type, outOffset);
|
||||
outOffset += 4;
|
||||
|
||||
origBuff.copy(outBuff, outOffset, inOffset);
|
||||
|
||||
// Encodes the buffer using base64 if requested
|
||||
return base64encoded ? outBuff.toString('base64') : outBuff;
|
||||
}
|
||||
|
||||
outBuff.writeInt32BE(length, outOffset);
|
||||
outOffset += 4;
|
||||
outBuff.writeInt32BE(type, outOffset);
|
||||
outOffset += 4;
|
||||
|
||||
origBuff.copy(outBuff, outOffset, inOffset, inOffset + length + 4);// +4 to move past the crc
|
||||
|
||||
inOffset += length + 4;
|
||||
outOffset += length + 4;
|
||||
}
|
||||
} catch (e) {
|
||||
loger.error(e.message, {stack: e.stack});
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
//TODO Create a lightweight html file similar to export3.html for exporting to vsdx
|
||||
exportVsdx(event, args, directFinalize) {
|
||||
let win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'electron-preload.js'),
|
||||
webSecurity: true,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true,
|
||||
},
|
||||
})
|
||||
|
||||
let loadEvtCount = 0;
|
||||
|
||||
function loadFinished() {
|
||||
loadEvtCount++;
|
||||
|
||||
if (loadEvtCount == 2) {
|
||||
win.webContents.send('export-vsdx', args);
|
||||
|
||||
ipcMain.once('export-vsdx-finished', (evt, data) => {
|
||||
let hasError = false;
|
||||
|
||||
if (data == null) {
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
//Set finalize here since it is call in the reply below
|
||||
function finalize() {
|
||||
win.destroy();
|
||||
}
|
||||
|
||||
if (directFinalize === true) {
|
||||
event.finalize = finalize;
|
||||
} else {
|
||||
//Destroy the window after response being received by caller
|
||||
ipcMain.once('export-finalize', finalize);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
event.reply('export-error');
|
||||
} else {
|
||||
event.reply('export-success', data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//Order of these two events is not guaranteed, so wait for them async.
|
||||
//TOOD There is still a chance we catch another window 'app-load-finished' if user created multiple windows quickly
|
||||
ipcMain.once('app-load-finished', loadFinished);
|
||||
win.webContents.on('did-finish-load', loadFinished);
|
||||
},
|
||||
|
||||
async mergePdfs(pdfFiles, xml) {
|
||||
//Pass throgh single files
|
||||
if (pdfFiles.length == 1 && xml == null) {
|
||||
return pdfFiles[0];
|
||||
}
|
||||
|
||||
try {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
pdfDoc.setCreator(config.name);
|
||||
|
||||
if (xml != null) {
|
||||
//Embed diagram XML as file attachment
|
||||
await pdfDoc.attach(Buffer.from(xml).toString('base64'), config.name + '.xml', {
|
||||
mimeType: 'application/vnd.jgraph.mxfile',
|
||||
description: config.name + ' Content'
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < pdfFiles.length; i++) {
|
||||
const pdfFile = await PDFDocument.load(pdfFiles[i].buffer);
|
||||
const pages = await pdfDoc.copyPages(pdfFile, pdfFile.getPageIndices());
|
||||
pages.forEach(p => pdfDoc.addPage(p));
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
return Buffer.from(pdfBytes);
|
||||
} catch (e) {
|
||||
throw new Error('Error during PDF combination: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
exportDiagram(event, args, directFinalize) {
|
||||
if (args.format == 'vsdx') {
|
||||
pdfExport.exportVsdx(event, args, directFinalize);
|
||||
return;
|
||||
}
|
||||
|
||||
let browser = null;
|
||||
|
||||
try {
|
||||
browser = new BrowserWindow({
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'electron-preload.js'),
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
disableBlinkFeatures: 'Auxclick' // Is this needed?
|
||||
},
|
||||
show: false,
|
||||
frame: false,
|
||||
enableLargerThanScreen: true,
|
||||
transparent: args.format == 'png' && (args.bg == null || args.bg == 'none'),
|
||||
});
|
||||
|
||||
if (serverUrl) {
|
||||
browser.loadURL(serverUrl + 'drawio/webapp/export3.html').then(_ => { }).catch(_ => { })
|
||||
} else {
|
||||
browser.loadFile('./public/drawio/webapp/export3.html').then(_ => { }).catch(_ => { })
|
||||
}
|
||||
|
||||
const contents = browser.webContents;
|
||||
let pageByPage = (args.format == 'pdf' && !args.print), from, to, pdfs;
|
||||
|
||||
if (pageByPage) {
|
||||
from = args.allPages ? 0 : parseInt(args.from || 0);
|
||||
to = args.allPages ? 1000 : parseInt(args.to || 1000) + 1; //The 'to' will be corrected later
|
||||
pdfs = [];
|
||||
|
||||
args.from = from;
|
||||
args.to = from;
|
||||
args.allPages = false;
|
||||
}
|
||||
|
||||
contents.on('did-finish-load', function () {
|
||||
//Set finalize here since it is call in the reply below
|
||||
function finalize() {
|
||||
browser.destroy();
|
||||
}
|
||||
|
||||
if (directFinalize === true) {
|
||||
event.finalize = finalize;
|
||||
} else {
|
||||
//Destroy the window after response being received by caller
|
||||
ipcMain.once('export-finalize', finalize);
|
||||
}
|
||||
|
||||
function renderingFinishHandler(evt, renderInfo) {
|
||||
if (renderInfo == null) {
|
||||
event.reply('export-error');
|
||||
return;
|
||||
}
|
||||
|
||||
let pageCount = renderInfo.pageCount, bounds = null;
|
||||
//For some reason, Electron 9 doesn't send this object as is without stringifying. Usually when variable is external to function own scope
|
||||
try {
|
||||
bounds = JSON.parse(renderInfo.bounds);
|
||||
} catch (e) {
|
||||
bounds = null;
|
||||
}
|
||||
|
||||
let pdfOptions = {pageSize: 'A4'};
|
||||
let hasError = false;
|
||||
|
||||
if (bounds == null || bounds.width < 5 || bounds.height < 5) //very small page size never return from printToPDF
|
||||
{
|
||||
//A workaround to detect errors in the input file or being empty file
|
||||
hasError = true;
|
||||
} else {
|
||||
pdfOptions = {
|
||||
printBackground: true,
|
||||
pageSize: {
|
||||
width: bounds.width / PIXELS_PER_INCH,
|
||||
height: (bounds.height + 2) / PIXELS_PER_INCH //the extra 2 pixels to prevent adding an extra empty page
|
||||
},
|
||||
margins: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0
|
||||
} // no margin
|
||||
}
|
||||
}
|
||||
|
||||
let base64encoded = args.base64 == '1';
|
||||
|
||||
if (hasError) {
|
||||
event.reply('export-error');
|
||||
} else if (args.format == 'png' || args.format == 'jpg' || args.format == 'jpeg') {
|
||||
//Adds an extra pixel to prevent scrollbars from showing
|
||||
let newBounds = {
|
||||
width: Math.ceil(bounds.width + bounds.x) + 1,
|
||||
height: Math.ceil(bounds.height + bounds.y) + 1
|
||||
};
|
||||
browser.setBounds(newBounds);
|
||||
|
||||
//TODO The browser takes sometime to show the graph (also after resize it takes some time to render)
|
||||
// 1 sec is most probably enough (for small images, 5 for large ones) BUT not a stable solution
|
||||
setTimeout(function () {
|
||||
browser.capturePage().then(function (img) {
|
||||
//Image is double the given bounds, so resize is needed!
|
||||
let tScale = 1;
|
||||
|
||||
//If user defined width and/or height, enforce it precisely here. Height override width
|
||||
if (args.h) {
|
||||
tScale = args.h / newBounds.height;
|
||||
} else if (args.w) {
|
||||
tScale = args.w / newBounds.width;
|
||||
}
|
||||
|
||||
newBounds.width *= tScale;
|
||||
newBounds.height *= tScale;
|
||||
img = img.resize(newBounds);
|
||||
|
||||
let data = args.format == 'png' ? img.toPNG() : img.toJPEG(args.jpegQuality || 90);
|
||||
|
||||
if (args.dpi != null && args.format == 'png') {
|
||||
data = pdfExport.writePngWithText(data, 'dpi', args.dpi);
|
||||
}
|
||||
|
||||
if (args.embedXml == "1" && args.format == 'png') {
|
||||
data = pdfExport.writePngWithText(data, "mxGraphModel", args.xml, true,
|
||||
base64encoded);
|
||||
} else {
|
||||
if (base64encoded) {
|
||||
data = data.toString('base64');
|
||||
}
|
||||
}
|
||||
|
||||
event.reply('export-success', data);
|
||||
});
|
||||
}, bounds.width * bounds.height < LARGE_IMAGE_AREA ? 1000 : 5000);
|
||||
} else if (args.format == 'pdf') {
|
||||
if (args.print) {
|
||||
pdfOptions = {
|
||||
scaleFactor: args.pageScale,
|
||||
printBackground: true,
|
||||
pageSize: {
|
||||
width: args.pageWidth * MICRON_TO_PIXEL,
|
||||
//This height adjustment fixes the output. TODO Test more cases
|
||||
height: (args.pageHeight * 1.025) * MICRON_TO_PIXEL
|
||||
},
|
||||
marginsType: 1 // no margin
|
||||
};
|
||||
|
||||
contents.print(pdfOptions, (success, errorType) => {
|
||||
//Consider all as success
|
||||
event.reply('export-success', {});
|
||||
});
|
||||
} else {
|
||||
contents.printToPDF(pdfOptions).then(async (data) => {
|
||||
pdfs.push(data);
|
||||
to = to > pageCount ? pageCount : to;
|
||||
from++;
|
||||
|
||||
if (from < to) {
|
||||
args.from = from;
|
||||
args.to = from;
|
||||
ipcMain.once('render-finished', renderingFinishHandler);
|
||||
contents.send('render', args);
|
||||
} else {
|
||||
data = await pdfExport.mergePdfs(pdfs, args.embedXml == '1' ? args.xml : null);
|
||||
event.reply('export-success', data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
event.reply('export-error', error);
|
||||
});
|
||||
}
|
||||
} else if (args.format == 'svg') {
|
||||
contents.send('get-svg-data');
|
||||
|
||||
ipcMain.once('svg-data', (evt, data) => {
|
||||
event.reply('export-success', data);
|
||||
});
|
||||
} else {
|
||||
event.reply('export-error', 'Error: Unsupported format');
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.once('render-finished', renderingFinishHandler);
|
||||
|
||||
if (args.format == 'xml') {
|
||||
ipcMain.once('xml-data', (evt, data) => {
|
||||
event.reply('export-success', data);
|
||||
});
|
||||
|
||||
ipcMain.once('xml-data-error', () => {
|
||||
event.reply('export-error');
|
||||
});
|
||||
}
|
||||
|
||||
args.border = args.border || 0;
|
||||
args.scale = args.scale || 1;
|
||||
|
||||
contents.send('render', args);
|
||||
});
|
||||
} catch (e) {
|
||||
if (browser != null) {
|
||||
browser.destroy();
|
||||
}
|
||||
|
||||
event.reply('export-error', e);
|
||||
console.log('export-error', e);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const onExport = () => {
|
||||
ipcMain.on('export', pdfExport.exportDiagram);
|
||||
}
|
||||
|
||||
module.exports = {pdfExport, onExport};
|
||||
675
electron/lib/renderer.js
vendored
Normal file
675
electron/lib/renderer.js
vendored
Normal file
@@ -0,0 +1,675 @@
|
||||
const fs = require("fs");
|
||||
const {BrowserWindow, shell, app, dialog, clipboard, nativeImage, ipcMain} = require("electron");
|
||||
const fsProm = require("fs/promises");
|
||||
const path = require("path");
|
||||
const {spawn} = require("child_process");
|
||||
const config = require("../package.json");
|
||||
const electronDown = require("../electron-down");
|
||||
const loger = require("electron-log");
|
||||
const {allowedUrls, isWin} = require("./other");
|
||||
|
||||
const {O_SYNC, O_CREAT, O_WRONLY, O_TRUNC, O_RDONLY} = fs.constants;
|
||||
const DRAFT_PREFEX = '.$';
|
||||
const OLD_DRAFT_PREFEX = '~$';
|
||||
const DRAFT_EXT = '.dtmp';
|
||||
const BKP_PREFEX = '.$';
|
||||
const OLD_BKP_PREFEX = '~$';
|
||||
const BKP_EXT = '.bkp';
|
||||
|
||||
let enableStoreBkp = true,
|
||||
dialogOpen = false,
|
||||
enablePlugins = false;
|
||||
|
||||
const renderer = {
|
||||
checkFileContent(body, enc) {
|
||||
if (body != null) {
|
||||
let head, headBinay;
|
||||
|
||||
if (typeof body === 'string') {
|
||||
if (enc == 'base64') {
|
||||
headBinay = Buffer.from(body.substring(0, 22), 'base64');
|
||||
head = headBinay.toString();
|
||||
} else {
|
||||
head = body.substring(0, 16);
|
||||
headBinay = Buffer.from(head);
|
||||
}
|
||||
} else {
|
||||
head = new TextDecoder("utf-8").decode(body.subarray(0, 16));
|
||||
headBinay = body;
|
||||
}
|
||||
|
||||
let c1 = head[0],
|
||||
c2 = head[1],
|
||||
c3 = head[2],
|
||||
c4 = head[3],
|
||||
c5 = head[4],
|
||||
c6 = head[5],
|
||||
c7 = head[6],
|
||||
c8 = head[7],
|
||||
c9 = head[8],
|
||||
c10 = head[9],
|
||||
c11 = head[10],
|
||||
c12 = head[11],
|
||||
c13 = head[12],
|
||||
c14 = head[13],
|
||||
c15 = head[14],
|
||||
c16 = head[15];
|
||||
|
||||
let cc1 = headBinay[0],
|
||||
cc2 = headBinay[1],
|
||||
cc3 = headBinay[2],
|
||||
cc4 = headBinay[3],
|
||||
cc5 = headBinay[4],
|
||||
cc6 = headBinay[5],
|
||||
cc7 = headBinay[6],
|
||||
cc8 = headBinay[7],
|
||||
cc9 = headBinay[8],
|
||||
cc10 = headBinay[9],
|
||||
cc11 = headBinay[10],
|
||||
cc12 = headBinay[11],
|
||||
cc13 = headBinay[12],
|
||||
cc14 = headBinay[13],
|
||||
cc15 = headBinay[14],
|
||||
cc16 = headBinay[15];
|
||||
|
||||
if (c1 == '<') {
|
||||
// text/html
|
||||
if (c2 == '!'
|
||||
|| ((c2 == 'h'
|
||||
&& (c3 == 't' && c4 == 'm' && c5 == 'l'
|
||||
|| c3 == 'e' && c4 == 'a' && c5 == 'd')
|
||||
|| (c2 == 'b' && c3 == 'o' && c4 == 'd'
|
||||
&& c5 == 'y')))
|
||||
|| ((c2 == 'H'
|
||||
&& (c3 == 'T' && c4 == 'M' && c5 == 'L'
|
||||
|| c3 == 'E' && c4 == 'A' && c5 == 'D')
|
||||
|| (c2 == 'B' && c3 == 'O' && c4 == 'D'
|
||||
&& c5 == 'Y')))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// application/xml
|
||||
if (c2 == '?' && c3 == 'x' && c4 == 'm' && c5 == 'l'
|
||||
&& c6 == ' ') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// application/svg+xml
|
||||
if (c2 == 's' && c3 == 'v' && c4 == 'g' && c5 == ' ') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// big and little (identical) endian UTF-8 encodings, with BOM
|
||||
// application/xml
|
||||
if (cc1 == 0xef && cc2 == 0xbb && cc3 == 0xbf) {
|
||||
if (c4 == '<' && c5 == '?' && c6 == 'x') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// big and little endian UTF-16 encodings, with byte order mark
|
||||
// application/xml
|
||||
if (cc1 == 0xfe && cc2 == 0xff) {
|
||||
if (cc3 == 0 && c4 == '<' && cc5 == 0 && c6 == '?' && cc7 == 0
|
||||
&& c8 == 'x') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// application/xml
|
||||
if (cc1 == 0xff && cc2 == 0xfe) {
|
||||
if (c3 == '<' && cc4 == 0 && c5 == '?' && cc6 == 0 && c7 == 'x'
|
||||
&& cc8 == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// big and little endian UTF-32 encodings, with BOM
|
||||
// application/xml
|
||||
if (cc1 == 0x00 && cc2 == 0x00 && cc3 == 0xfe && cc4 == 0xff) {
|
||||
if (cc5 == 0 && cc6 == 0 && cc7 == 0 && c8 == '<' && cc9 == 0
|
||||
&& cc10 == 0 && cc11 == 0 && c12 == '?' && cc13 == 0
|
||||
&& cc14 == 0 && cc15 == 0 && c16 == 'x') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// application/xml
|
||||
if (cc1 == 0xff && cc2 == 0xfe && cc3 == 0x00 && cc4 == 0x00) {
|
||||
if (c5 == '<' && cc6 == 0 && cc7 == 0 && cc8 == 0 && c9 == '?'
|
||||
&& cc10 == 0 && cc11 == 0 && cc12 == 0 && c13 == 'x'
|
||||
&& cc14 == 0 && cc15 == 0 && cc16 == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// application/pdf (%PDF-)
|
||||
if (cc1 == 37 && cc2 == 80 && cc3 == 68 && cc4 == 70 && cc5 == 45) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// image/png
|
||||
if ((cc1 == 137 && cc2 == 80 && cc3 == 78 && cc4 == 71 && cc5 == 13
|
||||
&& cc6 == 10 && cc7 == 26 && cc8 == 10) ||
|
||||
(cc1 == 194 && cc2 == 137 && cc3 == 80 && cc4 == 78 && cc5 == 71 && cc6 == 13 //Our embedded PNG+XML
|
||||
&& cc7 == 10 && cc8 == 26 && cc9 == 10)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// image/jpeg
|
||||
if (cc1 == 0xFF && cc2 == 0xD8 && cc3 == 0xFF) {
|
||||
if (cc4 == 0xE0 || cc4 == 0xEE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* File format used by digital cameras to store images.
|
||||
* Exif Format can be read by any application supporting
|
||||
* JPEG. Exif Spec can be found at:
|
||||
* http://www.pima.net/standards/it10/PIMA15740/Exif_2-1.PDF
|
||||
*/
|
||||
if ((cc4 == 0xE1) && (c7 == 'E' && c8 == 'x' && c9 == 'i'
|
||||
&& c10 == 'f' && cc11 == 0)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// vsdx, vssx (also zip, jar, odt, ods, odp, docx, xlsx, pptx, apk, aar)
|
||||
if (cc1 == 0x50 && cc2 == 0x4B && cc3 == 0x03 && cc4 == 0x04) {
|
||||
return true;
|
||||
} else if (cc1 == 0x50 && cc2 == 0x4B && cc3 == 0x03 && cc4 == 0x06) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// mxfile, mxlibrary, mxGraphModel
|
||||
if (c1 == '<' && c2 == 'm' && c3 == 'x') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
isConflict(origStat, stat) {
|
||||
return stat != null && origStat != null && stat.mtimeMs != origStat.mtimeMs;
|
||||
},
|
||||
|
||||
getDraftFileName(fileObject) {
|
||||
let filePath = fileObject.path;
|
||||
let draftFileName = '', counter = 1, uniquePart = '';
|
||||
|
||||
do {
|
||||
draftFileName = path.join(path.dirname(filePath), DRAFT_PREFEX + path.basename(filePath) + uniquePart + DRAFT_EXT);
|
||||
uniquePart = '_' + counter++;
|
||||
} while (fs.existsSync(draftFileName));
|
||||
|
||||
return draftFileName;
|
||||
},
|
||||
|
||||
async getFileDrafts(fileObject) {
|
||||
let filePath = fileObject.path;
|
||||
let draftsPaths = [], drafts = [], draftFileName, counter = 1, uniquePart = '';
|
||||
|
||||
do {
|
||||
draftsPaths.push(draftFileName);
|
||||
draftFileName = path.join(path.dirname(filePath), DRAFT_PREFEX + path.basename(filePath) + uniquePart + DRAFT_EXT);
|
||||
uniquePart = '_' + counter++;
|
||||
} while (fs.existsSync(draftFileName)); //TODO this assume continuous drafts names
|
||||
|
||||
//Port old draft files to new prefex
|
||||
counter = 1;
|
||||
uniquePart = '';
|
||||
let draftExists = false;
|
||||
|
||||
do {
|
||||
draftFileName = path.join(path.dirname(filePath), OLD_DRAFT_PREFEX + path.basename(filePath) + uniquePart + DRAFT_EXT);
|
||||
draftExists = fs.existsSync(draftFileName);
|
||||
|
||||
if (draftExists) {
|
||||
const newDraftFileName = path.join(path.dirname(filePath), DRAFT_PREFEX + path.basename(filePath) + uniquePart + DRAFT_EXT);
|
||||
await fsProm.rename(draftFileName, newDraftFileName);
|
||||
draftsPaths.push(newDraftFileName);
|
||||
}
|
||||
|
||||
uniquePart = '_' + counter++;
|
||||
} while (draftExists); //TODO this assume continuous drafts names
|
||||
|
||||
//Skip the first null element
|
||||
for (let i = 1; i < draftsPaths.length; i++) {
|
||||
try {
|
||||
let stat = await fsProm.lstat(draftsPaths[i]);
|
||||
drafts.push({
|
||||
data: await fsProm.readFile(draftsPaths[i], 'utf8'),
|
||||
created: stat.ctimeMs,
|
||||
modified: stat.mtimeMs,
|
||||
path: draftsPaths[i]
|
||||
});
|
||||
} catch (e) {
|
||||
} // Ignore
|
||||
}
|
||||
|
||||
return drafts;
|
||||
},
|
||||
|
||||
async saveDraft(fileObject, data) {
|
||||
if (!renderer.checkFileContent(data)) {
|
||||
throw new Error('Invalid file data');
|
||||
} else {
|
||||
let draftFileName = fileObject.draftFileName || renderer.getDraftFileName(fileObject);
|
||||
await fsProm.writeFile(draftFileName, data, 'utf8');
|
||||
|
||||
if (isWin) {
|
||||
try {
|
||||
// Add Hidden attribute:
|
||||
spawn('attrib', ['+h', draftFileName], {shell: true});
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
return draftFileName;
|
||||
}
|
||||
},
|
||||
|
||||
async saveFile(fileObject, data, origStat, overwrite, defEnc) {
|
||||
if (!renderer.checkFileContent(data)) {
|
||||
throw new Error('Invalid file data');
|
||||
}
|
||||
|
||||
let retryCount = 0;
|
||||
let backupCreated = false;
|
||||
let bkpPath = path.join(path.dirname(fileObject.path), BKP_PREFEX + path.basename(fileObject.path) + BKP_EXT);
|
||||
const oldBkpPath = path.join(path.dirname(fileObject.path), OLD_BKP_PREFEX + path.basename(fileObject.path) + BKP_EXT);
|
||||
let writeEnc = defEnc || fileObject.encoding;
|
||||
|
||||
let writeFile = async function () {
|
||||
let fh;
|
||||
|
||||
try {
|
||||
// O_SYNC is for sync I/O and reduce risk of file corruption
|
||||
fh = await fsProm.open(fileObject.path, O_SYNC | O_CREAT | O_WRONLY | O_TRUNC);
|
||||
await fsProm.writeFile(fh, data, writeEnc);
|
||||
} finally {
|
||||
await fh?.close();
|
||||
}
|
||||
|
||||
let stat2 = await fsProm.stat(fileObject.path);
|
||||
// Workaround for possible writing errors is to check the written
|
||||
// contents of the file and retry 3 times before showing an error
|
||||
let writtenData = await fsProm.readFile(fileObject.path, writeEnc);
|
||||
|
||||
if (data != writtenData) {
|
||||
retryCount++;
|
||||
|
||||
if (retryCount < 3) {
|
||||
return await writeFile();
|
||||
} else {
|
||||
throw new Error('all saving trials failed');
|
||||
}
|
||||
} else {
|
||||
//We'll keep the backup file in case the original file is corrupted. TODO When should we delete the backup file?
|
||||
if (backupCreated) {
|
||||
//fs.unlink(bkpPath, (err) => {}); //Ignore errors!
|
||||
|
||||
//Delete old backup file with old prefix
|
||||
if (fs.existsSync(oldBkpPath)) {
|
||||
fs.unlink(oldBkpPath, () => {
|
||||
//Ignore errors
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stat2;
|
||||
}
|
||||
};
|
||||
|
||||
async function doSaveFile(isNew) {
|
||||
if (enableStoreBkp && !isNew) {
|
||||
//Copy file to back up file (after conflict and stat is checked)
|
||||
let bkpFh;
|
||||
|
||||
try {
|
||||
//Use file read then write to open the backup file direct sync write to reduce the chance of file corruption
|
||||
let fileContent = await fsProm.readFile(fileObject.path, writeEnc);
|
||||
bkpFh = await fsProm.open(bkpPath, O_SYNC | O_CREAT | O_WRONLY | O_TRUNC);
|
||||
await fsProm.writeFile(bkpFh, fileContent, writeEnc);
|
||||
backupCreated = true;
|
||||
} catch (e) {
|
||||
if (__DEV__) {
|
||||
console.log('Backup file writing failed', e); //Ignore
|
||||
}
|
||||
} finally {
|
||||
await bkpFh?.close();
|
||||
|
||||
if (isWin) {
|
||||
try {
|
||||
// Add Hidden attribute:
|
||||
spawn('attrib', ['+h', bkpPath], {shell: true});
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await writeFile();
|
||||
}
|
||||
|
||||
if (overwrite) {
|
||||
return await doSaveFile(true);
|
||||
} else {
|
||||
let stat = fs.existsSync(fileObject.path) ?
|
||||
await fsProm.stat(fileObject.path) : null;
|
||||
|
||||
if (stat && renderer.isConflict(origStat, stat)) {
|
||||
throw new Error('conflict');
|
||||
} else {
|
||||
return await doSaveFile(stat == null);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async writeFile(path, data, enc) {
|
||||
if (!renderer.checkFileContent(data, enc)) {
|
||||
throw new Error('Invalid file data');
|
||||
} else {
|
||||
return await fsProm.writeFile(path, data, enc);
|
||||
}
|
||||
},
|
||||
|
||||
getAppDataFolder() {
|
||||
try {
|
||||
let appDataDir = app.getPath('appData');
|
||||
let drawioDir = appDataDir + '/' + config.name;
|
||||
|
||||
if (!fs.existsSync(drawioDir)) //Usually this dir already exists
|
||||
{
|
||||
fs.mkdirSync(drawioDir);
|
||||
}
|
||||
|
||||
return drawioDir;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
return '.';
|
||||
},
|
||||
|
||||
getDocumentsFolder() {
|
||||
//On windows, misconfigured Documents folder cause an exception
|
||||
try {
|
||||
return app.getPath('documents');
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
return '.';
|
||||
},
|
||||
|
||||
checkFileExists(pathParts) {
|
||||
let filePath = path.join(...pathParts);
|
||||
return {exists: fs.existsSync(filePath), path: filePath};
|
||||
},
|
||||
|
||||
async showOpenDialog(defaultPath, filters, properties) {
|
||||
let win = BrowserWindow.getFocusedWindow();
|
||||
|
||||
return dialog.showOpenDialog(win, {
|
||||
defaultPath: defaultPath,
|
||||
filters: filters,
|
||||
properties: properties
|
||||
});
|
||||
},
|
||||
|
||||
async showSaveDialog(defaultPath, filters) {
|
||||
let win = BrowserWindow.getFocusedWindow();
|
||||
|
||||
return dialog.showSaveDialog(win, {
|
||||
defaultPath: defaultPath,
|
||||
filters: filters
|
||||
});
|
||||
},
|
||||
|
||||
async installPlugin(filePath) {
|
||||
if (!enablePlugins) return {};
|
||||
|
||||
let pluginsDir = path.join(renderer.getAppDataFolder(), '/plugins');
|
||||
|
||||
if (!fs.existsSync(pluginsDir)) {
|
||||
fs.mkdirSync(pluginsDir);
|
||||
}
|
||||
|
||||
let pluginName = path.basename(filePath);
|
||||
let dstFile = path.join(pluginsDir, pluginName);
|
||||
|
||||
if (fs.existsSync(dstFile)) {
|
||||
throw new Error('fileExists');
|
||||
} else {
|
||||
await fsProm.copyFile(filePath, dstFile);
|
||||
}
|
||||
|
||||
return {pluginName: pluginName, selDir: path.dirname(filePath)};
|
||||
},
|
||||
|
||||
getPluginFile(plugin) {
|
||||
if (!enablePlugins) return null;
|
||||
|
||||
const prefix = path.join(renderer.getAppDataFolder(), '/plugins/');
|
||||
const pluginFile = path.join(prefix, plugin);
|
||||
|
||||
if (pluginFile.startsWith(prefix) && fs.existsSync(pluginFile)) {
|
||||
return pluginFile;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
async uninstallPlugin(plugin) {
|
||||
const pluginFile = renderer.getPluginFile(plugin);
|
||||
|
||||
if (pluginFile != null) {
|
||||
fs.unlinkSync(pluginFile);
|
||||
}
|
||||
},
|
||||
|
||||
dirname(path_p) {
|
||||
return path.dirname(path_p);
|
||||
},
|
||||
|
||||
async readFile(filename, encoding) {
|
||||
let data = await fsProm.readFile(filename, encoding);
|
||||
|
||||
if (renderer.checkFileContent(data, encoding)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
throw new Error('Invalid file data');
|
||||
},
|
||||
|
||||
async fileStat(file) {
|
||||
return await fsProm.stat(file);
|
||||
},
|
||||
|
||||
async isFileWritable(file) {
|
||||
try {
|
||||
await fsProm.access(file, fs.constants.W_OK);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clipboardAction(method, data) {
|
||||
if (method == 'writeText') {
|
||||
clipboard.writeText(data);
|
||||
} else if (method == 'readText') {
|
||||
return clipboard.readText();
|
||||
} else if (method == 'writeImage') {
|
||||
clipboard.write({
|
||||
image:
|
||||
nativeImage.createFromDataURL(data.dataUrl), html: '<img src="' +
|
||||
data.dataUrl + '" width="' + data.w + '" height="' + data.h + '">'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async deleteFile(file) {
|
||||
// Reading the header of the file to confirm it is a file we can delete
|
||||
let fh = await fsProm.open(file, O_RDONLY);
|
||||
let buffer = Buffer.allocUnsafe(16);
|
||||
await fh.read(buffer, 0, 16);
|
||||
await fh.close();
|
||||
|
||||
if (renderer.checkFileContent(buffer)) {
|
||||
await fsProm.unlink(file);
|
||||
}
|
||||
},
|
||||
|
||||
async windowAction(method) {
|
||||
let win = BrowserWindow.getFocusedWindow();
|
||||
|
||||
if (win) {
|
||||
if (method == 'minimize') {
|
||||
win.minimize();
|
||||
} else if (method == 'maximize') {
|
||||
win.maximize();
|
||||
} else if (method == 'unmaximize') {
|
||||
win.unmaximize();
|
||||
} else if (method == 'close') {
|
||||
win.close();
|
||||
} else if (method == 'isMaximized') {
|
||||
return win.isMaximized();
|
||||
} else if (method == 'removeAllListeners') {
|
||||
win.removeAllListeners();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async openExternal(url) {
|
||||
//Only open http(s), mailto, tel, and callto links
|
||||
if (allowedUrls.test(url)) {
|
||||
await shell.openExternal(url)
|
||||
}
|
||||
},
|
||||
|
||||
async watchFile(path) {
|
||||
let win = BrowserWindow.getFocusedWindow();
|
||||
|
||||
if (win) {
|
||||
fs.watchFile(path, (curr, prev) => {
|
||||
try {
|
||||
win.webContents.send('fileChanged', {
|
||||
path: path,
|
||||
curr: curr,
|
||||
prev: prev
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async unwatchFile(path) {
|
||||
fs.unwatchFile(path);
|
||||
},
|
||||
}
|
||||
|
||||
const onRenderer = (getMainWindow) => {
|
||||
ipcMain.on("rendererReq", async (event, args) => {
|
||||
try {
|
||||
let ret = null;
|
||||
|
||||
switch (args.action) {
|
||||
case 'saveFile':
|
||||
ret = await renderer.saveFile(args.fileObject, args.data, args.origStat, args.overwrite, args.defEnc);
|
||||
break;
|
||||
case 'writeFile':
|
||||
ret = await renderer.writeFile(args.path, args.data, args.enc);
|
||||
break;
|
||||
case 'saveDraft':
|
||||
ret = await renderer.saveDraft(args.fileObject, args.data);
|
||||
break;
|
||||
case 'getFileDrafts':
|
||||
ret = await renderer.getFileDrafts(args.fileObject);
|
||||
break;
|
||||
case 'getDocumentsFolder':
|
||||
ret = await renderer.getDocumentsFolder();
|
||||
break;
|
||||
case 'checkFileExists':
|
||||
ret = renderer.checkFileExists(args.pathParts);
|
||||
break;
|
||||
case 'showOpenDialog':
|
||||
dialogOpen = true;
|
||||
ret = await renderer.showOpenDialog(args.defaultPath, args.filters, args.properties);
|
||||
ret = ret.filePaths;
|
||||
dialogOpen = false;
|
||||
break;
|
||||
case 'showSaveDialog':
|
||||
dialogOpen = true;
|
||||
ret = await renderer.showSaveDialog(args.defaultPath, args.filters);
|
||||
ret = ret.canceled ? null : ret.filePath;
|
||||
dialogOpen = false;
|
||||
break;
|
||||
case 'installPlugin':
|
||||
ret = await renderer.installPlugin(args.filePath);
|
||||
break;
|
||||
case 'uninstallPlugin':
|
||||
ret = await renderer.uninstallPlugin(args.plugin);
|
||||
break;
|
||||
case 'getPluginFile':
|
||||
ret = renderer.getPluginFile(args.plugin);
|
||||
break;
|
||||
case 'isPluginsEnabled':
|
||||
ret = enablePlugins;
|
||||
break;
|
||||
case 'dirname':
|
||||
ret = await renderer.dirname(args.path);
|
||||
break;
|
||||
case 'readFile':
|
||||
ret = await renderer.readFile(args.filename, args.encoding);
|
||||
break;
|
||||
case 'clipboardAction':
|
||||
ret = renderer.clipboardAction(args.method, args.data);
|
||||
break;
|
||||
case 'deleteFile':
|
||||
ret = await renderer.deleteFile(args.file);
|
||||
break;
|
||||
case 'fileStat':
|
||||
ret = await renderer.fileStat(args.file);
|
||||
break;
|
||||
case 'isFileWritable':
|
||||
ret = await renderer.isFileWritable(args.file);
|
||||
break;
|
||||
case 'windowAction':
|
||||
ret = await renderer.windowAction(args.method);
|
||||
break;
|
||||
case 'openExternal':
|
||||
ret = await renderer.openExternal(args.url);
|
||||
break;
|
||||
case 'openDownloadWindow':
|
||||
ret = await electronDown.open(args.language || 'zh', args.theme || 'light');
|
||||
break;
|
||||
case 'updateDownloadWindow':
|
||||
ret = await electronDown.updateWindow(args.language, args.theme);
|
||||
break;
|
||||
case 'createDownload':
|
||||
ret = await electronDown.createDownload(getMainWindow(), args.url, args.options || {});
|
||||
break;
|
||||
case 'watchFile':
|
||||
ret = await renderer.watchFile(args.path);
|
||||
break;
|
||||
case 'unwatchFile':
|
||||
ret = await renderer.unwatchFile(args.path);
|
||||
break;
|
||||
case 'getCurDir':
|
||||
ret = __dirname;
|
||||
break;
|
||||
}
|
||||
|
||||
event.reply('mainResp', {success: true, data: ret, reqId: args.reqId});
|
||||
} catch (e) {
|
||||
event.reply('mainResp', {error: true, msg: e.message, e: e, reqId: args.reqId});
|
||||
loger.error('Renderer request error', e.message, e.stack);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { renderer, onRenderer };
|
||||
146
electron/lib/utils.js
vendored
146
electron/lib/utils.js
vendored
@@ -5,7 +5,7 @@ const dayjs = require("dayjs");
|
||||
const http = require('http')
|
||||
const https = require('https')
|
||||
const crypto = require('crypto')
|
||||
const {shell, dialog, session, Notification, nativeTheme} = require("electron");
|
||||
const {shell, session, net, Notification, nativeTheme} = require("electron");
|
||||
const loger = require("electron-log");
|
||||
const Store = require("electron-store");
|
||||
const store = new Store();
|
||||
@@ -291,6 +291,7 @@ const utils = {
|
||||
/**
|
||||
* 正则提取域名
|
||||
* @param weburl
|
||||
* @param toLowerCase
|
||||
* @returns {string|string}
|
||||
*/
|
||||
getDomain(weburl, toLowerCase = true) {
|
||||
@@ -327,35 +328,6 @@ const utils = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 窗口关闭事件
|
||||
* @param event
|
||||
* @param app
|
||||
*/
|
||||
onBeforeUnload(event, app) {
|
||||
return new Promise(resolve => {
|
||||
const contents = app.webContents
|
||||
if (contents != null) {
|
||||
contents.executeJavaScript(`if(typeof window.__onBeforeUnload === 'function'){window.__onBeforeUnload()}`, true).then(options => {
|
||||
if (utils.isJson(options)) {
|
||||
let choice = dialog.showMessageBoxSync(app, options)
|
||||
if (choice === 1) {
|
||||
contents.executeJavaScript(`if(typeof window.__removeBeforeUnload === 'function'){window.__removeBeforeUnload()}`, true).catch(() => {});
|
||||
resolve()
|
||||
}
|
||||
} else if (options !== true) {
|
||||
resolve()
|
||||
}
|
||||
}).catch(_ => {
|
||||
resolve()
|
||||
})
|
||||
event.preventDefault()
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 新窗口打开事件
|
||||
* @param webContents
|
||||
@@ -642,6 +614,94 @@ const utils = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取并验证 favicon,转换为 base64
|
||||
* @param {string} faviconUrl - favicon 的 URL
|
||||
* @param {number} timeout - 超时时间(毫秒),默认 5000
|
||||
* @returns {Promise<string|null>} - 成功返回 base64 data URL,失败返回 null
|
||||
*/
|
||||
async fetchFaviconAsBase64(faviconUrl, timeout = 5000) {
|
||||
if (!faviconUrl || typeof faviconUrl !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果已经是 base64,直接返回
|
||||
if (faviconUrl.startsWith('data:')) {
|
||||
return faviconUrl;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const request = net.request(faviconUrl);
|
||||
|
||||
// 设置超时
|
||||
const timeoutId = setTimeout(() => {
|
||||
request.abort();
|
||||
resolve(null);
|
||||
}, timeout);
|
||||
|
||||
const chunks = [];
|
||||
|
||||
request.on('response', (response) => {
|
||||
const contentType = response.headers['content-type'];
|
||||
// 验证是否为图片类型
|
||||
const isImage = contentType && (
|
||||
contentType.includes('image/') ||
|
||||
contentType.includes('icon')
|
||||
);
|
||||
|
||||
if (response.statusCode !== 200 || !isImage) {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
response.on('end', () => {
|
||||
clearTimeout(timeoutId);
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
// 验证图片数据有效(至少有一些字节)
|
||||
if (buffer.length < 10) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
// 获取正确的 MIME 类型
|
||||
let mimeType = 'image/png';
|
||||
if (contentType) {
|
||||
const match = contentType.match(/^([^;]+)/);
|
||||
if (match) {
|
||||
mimeType = match[1].trim();
|
||||
}
|
||||
}
|
||||
const base64 = buffer.toString('base64');
|
||||
resolve(`data:${mimeType};base64,${base64}`);
|
||||
} catch (e) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
response.on('error', () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
request.end();
|
||||
} catch (e) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 判断是否是本地URL
|
||||
* @param url
|
||||
@@ -649,11 +709,14 @@ const utils = {
|
||||
*/
|
||||
isLocalHost(url) {
|
||||
if (!url) {
|
||||
return false
|
||||
return true
|
||||
}
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const uri = new URL(url)
|
||||
return uri.hostname == "localhost"
|
||||
return uri.hostname === 'localhost'
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
@@ -680,6 +743,23 @@ const utils = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载内容 URL(自动判断完整 URL 或相对路径)
|
||||
* @param webContents - BrowserWindow 或 WebContents 对象
|
||||
* @param serverUrl - 服务器地址
|
||||
* @param url - 要加载的 URL(完整 URL 或相对路径)
|
||||
*/
|
||||
loadContentUrl(webContents, serverUrl, url) {
|
||||
if (!url) return;
|
||||
if (/^https?:/i.test(url)) {
|
||||
// 完整 URL 直接加载
|
||||
webContents.loadURL(url).then(_ => { }).catch(_ => { })
|
||||
} else {
|
||||
// 相对路径使用 loadUrl 处理
|
||||
utils.loadUrl(webContents, serverUrl, url)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取主题名称
|
||||
* @returns {string|*}
|
||||
@@ -698,7 +778,7 @@ const utils = {
|
||||
*/
|
||||
getDefaultBackgroundColor() {
|
||||
if (utils.getThemName() === "dark") {
|
||||
return "#0D0D0D";
|
||||
return "#202124";
|
||||
} else {
|
||||
return "#FFFFFF";
|
||||
}
|
||||
|
||||
2059
electron/lib/web-tab-manager.js
vendored
Normal file
2059
electron/lib/web-tab-manager.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user