Compare commits
295 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2239ea9f58 | ||
|
|
4b34932468 | ||
|
|
f4b623deb6 | ||
|
|
84f225f3f3 | ||
|
|
fbd1c829a1 | ||
|
|
82d2ca6360 | ||
|
|
717e520556 | ||
|
|
c8ddb511cf | ||
|
|
caf728de8d | ||
|
|
a7cd4d7fa8 | ||
|
|
ddc0046e24 | ||
|
|
1059630b9d | ||
|
|
e1c1fc030f | ||
|
|
09edb14d56 | ||
|
|
f27cef2d66 | ||
|
|
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 | ||
|
|
dc77f1cda1 | ||
|
|
1f791b528a | ||
|
|
1459d953ed | ||
|
|
719a36b275 | ||
|
|
0b7a3046fe | ||
|
|
203d107d68 | ||
|
|
17fd7f02a6 | ||
|
|
57ea4f2b6f | ||
|
|
df431eea46 | ||
|
|
ad9dd6330f | ||
|
|
df9d291f98 | ||
|
|
0cf7fc2ed2 | ||
|
|
e8f82baa99 | ||
|
|
353a05f344 | ||
|
|
d94ebfe04c | ||
|
|
52913abb4f | ||
|
|
d77406951d | ||
|
|
8c23192eeb | ||
|
|
078c9c198d | ||
|
|
6cfe2d226a | ||
|
|
fee1c12357 | ||
|
|
a6385b699e | ||
|
|
718ed8953f | ||
|
|
a1eea77b9e | ||
|
|
6eb08ac09b | ||
|
|
20fc2b073b | ||
|
|
8c4b9e8d12 | ||
|
|
8d187f5cfc | ||
|
|
db07a96e97 | ||
|
|
7acc9227ff | ||
|
|
c3a71e5b07 | ||
|
|
ac9e1e5e67 | ||
|
|
c668340661 | ||
|
|
ee9b6248bb | ||
|
|
01c7f7250b | ||
|
|
2abc5976f9 | ||
|
|
3e468c74e4 | ||
|
|
4ef78d2c81 | ||
|
|
4621222fa3 | ||
|
|
be860f9968 | ||
|
|
fe0b8aed20 | ||
|
|
f0e844c308 | ||
|
|
6a7cc95b23 | ||
|
|
7fd90b9ceb | ||
|
|
43577073e6 | ||
|
|
faeeb09a4a | ||
|
|
d88349b6f7 | ||
|
|
ff53e1fac3 | ||
|
|
cf4894b7c3 | ||
|
|
678dfd2d5c | ||
|
|
bf4a62ae04 | ||
|
|
7e6f3f92cf | ||
|
|
df382dafb4 | ||
|
|
10925d3a47 | ||
|
|
66252072c7 | ||
|
|
29918882bd | ||
|
|
4983fe8feb | ||
|
|
f65da118d7 | ||
|
|
a86bd9a05e | ||
|
|
f2719eb742 | ||
|
|
4f9ee1dfa9 | ||
|
|
e6ad1218bc | ||
|
|
dd2cd1df9a | ||
|
|
6dcbe8ba38 | ||
|
|
360d4dbbe2 | ||
|
|
2f32b53d19 | ||
|
|
6a3e3c3753 | ||
|
|
5ad08d8d36 |
83
.claude/skills/release/SKILL.md
Normal file
83
.claude/skills/release/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: release
|
||||
description: Use when releasing a new DooTask frontend version from the `pro` branch. Rigid sequential workflow (translate → version → build → commit → push) with strict pre-checks (branch, clean worktree, Node 20+) and per-step user confirmation. Use when user says "发布新版本", "release", "出新版本", "打版本". Stop on any failure; do NOT auto-fix dirty worktree, do NOT add tag step, do NOT use `git add -A`.
|
||||
---
|
||||
|
||||
# DooTask 发布流程
|
||||
|
||||
**刚性技能**——严格按顺序执行,每步向用户确认,任何一步失败立即停止。
|
||||
|
||||
## 核心原则
|
||||
|
||||
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并或重排步骤。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
执行任何发布步骤前,依次检查:
|
||||
|
||||
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
|
||||
```
|
||||
构建前端生产版本。
|
||||
|
||||
## 最终:提交并推送
|
||||
|
||||
所有步骤完成后:
|
||||
|
||||
1. 通过 `git diff` + `git status` 汇总所有变更,向用户报告摘要
|
||||
2. **询问用户是否提交并推送**
|
||||
3. 用户明确确认后才执行 `git add`、`git commit`、`git push`
|
||||
4. 未确认一律不执行
|
||||
|
||||
提交规范:
|
||||
- 提交信息使用 `release: v<新版本号>`(与历史提交风格一致,参见 `git log --oneline | grep '^release:'`)
|
||||
- **只 add 本次发布相关改动**,按文件名显式添加(例如 `git add package.json public/js/...`),**不要用 `git add -A` 或 `git add .`**,以免卷入未跟踪的本地实验文件
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,报告错误信息
|
||||
- **不要**自动重试
|
||||
- **不要**自动跳过失败步骤
|
||||
- 由用户决定如何处理
|
||||
|
||||
## 禁止项(基线测试暴露的反模式)
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 遇到脏工作区主动提出修复方案(加 `.gitignore`、先 push 等) | **停下**,报告脏工作区事实,交用户决定 |
|
||||
| 增加 `git tag v1.7.xx` 步骤 | DooTask 现行发布流程**不打 tag**,不要擅自添加 |
|
||||
| `git add -A` / `git add .` | 按文件名显式添加发布相关改动 |
|
||||
| 一次性 add + commit + push,不给确认机会 | 摘要 → 问确认 → 再 add/commit/push 三步分离 |
|
||||
| 把 translate/version/build 顺序自作主张调整 | 顺序固定为 translate → version → build |
|
||||
| 失败后"我再试一次"或"跳过这步" | 立即停止,交还给用户 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "这个脏工作区我来帮 TA 搞定一下" → 停下,交用户
|
||||
- "顺便打个 tag 吧" → 不,没有这一步
|
||||
- "`git add -A` 省事" → 不,显式 add
|
||||
- "翻译这步没改动可以跳" → 不,按顺序执行、执行后报告结果即可
|
||||
- "一起 commit + push 一气呵成" → 必须先让用户确认
|
||||
139
.github/workflows/publish.yml
vendored
139
.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
|
||||
@@ -166,8 +131,6 @@ jobs:
|
||||
include:
|
||||
- platform: "macos-latest"
|
||||
build_type: "mac"
|
||||
- platform: "ubuntu-latest"
|
||||
build_type: "android"
|
||||
- platform: "windows-latest"
|
||||
build_type: "windows"
|
||||
|
||||
@@ -182,68 +145,8 @@ jobs:
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
# Android 构建步骤
|
||||
- name: (Android) Build Js
|
||||
if: matrix.build_type == 'android'
|
||||
uses: nick-fields/retry@v2
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: |
|
||||
git submodule init
|
||||
git submodule update --remote "resources/mobile"
|
||||
./cmd appbuild publish
|
||||
|
||||
- name: (Android) Setup JDK 11
|
||||
if: matrix.build_type == 'android'
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "11"
|
||||
|
||||
- name: (Android) Build App
|
||||
if: matrix.build_type == 'android'
|
||||
uses: nick-fields/retry@v2
|
||||
with:
|
||||
timeout_minutes: 20
|
||||
max_attempts: 5
|
||||
command: |
|
||||
cd resources/mobile/platforms/android/eeuiApp
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleRelease --quiet
|
||||
|
||||
- name: (Android) Upload File
|
||||
if: matrix.build_type == 'android'
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
run: |
|
||||
node ./electron/build.js android-upload
|
||||
|
||||
- name: (Android) Upload Release
|
||||
if: matrix.build_type == 'android'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const globby = require('globby');
|
||||
|
||||
// 查找 APK 文件
|
||||
const files = await globby('resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release/*.apk');
|
||||
|
||||
for (const file of files) {
|
||||
const data = await fs.promises.readFile(file);
|
||||
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.RELEASE_ID,
|
||||
name: path.basename(file),
|
||||
data: data
|
||||
});
|
||||
}
|
||||
# 移动端构建已迁移到 dootask-app 仓库(EAS Build),见
|
||||
# dootask-app/.github/workflows/build.yml
|
||||
|
||||
# Mac 构建步骤
|
||||
- name: (Mac) Build Client
|
||||
@@ -253,7 +156,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 +167,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 +199,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
|
||||
|
||||
45
.github/workflows/sync-gitee.yml
vendored
Normal file
45
.github/workflows/sync-gitee.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: "Sync to Gitee"
|
||||
|
||||
# Required GitHub Secrets:
|
||||
#
|
||||
# GITEE_SSH_PRIVATE_KEY - SSH private key with push access to gitee.com/aipaw/dootask
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Publish"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Push to Gitee
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup SSH key
|
||||
env:
|
||||
GITEE_SSH_PRIVATE_KEY: ${{ secrets.GITEE_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$GITEE_SSH_PRIVATE_KEY" > ~/.ssh/gitee_key
|
||||
chmod 600 ~/.ssh/gitee_key
|
||||
cat >> ~/.ssh/config << EOF
|
||||
Host gitee.com
|
||||
HostName gitee.com
|
||||
IdentityFile ~/.ssh/gitee_key
|
||||
StrictHostKeyChecking no
|
||||
EOF
|
||||
|
||||
- name: Push to Gitee
|
||||
run: |
|
||||
git remote add gitee git@gitee.com:aipaw/dootask.git
|
||||
git push gitee pro
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: rm -rf ~/.ssh/gitee_key
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,6 +23,9 @@
|
||||
vars.yaml
|
||||
|
||||
# IDE and editor files
|
||||
.cursor/*
|
||||
!.cursor/rules/
|
||||
!.cursor/rules/**
|
||||
.idea
|
||||
.vscode
|
||||
.windsurfrules
|
||||
@@ -57,5 +60,4 @@ laravels.pid
|
||||
.DS_Store
|
||||
|
||||
# Documentation
|
||||
AGENTS.md
|
||||
README_LOCAL.md
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,6 +1,3 @@
|
||||
[submodule "resources/drawio"]
|
||||
path = resources/drawio
|
||||
url = https://github.com/jgraph/drawio.git
|
||||
[submodule "resources/mobile"]
|
||||
path = resources/mobile
|
||||
url = https://github.com/kuaifan/dootask-app.git
|
||||
|
||||
209
CHANGELOG.md
209
CHANGELOG.md
@@ -2,6 +2,215 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.7.23]
|
||||
|
||||
### Features
|
||||
|
||||
- 支持使用非邮箱形式的用户名登录,登录方式更灵活,也更适合接入常见的企业账号环境。
|
||||
- 进一步优化与 Active Directory 的兼容性,企业用户接入和登录更顺畅。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复部分企业账号环境下的登录问题,提升账号验证的稳定性和成功率。
|
||||
- 修复上传或发布失败时提示不明确的问题,方便更快发现并处理失败情况。
|
||||
|
||||
## [1.7.20]
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 优化了 LDAP 登录方式,更好兼容 Active Directory,企业账号登录更稳定。
|
||||
|
||||
## [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
|
||||
|
||||
- 优化消息列表工具的说明,让你更容易理解和使用相关功能。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 调整任务与子任务的进度展示方式,让进度显示更加准确一致。
|
||||
- 优化子任务相关数据的加载方式,减少不必要的请求,提升使用流畅度。
|
||||
- 修复 Android 16 系统返回键不能正常使用的问题,现在返回操作更加顺畅友好。
|
||||
|
||||
## [1.4.99]
|
||||
|
||||
### Features
|
||||
|
||||
- 优化群组资料修改方式,增加权限校验和名称修改提醒,减少误改、改错的情况。
|
||||
- 调整群组名称编辑入口,改为更明显的修改按钮,更好理解也更好用。
|
||||
- 优化微应用菜单和配置逻辑,兼容旧版本配置,减少升级后菜单不显示或打不开的问题。
|
||||
|
||||
## [1.4.88]
|
||||
|
||||
### Features
|
||||
|
||||
- 新增导航功能,支持快捷键和鼠标手势,操作更顺手高效
|
||||
- 优化顶部胶囊区域的显示逻辑,显示更智能更贴合使用场景
|
||||
- 更新内置应用商店版本,带来更稳定的应用安装与更新体验
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复部分情况下无法打开微应用的问题,使用更稳定
|
||||
|
||||
## [1.4.81]
|
||||
|
||||
### Features
|
||||
|
||||
- 优化周报、日报中已完成和未完成任务的统计规则,任务数据更准确。
|
||||
- 改进消息推送逻辑,并支持点击消息直接打开相关微应用,查看内容更便捷。
|
||||
- 更新内置应用商店版本,提升整体可用性和稳定性。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复任务导出时状态判断错误和状态高亮列错位的问题,导出结果更清晰。
|
||||
- 修复任务统计导出时,部分无计划时间但已完成的任务会被漏掉的问题,统计更完整。
|
||||
- 修复关闭应用时加载状态未及时更新的问题,应用退出过程更自然。
|
||||
- 调整弹窗的最小高度设置,显示更加美观。
|
||||
- 提升内嵌页面的安全设置,使用更放心。
|
||||
|
||||
## [1.4.67]
|
||||
|
||||
### Features
|
||||
|
||||
- 支持为每个人设置「未完成任务上限」,避免一次接太多任务,方便控制工作节奏。
|
||||
- 任务列表支持“一键归档已完成任务”,让列表更清爽。
|
||||
- 支持自定义微应用菜单,可按实际需要配置和保存菜单项,入口更符合团队习惯。
|
||||
- 调整窗口和各组件的高度表现,在不同窗口大小下内容显示更合理。
|
||||
- 更新默认智能助手模型为更高效版本,回答速度和质量更均衡。
|
||||
- 优化文件管理页面展示效果:列表更清晰,文件内容与侧边抽屉的外观更统一。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复桌面端部分设备在打开新窗口时可能出现的报错问题,使用更稳定。
|
||||
- 修复部分组件在全屏或窗口变化时高度计算不准确的问题,避免内容被遮挡或留白过多。
|
||||
- 修正微模态弹窗的定位问题,在全屏场景下显示更正常。
|
||||
|
||||
## [1.4.43]
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复登录后出现404的问题
|
||||
|
||||
## [1.4.35]
|
||||
|
||||
### 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)`——禁止拼接翻译
|
||||
|
||||
## 交互规范
|
||||
|
||||
- **提问时附带建议**:当需要向用户提问或请求澄清时,应同时提供具体的建议选项或推荐方案,帮助用户快速决策,而非仅抛出开放式问题
|
||||
|
||||
## 语言偏好
|
||||
|
||||
- 技术总结和关键结论优先使用简体中文,除非用户明确要求其他语言
|
||||
@@ -18,14 +18,25 @@ npm run build # 编译前端
|
||||
说明:
|
||||
|
||||
- 执行 `npm run build` 作用是生成网页端;
|
||||
- 客户端 (Windows、Mac、Android) 会通过 GitHub Actions 自动生成并发布;所以,如果要自动发布只需要提交git并推送即可;
|
||||
- 如果想手动生成客户端执行 `./cmd electron` 根据提示选择操作。
|
||||
- 桌面客户端(Windows、Mac)会通过 GitHub Actions 自动生成并发布;所以,如果要自动发布只需要提交 git 并推送即可;
|
||||
- 如果想手动生成桌面客户端执行 `./cmd electron` 根据提示选择操作。
|
||||
|
||||
## 编译移动端 App
|
||||
|
||||
## 编译 App
|
||||
移动端(iOS / Android)已迁移到独立仓库 [kuaifan/dootask-app](https://github.com/kuaifan/dootask-app)
|
||||
(Expo + EAS Build)。构建流程:
|
||||
|
||||
```shell
|
||||
./cmd appbuild publish # 编译生成App需要的资源
|
||||
# 1. 本仓库:构建前端资源
|
||||
./cmd appbuild
|
||||
|
||||
# 2. 拷贝到 dootask-app 仓库
|
||||
cp -r public/* ~/workspaces/dootask-app/assets/web/
|
||||
|
||||
# 3. dootask-app 仓库:EAS Build 本地或 CI 触发
|
||||
cd ~/workspaces/dootask-app
|
||||
npx eas build --platform android --profile preview
|
||||
# 或在 dootask-app 的 GitHub Actions 里手动触发 "EAS Build" workflow
|
||||
```
|
||||
|
||||
编译完后进入 `resources/mobile` EEUI框架目录内打包 Android 或 iOS 应用(Android 以实现 GitHub Actions 自动发布)
|
||||
详见 dootask-app 仓库的 README。
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\AiAssistantSession;
|
||||
use App\Models\User;
|
||||
use App\Module\AI;
|
||||
use App\Module\Apps;
|
||||
@@ -70,4 +71,237 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
public function session__list()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
|
||||
$sessions = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey)
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
|
||||
$list = [];
|
||||
foreach ($sessions as $session) {
|
||||
$data = Base::json2array($session->data);
|
||||
$images = Base::json2array($session->images);
|
||||
foreach ($images as $imageId => $path) {
|
||||
$images[$imageId] = Base::fillUrl($path);
|
||||
}
|
||||
$list[] = [
|
||||
'id' => $session->session_id,
|
||||
'title' => $session->title,
|
||||
'responses' => $data,
|
||||
'images' => $images,
|
||||
'sceneKey' => $session->scene_key,
|
||||
'createdAt' => $session->created_at ? $session->created_at->getTimestampMs() : 0,
|
||||
'updatedAt' => $session->updated_at ? $session->updated_at->getTimestampMs() : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话
|
||||
*/
|
||||
public function session__save()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
$sessionId = trim(Request::input('session_id', ''));
|
||||
$sceneKey = trim(Request::input('scene_key', ''));
|
||||
$title = trim(Request::input('title', ''));
|
||||
$data = Request::input('data', []);
|
||||
$newImages = Request::input('new_images', []);
|
||||
|
||||
if (empty($sessionId)) {
|
||||
return Base::retError('session_id 不能为空');
|
||||
}
|
||||
|
||||
$newImageUrls = [];
|
||||
if (is_array($newImages)) {
|
||||
$path = 'uploads/assistant/' . date('Ym') . '/' . $user->userid . '/';
|
||||
foreach ($newImages as $img) {
|
||||
$imageId = $img['imageId'] ?? '';
|
||||
$dataUrl = $img['dataUrl'] ?? '';
|
||||
if (empty($imageId) || empty($dataUrl)) {
|
||||
continue;
|
||||
}
|
||||
$result = Base::image64save([
|
||||
'image64' => $dataUrl,
|
||||
'path' => $path,
|
||||
'autoThumb' => false,
|
||||
]);
|
||||
if (Base::isSuccess($result)) {
|
||||
$newImageUrls[$imageId] = $result['data']['path'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$session = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey)
|
||||
->where('session_id', $sessionId)
|
||||
->first();
|
||||
|
||||
$imageMap = $newImageUrls;
|
||||
if ($session) {
|
||||
$existingImages = Base::json2array($session->images);
|
||||
$imageMap = array_merge($existingImages, $newImageUrls);
|
||||
}
|
||||
|
||||
$session = AiAssistantSession::createInstance([
|
||||
'userid' => $user->userid,
|
||||
'session_key' => $sessionKey,
|
||||
'session_id' => $sessionId,
|
||||
'scene_key' => $sceneKey,
|
||||
'title' => mb_substr($title, 0, 255),
|
||||
'data' => Base::array2json(is_array($data) ? $data : []),
|
||||
'images' => Base::array2json($imageMap),
|
||||
], $session?->id);
|
||||
$session->save();
|
||||
|
||||
// 仅返回本次新增的图片URL
|
||||
$urls = [];
|
||||
foreach ($newImageUrls as $imageId => $path) {
|
||||
$urls[$imageId] = Base::fillUrl($path);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'image_urls' => $urls,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
public function session__delete()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
$sessionId = trim(Request::input('session_id', ''));
|
||||
$clearAll = Request::input('clear_all', false);
|
||||
|
||||
$query = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey);
|
||||
|
||||
if ($clearAll) {
|
||||
$sessions = $query->get();
|
||||
foreach ($sessions as $session) {
|
||||
$this->deleteSessionImages($session);
|
||||
}
|
||||
$query->delete();
|
||||
} else {
|
||||
if (empty($sessionId)) {
|
||||
return Base::retError('session_id 不能为空');
|
||||
}
|
||||
$session = $query->where('session_id', $sessionId)->first();
|
||||
if ($session) {
|
||||
$this->deleteSessionImages($session);
|
||||
$session->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return Base::retSuccess('success');
|
||||
}
|
||||
|
||||
private function deleteSessionImages(AiAssistantSession $session)
|
||||
{
|
||||
$images = Base::json2array($session->images);
|
||||
foreach ($images as $path) {
|
||||
$fullPath = public_path($path);
|
||||
if (file_exists($fullPath)) {
|
||||
@unlink($fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 获取单条消息
|
||||
*
|
||||
@@ -1025,6 +980,16 @@ class DialogController extends AbstractController
|
||||
return Base::retSuccess('success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 AI 助手生成消息
|
||||
*
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function msg__ai_generate()
|
||||
{
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/sendtext 发送消息
|
||||
*
|
||||
@@ -1350,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
|
||||
* - 默认:不翻译结果
|
||||
@@ -1371,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('说话时间太短');
|
||||
}
|
||||
@@ -1386,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;
|
||||
}
|
||||
@@ -1720,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 发送位置消息
|
||||
*
|
||||
@@ -1988,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;
|
||||
}
|
||||
@@ -2227,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');
|
||||
@@ -2237,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("消息不存在或已被删除");
|
||||
@@ -2246,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回复
|
||||
*
|
||||
@@ -2419,6 +2623,7 @@ class DialogController extends AbstractController
|
||||
$id = intval(Request::input("id"));
|
||||
//
|
||||
$add = [];
|
||||
$update = [];
|
||||
$todo = WebSocketDialogMsgTodo::whereId($id)->whereUserid($user->userid)->first();
|
||||
if ($todo && empty($todo->done_at)) {
|
||||
$todo->done_at = Carbon::now();
|
||||
@@ -2426,16 +2631,45 @@ class DialogController extends AbstractController
|
||||
//
|
||||
$msg = WebSocketDialogMsg::find($todo->msg_id);
|
||||
if ($msg) {
|
||||
$res = WebSocketDialogMsg::sendMsg(null, $todo->dialog_id, 'todo', [
|
||||
'action' => 'done',
|
||||
'data' => [
|
||||
'id' => $msg->id,
|
||||
'type' => $msg->type,
|
||||
'msg' => $msg->quoteTextMsg(),
|
||||
]
|
||||
]);
|
||||
if (Base::isSuccess($res)) {
|
||||
$add = $res['data'];
|
||||
$doneUserIds = WebSocketDialogMsgTodo::whereMsgId($msg->id)
|
||||
->whereNotNull('done_at')
|
||||
->orderByDesc('done_at')
|
||||
->orderByDesc('id')
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
//
|
||||
$lastMsg = WebSocketDialogMsg::whereDialogId($todo->dialog_id)->orderByDesc('id')->first();
|
||||
if ($lastMsg && $lastMsg->type === 'todo') {
|
||||
$lastMsgData = $lastMsg->msg;
|
||||
$lastData = $lastMsgData['data'] ?? [];
|
||||
if (($lastMsgData['action'] ?? '') === 'done' && intval($lastData['id'] ?? 0) === $msg->id) {
|
||||
$lastData['done_userids'] = $doneUserIds;
|
||||
$lastMsgData['data'] = $lastData;
|
||||
$lastMsg->updateInstance(['msg' => $lastMsgData]);
|
||||
$lastMsg->save();
|
||||
$update = [
|
||||
'id' => $lastMsg->id,
|
||||
'dialog_id' => $lastMsg->dialog_id,
|
||||
'type' => $lastMsg->type,
|
||||
'msg' => $lastMsgData,
|
||||
];
|
||||
$lastMsg->webSocketDialog?->pushMsg('update', $update);
|
||||
}
|
||||
}
|
||||
//
|
||||
if (empty($update)) {
|
||||
$res = WebSocketDialogMsg::sendMsg(null, $todo->dialog_id, 'todo', [
|
||||
'action' => 'done',
|
||||
'data' => [
|
||||
'id' => $msg->id,
|
||||
'type' => $msg->type,
|
||||
'msg' => $msg->quoteTextMsg(),
|
||||
'done_userids' => $doneUserIds,
|
||||
]
|
||||
]);
|
||||
if (Base::isSuccess($res)) {
|
||||
$add = $res['data'];
|
||||
}
|
||||
}
|
||||
//
|
||||
$msg->webSocketDialog?->pushMsg('update', [
|
||||
@@ -2448,7 +2682,8 @@ class DialogController extends AbstractController
|
||||
}
|
||||
//
|
||||
return Base::retSuccess("待办已完成", [
|
||||
'add' => $add ?: null
|
||||
'add' => $add ?: null,
|
||||
'update' => $update ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2493,6 +2728,16 @@ class DialogController extends AbstractController
|
||||
return Base::retSuccess("success", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为AI对话
|
||||
*
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function msg__webhookmsg2ai()
|
||||
{
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/group/add 新增群组
|
||||
*
|
||||
@@ -3245,6 +3490,16 @@ class DialogController extends AbstractController
|
||||
return Base::retSuccess('success', $topMsg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记消息已应用
|
||||
*
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function msg__applied()
|
||||
{
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/sticker/search 搜索在线表情
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
@@ -1184,6 +1226,7 @@ class ProjectController extends AbstractController
|
||||
$builder->leftJoinSub(function ($query) {
|
||||
$query->select('parent_id', DB::raw('count(*) as sub_num, sum(CASE WHEN complete_at IS NOT NULL THEN 1 ELSE 0 END) sub_complete') )
|
||||
->from('project_tasks')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('parent_id');
|
||||
}, 'sub_task', 'sub_task.parent_id', '=', 'project_tasks.id');
|
||||
// 给前缀“_”是为了不触发获取器
|
||||
@@ -1220,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) {
|
||||
@@ -1359,11 +1410,30 @@ class ProjectController extends AbstractController
|
||||
'style' => 'font-weight: bold;padding-bottom: 4px;',
|
||||
];
|
||||
//
|
||||
$startTime = Carbon::parse($time[0])->startOfDay();
|
||||
$endTime = Carbon::parse($time[1])->endOfDay();
|
||||
$builder = ProjectTask::with(['taskTag'])->select(['project_tasks.*', 'project_task_users.userid as ownerid'])
|
||||
->join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->where('project_task_users.owner', 1)
|
||||
->whereIn('project_task_users.userid', $userid)
|
||||
->betweenTime(Carbon::parse($time[0])->startOfDay(), Carbon::parse($time[1])->endOfDay(), $type);
|
||||
->whereIn('project_task_users.userid', $userid);
|
||||
// 按导出时间类型筛选:
|
||||
// - createdTime:仅按创建时间范围筛选;
|
||||
// - 任务时间(默认):优先使用任务计划时间筛选,但对“无计划时间”的任务,
|
||||
// 若在考核期内已完成,则按完成时间 complete_at 兜底纳入导出,避免漏掉考核期内完成的任务。
|
||||
if ($type === 'createdTime') {
|
||||
$builder->betweenTime($startTime, $endTime, $type);
|
||||
} else {
|
||||
$builder->where(function ($query) use ($startTime, $endTime) {
|
||||
$query->betweenTime($startTime, $endTime, 'taskTime')
|
||||
->orWhere(function ($q2) use ($startTime, $endTime) {
|
||||
$q2->where(function ($q3) {
|
||||
$q3->whereNull('project_tasks.start_at')
|
||||
->orWhereNull('project_tasks.end_at');
|
||||
})->whereNotNull('project_tasks.complete_at')
|
||||
->whereBetween('project_tasks.complete_at', [$startTime, $endTime]);
|
||||
});
|
||||
});
|
||||
}
|
||||
$builder->orderByDesc('project_tasks.id')->chunk(100, function ($tasks) use ($doo, &$datas) {
|
||||
/** @var ProjectTask $task */
|
||||
foreach ($tasks as $task) {
|
||||
@@ -1409,15 +1479,17 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
$actualTime = $task->complete_at ? $totalTime : 0; // 实际完成用时
|
||||
$statusText = '未完成';
|
||||
// 状态判定规则:
|
||||
// - flow_item_name 以 end| 开头:视为结束态,区分“已取消”和“已完成”
|
||||
// - 非 end|,但 complete_at 有值:视为已完成(兼容无流程或历史数据)
|
||||
if (str_starts_with($task->flow_item_name, 'end')) {
|
||||
if (preg_match('/已取消|Cancelled|취소됨|キャンセル済み|Abgebrochen|Annulé|Dibatalkan|Отменено/', $task->flow_item_name)) {
|
||||
$statusText = '已完成';
|
||||
if (ProjectTask::isCanceledFlowName($task->flow_item_name)) {
|
||||
$statusText = '已取消';
|
||||
$actualTime = 0;
|
||||
$testTime = 0;
|
||||
$developTime = 0;
|
||||
$overTime = '-';
|
||||
} elseif (str_contains($task->flow_item_name, '已完成')) {
|
||||
$statusText = '已完成';
|
||||
}
|
||||
} elseif ($task->complete_at) {
|
||||
$statusText = '已完成';
|
||||
@@ -1426,15 +1498,15 @@ class ProjectController extends AbstractController
|
||||
$datas[$task->ownerid] = [
|
||||
'index' => 1,
|
||||
'nickname' => Base::filterEmoji(User::userid2nickname($task->ownerid)),
|
||||
'styles' => ["A1:P1" => ["font" => ["bold" => true]]],
|
||||
'styles' => ["A1:Q1" => ["font" => ["bold" => true]]],
|
||||
'data' => [],
|
||||
];
|
||||
}
|
||||
$datas[$task->ownerid]['index']++;
|
||||
if ($statusText === '未完成') {
|
||||
$datas[$task->ownerid]['styles']["P{$datas[$task->ownerid]['index']}"] = ["font" => ["color" => ["rgb" => "ff0000"]]]; // 未完成
|
||||
$datas[$task->ownerid]['styles']["Q{$datas[$task->ownerid]['index']}"] = ["font" => ["color" => ["rgb" => "ff0000"]]]; // 未完成
|
||||
} elseif ($statusText === '已完成' && $task->end_at && Carbon::parse($task->complete_at)->gt($task->end_at)) {
|
||||
$datas[$task->ownerid]['styles']["P{$datas[$task->ownerid]['index']}"] = ["font" => ["color" => ["rgb" => "436FF6"]]]; // 已完成超期
|
||||
$datas[$task->ownerid]['styles']["Q{$datas[$task->ownerid]['index']}"] = ["font" => ["color" => ["rgb" => "436FF6"]]]; // 已完成超期
|
||||
}
|
||||
$datas[$task->ownerid]['data'][] = [
|
||||
$task->id,
|
||||
@@ -1476,7 +1548,7 @@ class ProjectController extends AbstractController
|
||||
foreach ($userid as $ownerid) {
|
||||
$data = $datas[$ownerid] ?? [
|
||||
'nickname' => Base::filterEmoji(User::userid2nickname($ownerid)),
|
||||
'styles' => ["A1:P1" => ["font" => ["bold" => true]]],
|
||||
'styles' => ["A1:Q1" => ["font" => ["bold" => true]]],
|
||||
'data' => [],
|
||||
];
|
||||
$title = (count($sheets) + 1) . "." . ($data['nickname'] ?: $ownerid);
|
||||
@@ -1923,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 获取任务详细描述
|
||||
*
|
||||
@@ -2221,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,
|
||||
@@ -2342,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;
|
||||
@@ -2458,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()) {
|
||||
// 已经存在负责人,则需要检查权限(即:没有任务负责人时,不检查权限)
|
||||
@@ -2994,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 = [
|
||||
@@ -3014,6 +3126,11 @@ class ProjectController extends AbstractController
|
||||
$copy->addLog('复制{任务}', [
|
||||
'copy_from' => $task->id,
|
||||
]);
|
||||
// 复制子任务
|
||||
$task->copySubTasks($copy, [
|
||||
'reset_complete' => true,
|
||||
'update_project' => true,
|
||||
]);
|
||||
return $copy;
|
||||
});
|
||||
//
|
||||
@@ -3024,6 +3141,26 @@ class ProjectController extends AbstractController
|
||||
return Base::retSuccess('复制成功', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 AI 助手生成任务
|
||||
*
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function task__ai_generate()
|
||||
{
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 AI 助手生成项目
|
||||
*
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function ai__generate()
|
||||
{
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/flow/list 工作流列表
|
||||
*
|
||||
@@ -3759,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']}%");
|
||||
}
|
||||
@@ -327,6 +339,13 @@ class ReportController extends AbstractController
|
||||
$start_time->startOfWeek();
|
||||
$end_time = Carbon::instance($start_time)->endOfWeek();
|
||||
}
|
||||
// 周报时预计算下一周期时间范围(下周)
|
||||
$next_start_time = null;
|
||||
$next_end_time = null;
|
||||
if ($type === Report::WEEKLY) {
|
||||
$next_start_time = Carbon::instance($start_time)->copy()->addWeek();
|
||||
$next_end_time = Carbon::instance($end_time)->copy()->addWeek();
|
||||
}
|
||||
|
||||
// 生成唯一标识
|
||||
$sign = Report::generateSign($type, 0, Carbon::instance($start_time));
|
||||
@@ -362,6 +381,10 @@ class ReportController extends AbstractController
|
||||
->get();
|
||||
if ($complete_task->isNotEmpty()) {
|
||||
foreach ($complete_task as $task) {
|
||||
// 排除取消态任务:不将已取消任务计入“已完成工作”
|
||||
if (ProjectTask::isCanceledFlowName($task->flow_item_name)) {
|
||||
continue;
|
||||
}
|
||||
$complete_at = Carbon::parse($task->complete_at);
|
||||
$remark = $type == Report::WEEKLY ? ('<div style="text-align:center">[' . Doo::translate('周' . ['日', '一', '二', '三', '四', '五', '六'][$complete_at->dayOfWeek]) . ']</div>') : ' ';
|
||||
$completeDatas[] = [
|
||||
@@ -377,18 +400,7 @@ class ReportController extends AbstractController
|
||||
|
||||
// 未完成的任务
|
||||
$unfinishedDatas = [];
|
||||
$unfinished_task = ProjectTask::query()
|
||||
->join("projects", "projects.id", "=", "project_tasks.project_id")
|
||||
->whereNull("projects.archived_at")
|
||||
->whereNull("project_tasks.complete_at")
|
||||
->whereNotNull("project_tasks.start_at")
|
||||
->where("project_tasks.end_at", "<", $end_time->toDateTimeString())
|
||||
->whereHas("taskUser", function ($query) use ($user) {
|
||||
$query->where("userid", $user->userid);
|
||||
})
|
||||
->select("project_tasks.*")
|
||||
->orderByDesc("project_tasks.id")
|
||||
->get();
|
||||
$unfinished_task = ProjectTask::buildUnfinishedTaskQuery($user->userid, $start_time, $end_time, true)->get();
|
||||
if ($unfinished_task->isNotEmpty()) {
|
||||
foreach ($unfinished_task as $task) {
|
||||
empty($task->end_at) || $end_at = Carbon::parse($task->end_at);
|
||||
@@ -408,8 +420,10 @@ class ReportController extends AbstractController
|
||||
if ($type === Report::WEEKLY) {
|
||||
$title = $user->nickname . "的周报[" . $start_time->format("m/d") . "-" . $end_time->format("m/d") . "]";
|
||||
$title .= "[" . $start_time->month . "月第" . $start_time->weekOfMonth . "周]";
|
||||
$unfinishedTitle = '本周未完成的工作';
|
||||
} else {
|
||||
$title = $user->nickname . "的日报[" . $start_time->format("Y/m/d") . "]";
|
||||
$unfinishedTitle = '今日未完成的工作';
|
||||
}
|
||||
$title = Doo::translate($title);
|
||||
|
||||
@@ -422,22 +436,44 @@ class ReportController extends AbstractController
|
||||
])->render();
|
||||
|
||||
$contents[] = '<p> </p>';
|
||||
$contents[] = '<h2>' . Doo::translate('未完成的工作') . '</h2>';
|
||||
$contents[] = '<h2>' . Doo::translate($unfinishedTitle) . '</h2>';
|
||||
$contents[] = view('report', [
|
||||
'labels' => $labels,
|
||||
'datas' => $unfinishedDatas,
|
||||
])->render();
|
||||
|
||||
if ($type === Report::WEEKLY) {
|
||||
// 下周拟定计划:基于下周时间范围预生成候选任务
|
||||
$nextPlanDatas = [];
|
||||
if ($next_start_time && $next_end_time) {
|
||||
$next_tasks = ProjectTask::buildUnfinishedTaskQuery($user->userid, $next_start_time, $next_end_time, false)->get();
|
||||
if ($next_tasks->isNotEmpty()) {
|
||||
foreach ($next_tasks as $task) {
|
||||
$planTime = '-';
|
||||
if ($task->start_at || $task->end_at) {
|
||||
$startText = $task->start_at ? Carbon::parse($task->start_at)->format('Y-m-d H:i') : '';
|
||||
$endText = $task->end_at ? Carbon::parse($task->end_at)->format('Y-m-d H:i') : '';
|
||||
$planTime = trim($startText . ($endText ? (' ~ ' . $endText) : ''));
|
||||
}
|
||||
$nextPlanDatas[] = [
|
||||
'[' . $task->project->name . '] ' . $task->name,
|
||||
$planTime,
|
||||
$task->taskUser->where("owner", 1)->map(function ($item) {
|
||||
return User::userid2nickname($item->userid);
|
||||
})->implode(", "),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$contents[] = '<p> </p>';
|
||||
$contents[] = "<h2>" . Doo::translate("下周拟定计划") . "[" . $start_time->addWeek()->format("m/d") . "-" . $end_time->addWeek()->format("m/d") . "]</h2>";
|
||||
$contents[] = "<h2>" . Doo::translate("下周拟定计划") . "[" . $next_start_time->format("m/d") . "-" . $next_end_time->format("m/d") . "]</h2>";
|
||||
$contents[] = view('report', [
|
||||
'labels' => [
|
||||
Doo::translate('计划描述'),
|
||||
Doo::translate('计划时间'),
|
||||
Doo::translate('负责人'),
|
||||
],
|
||||
'datas' => [],
|
||||
'datas' => $nextPlanDatas,
|
||||
])->render();
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ class SystemController extends AbstractController
|
||||
* @apiParam {String} type
|
||||
* - get: 获取(默认)
|
||||
* - all: 获取所有(需要管理员权限)
|
||||
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'convert_video', 'compress_video', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'system_alias', 'system_welcome', 'image_compress', 'image_quality', 'image_save_local'])
|
||||
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'convert_video', 'compress_video', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'task_user_limit', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'system_alias', 'system_welcome', 'image_compress', 'image_quality', 'image_save_local'])
|
||||
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -80,6 +80,7 @@ class SystemController extends AbstractController
|
||||
'archived_day',
|
||||
'task_visible',
|
||||
'task_default_time',
|
||||
'task_user_limit',
|
||||
'all_group_mute',
|
||||
'all_group_autoin',
|
||||
'user_private_chat_mute',
|
||||
@@ -92,6 +93,7 @@ class SystemController extends AbstractController
|
||||
'file_upload_limit',
|
||||
'unclaimed_task_reminder',
|
||||
'unclaimed_task_reminder_time',
|
||||
'task_ai_auto_analyze',
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
}
|
||||
@@ -145,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();
|
||||
//
|
||||
@@ -275,6 +278,16 @@ class SystemController extends AbstractController
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI助手设置(限管理员)
|
||||
*
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function setting__ai()
|
||||
{
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot 获取AI设置、保存AI机器人设置(限管理员)
|
||||
*
|
||||
@@ -333,6 +346,26 @@ class SystemController extends AbstractController
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取AI模型
|
||||
*
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function setting__aibot_models()
|
||||
{
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取AI默认模型
|
||||
*
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function setting__aibot_defmodels()
|
||||
{
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/checkin 获取签到设置、保存签到设置(限管理员)
|
||||
*
|
||||
@@ -425,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');
|
||||
@@ -561,6 +612,7 @@ class SystemController extends AbstractController
|
||||
'ldap_password',
|
||||
'ldap_user_dn',
|
||||
'ldap_base_dn',
|
||||
'ldap_login_attr',
|
||||
'ldap_sync_local'
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
@@ -574,6 +626,7 @@ class SystemController extends AbstractController
|
||||
//
|
||||
$setting['ldap_open'] = $setting['ldap_open'] ?: 'close';
|
||||
$setting['ldap_port'] = intval($setting['ldap_port']) ?: 389;
|
||||
$setting['ldap_login_attr'] = $setting['ldap_login_attr'] ?: 'cn';
|
||||
$setting['ldap_sync_local'] = $setting['ldap_sync_local'] ?: 'close';
|
||||
//
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
@@ -666,32 +719,62 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/microapp_menu 自定义应用菜单
|
||||
*
|
||||
* @apiDescription 获取或保存自定义微应用菜单,仅管理员可配置
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName microapp_menu
|
||||
*
|
||||
* @apiParam {String} type
|
||||
* - get: 获取(默认)
|
||||
* - save: 保存(限管理员)
|
||||
* @apiParam {Array} list 菜单列表,格式:[{id,name,version,menu_items}]
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function microapp_menu()
|
||||
{
|
||||
$type = trim(Request::input('type'));
|
||||
$user = User::auth();
|
||||
if ($type == 'save') {
|
||||
User::auth('admin');
|
||||
$list = Request::input('list');
|
||||
if (empty($list) || !is_array($list)) {
|
||||
$list = [];
|
||||
}
|
||||
$apps = Setting::normalizeCustomMicroApps($list);
|
||||
$setting = Base::setting('microapp_menu', $apps);
|
||||
$setting = Setting::formatCustomMicroAppsForResponse($setting);
|
||||
} else {
|
||||
$setting = Base::setting('microapp_menu');
|
||||
if (!is_array($setting)) {
|
||||
$setting = [];
|
||||
}
|
||||
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
|
||||
$setting = Setting::formatCustomMicroAppsForResponse($setting);
|
||||
}
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/column/template 创建项目模板
|
||||
*
|
||||
@@ -1210,6 +1293,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)) {
|
||||
@@ -1218,7 +1303,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 = [];
|
||||
@@ -1255,9 +1340,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;
|
||||
@@ -1471,7 +1557,8 @@ class SystemController extends AbstractController
|
||||
{
|
||||
$userAgent = strtolower(Request::server('HTTP_USER_AGENT'));
|
||||
$isMain = str_contains($userAgent, 'maintaskwindow');
|
||||
$isApp = str_contains($userAgent, 'kuaifan_eeui');
|
||||
$isApp = str_contains($userAgent, 'kuaifan_eeui')
|
||||
|| str_contains($userAgent, 'dootask_expo');
|
||||
$version = Base::getVersion();
|
||||
$array = [];
|
||||
|
||||
|
||||
@@ -300,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 返回数据
|
||||
@@ -307,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;
|
||||
@@ -320,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);
|
||||
}
|
||||
|
||||
@@ -377,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);
|
||||
@@ -662,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']}%")
|
||||
@@ -2824,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";
|
||||
}
|
||||
@@ -454,12 +457,16 @@ class IndexController extends InvokeController
|
||||
'button' => Doo::translate('点击下载'),
|
||||
]);
|
||||
}
|
||||
// 浏览器类型
|
||||
// 浏览器类型(兼容旧 EEUI 与新 Expo 壳)
|
||||
$browser = 'none';
|
||||
if (str_contains($userAgent, 'chrome') || str_contains($userAgent, 'android_kuaifan_eeui')) {
|
||||
$browser = str_contains($userAgent, 'android_kuaifan_eeui') ? 'android-mobile' : 'chrome-desktop';
|
||||
} elseif (str_contains($userAgent, 'safari') || str_contains($userAgent, 'ios_kuaifan_eeui')) {
|
||||
$browser = str_contains($userAgent, 'ios_kuaifan_eeui') ? 'safari-mobile' : 'safari-desktop';
|
||||
$isAndroidApp = str_contains($userAgent, 'android_kuaifan_eeui')
|
||||
|| str_contains($userAgent, 'android_dootask_expo');
|
||||
$isIosApp = str_contains($userAgent, 'ios_kuaifan_eeui')
|
||||
|| str_contains($userAgent, 'ios_dootask_expo');
|
||||
if (str_contains($userAgent, 'chrome') || $isAndroidApp) {
|
||||
$browser = $isAndroidApp ? 'android-mobile' : 'chrome-desktop';
|
||||
} elseif (str_contains($userAgent, 'safari') || $isIosApp) {
|
||||
$browser = $isIosApp ? 'safari-mobile' : 'safari-desktop';
|
||||
}
|
||||
// electron 直接在线预览查看
|
||||
if (str_contains($userAgent, 'electron') || str_contains($browser, 'desktop')) {
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Http\Middleware;
|
||||
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Services\RequestContext;
|
||||
use Cache;
|
||||
use Closure;
|
||||
|
||||
class WebApi
|
||||
@@ -23,12 +25,26 @@ 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);
|
||||
|
||||
// 加载Doo类
|
||||
Doo::load();
|
||||
|
||||
// 记录 PC 端活跃时间
|
||||
$userid = Doo::userId();
|
||||
if ($userid > 0 && Base::isPc()) {
|
||||
Cache::put("user_pc_active:{$userid}", time(), 60);
|
||||
}
|
||||
|
||||
// 解密请求内容
|
||||
$encrypt = Doo::pgpParseStr($request->header('encrypt'));
|
||||
if ($request->isMethod('post')) {
|
||||
@@ -48,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);
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Ldap;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Services\RequestContext;
|
||||
use LdapRecord\Configuration\ConfigurationException;
|
||||
use LdapRecord\Container;
|
||||
use LdapRecord\LdapRecordException;
|
||||
@@ -11,20 +13,18 @@ use LdapRecord\Models\Model;
|
||||
|
||||
class LdapUser extends Model
|
||||
{
|
||||
protected static $init = null;
|
||||
/**
|
||||
* The object classes of the LDAP model.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $objectClasses = [
|
||||
'inetOrgPerson',
|
||||
'organizationalPerson',
|
||||
'person',
|
||||
'top',
|
||||
'posixAccount',
|
||||
];
|
||||
|
||||
private static $emailAttrs = ['mail', 'cn', 'uid', 'userPrincipalName'];
|
||||
|
||||
/**
|
||||
* @return mixed|null
|
||||
*/
|
||||
@@ -68,19 +68,29 @@ class LdapUser extends Model
|
||||
return Base::settingFind('thirdAccessSetting', 'ldap_sync_local') === 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录属性名
|
||||
* @return string
|
||||
*/
|
||||
public static function getLoginAttr(): string
|
||||
{
|
||||
$attr = Base::settingFind('thirdAccessSetting', 'ldap_login_attr');
|
||||
return in_array($attr, ['cn', 'uid', 'mail', 'sAMAccountName', 'userPrincipalName']) ? $attr : 'cn';
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化配置
|
||||
* @return bool
|
||||
*/
|
||||
public static function initConfig()
|
||||
{
|
||||
if (is_bool(self::$init)) {
|
||||
return self::$init;
|
||||
if (RequestContext::has('ldap_init')) {
|
||||
return RequestContext::get('ldap_init');
|
||||
}
|
||||
//
|
||||
$setting = Base::setting('thirdAccessSetting');
|
||||
if ($setting['ldap_open'] !== 'open') {
|
||||
return self::$init = false;
|
||||
return RequestContext::save('ldap_init', false);
|
||||
}
|
||||
//
|
||||
$connection = Container::getDefaultConnection();
|
||||
@@ -92,15 +102,15 @@ class LdapUser extends Model
|
||||
"username" => $setting['ldap_user_dn'],
|
||||
"password" => $setting['ldap_password'],
|
||||
]);
|
||||
return self::$init = true;
|
||||
return RequestContext::save('ldap_init', true);
|
||||
} catch (ConfigurationException $e) {
|
||||
info($e->getMessage());
|
||||
return self::$init = false;
|
||||
return RequestContext::save('ldap_init', false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取
|
||||
* 通过管理员绑定搜索用户,然后用用户 DN 做 Bind 认证
|
||||
* @param $username
|
||||
* @param $password
|
||||
* @return Model|null
|
||||
@@ -111,16 +121,68 @@ class LdapUser extends Model
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
'userPassword' => $password
|
||||
])->first();
|
||||
$loginAttr = self::getLoginAttr();
|
||||
$row = self::static()
|
||||
->whereRaw($loginAttr, '=', $username)
|
||||
->first();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$connection = Container::getDefaultConnection();
|
||||
if (!$connection->auth()->attempt($row->getDn(), $password)) {
|
||||
return null;
|
||||
}
|
||||
// Swoole 下连接共享,必须恢复管理员绑定
|
||||
$connection->auth()->attempt(
|
||||
$connection->getConfiguration()->get('username'),
|
||||
$connection->getConfiguration()->get('password')
|
||||
);
|
||||
return $row;
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] auth fail: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过邮箱查找 LDAP 用户
|
||||
* @param $email
|
||||
* @return Model|null
|
||||
*/
|
||||
public static function findByEmail($email): ?Model
|
||||
{
|
||||
if (!self::initConfig()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
foreach (self::$emailAttrs as $attr) {
|
||||
$row = self::static()->whereRaw($attr, '=', $email)->first();
|
||||
if ($row) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的邮箱(从 LDAP 记录中提取)
|
||||
* @param Model $row
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getUserEmail(Model $row): ?string
|
||||
{
|
||||
foreach (self::$emailAttrs as $attr) {
|
||||
$val = $row->getFirstAttribute($attr);
|
||||
if ($val && Base::isEmail($val)) {
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* @param $username
|
||||
@@ -138,7 +200,18 @@ class LdapUser extends Model
|
||||
return null;
|
||||
}
|
||||
if (empty($user)) {
|
||||
$user = User::reg($username, $password);
|
||||
$email = self::getUserEmail($row);
|
||||
if (empty($email)) {
|
||||
throw new ApiException('LDAP 用户缺少邮箱属性,请联系管理员配置');
|
||||
}
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (empty($user)) {
|
||||
// LDAP 用户通过 LDAP 认证,本地密码用随机值以满足密码策略
|
||||
$localPassword = Base::generatePassword(16) . 'Aa1!';
|
||||
$user = User::reg($email, $localPassword);
|
||||
} elseif (!$user->isLdap()) {
|
||||
info("[LDAP] merged with existing local account: userid={$user->userid}, email={$email}");
|
||||
}
|
||||
}
|
||||
if ($user) {
|
||||
$userimg = $row->getPhoto();
|
||||
@@ -173,7 +246,7 @@ class LdapUser extends Model
|
||||
}
|
||||
//
|
||||
if (self::isSyncLocal()) {
|
||||
$row = self::userFirst($user->email, $password);
|
||||
$row = self::findByEmail($user->email);
|
||||
if ($row) {
|
||||
return;
|
||||
}
|
||||
@@ -184,17 +257,18 @@ class LdapUser extends Model
|
||||
} else {
|
||||
$userimg = '';
|
||||
}
|
||||
self::static()->create([
|
||||
$attrs = [
|
||||
'cn' => $user->email,
|
||||
'gidNumber' => 0,
|
||||
'homeDirectory' => '/home/ldap/dootask/' . env("APP_NAME"),
|
||||
'sn' => $user->email,
|
||||
'uid' => $user->email,
|
||||
'uidNumber' => $user->userid,
|
||||
'userPassword' => $password,
|
||||
'displayName' => $user->nickname,
|
||||
'jpegPhoto' => $userimg,
|
||||
]);
|
||||
'mail' => $user->email,
|
||||
];
|
||||
if ($userimg) {
|
||||
$attrs['jpegPhoto'] = $userimg;
|
||||
}
|
||||
self::static()->create($attrs);
|
||||
$user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['ldap']), ['ldap']));
|
||||
$user->save();
|
||||
} catch (LdapRecordException $e) {
|
||||
@@ -205,11 +279,11 @@ class LdapUser extends Model
|
||||
|
||||
/**
|
||||
* 更新
|
||||
* @param $username
|
||||
* @param $email
|
||||
* @param $array
|
||||
* @return void
|
||||
*/
|
||||
public static function userUpdate($username, $array)
|
||||
public static function userUpdate($email, $array)
|
||||
{
|
||||
if (empty($array)) {
|
||||
return;
|
||||
@@ -218,10 +292,7 @@ class LdapUser extends Model
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$row = self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
])->first();
|
||||
$row = self::findByEmail($email);
|
||||
$row?->update($array);
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] update fail: " . $e->getMessage());
|
||||
@@ -230,19 +301,16 @@ class LdapUser extends Model
|
||||
|
||||
/**
|
||||
* 删除
|
||||
* @param $username
|
||||
* @param $email
|
||||
* @return void
|
||||
*/
|
||||
public static function userDelete($username)
|
||||
public static function userDelete($email)
|
||||
{
|
||||
if (!self::initConfig()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$row = self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
])->first();
|
||||
$row = self::findByEmail($email);
|
||||
$row?->delete();
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] delete fail: " . $e->getMessage());
|
||||
|
||||
22
app/Models/AiAssistantSession.php
Normal file
22
app/Models/AiAssistantSession.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* AI 助手会话
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $userid
|
||||
* @property string $session_key
|
||||
* @property string $session_id
|
||||
* @property string $scene_key
|
||||
* @property string $title
|
||||
* @property string|null $data
|
||||
* @property string|null $images
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class AiAssistantSession extends AbstractModel
|
||||
{
|
||||
protected $table = 'ai_assistant_sessions';
|
||||
}
|
||||
@@ -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)
|
||||
@@ -156,7 +157,7 @@ class ProjectTask extends AbstractModel
|
||||
return;
|
||||
}
|
||||
if (!isset($this->appendattrs['sub_num'])) {
|
||||
$builder = self::whereParentId($this->id)->whereNull('archived_at');
|
||||
$builder = self::whereParentId($this->id);
|
||||
$this->appendattrs['sub_num'] = $builder->count();
|
||||
$this->appendattrs['sub_complete'] = $builder->whereNotNull('complete_at')->count();
|
||||
//
|
||||
@@ -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
|
||||
@@ -396,6 +455,7 @@ class ProjectTask extends AbstractModel
|
||||
$userid = User::userid();
|
||||
$visibility = $data['visibility_appoint'] ?? $data['visibility'];
|
||||
$visibility_userids = $data['visibility_appointor'] ?: [];
|
||||
$taskUserLimit = intval(Base::settingFind('system', 'task_user_limit'));
|
||||
//
|
||||
if (ProjectTask::whereProjectId($project_id)
|
||||
->whereNull('project_tasks.complete_at')
|
||||
@@ -417,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,
|
||||
@@ -455,8 +531,8 @@ class ProjectTask extends AbstractModel
|
||||
if (ProjectTask::authData($uid)
|
||||
->whereNull('project_tasks.complete_at')
|
||||
->whereNull('project_tasks.archived_at')
|
||||
->count() > 500) {
|
||||
throw new ApiException(User::userid2nickname($uid) . '负责或参与的未完成任务最多不能超过500个');
|
||||
->count() > $taskUserLimit) {
|
||||
throw new ApiException(User::userid2nickname($uid) . '负责或参与的未完成任务最多不能超过' . $taskUserLimit . '个');
|
||||
}
|
||||
$tmpArray[] = $uid;
|
||||
}
|
||||
@@ -674,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;
|
||||
@@ -757,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) {
|
||||
@@ -1185,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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步项目成员至聊天室
|
||||
*/
|
||||
@@ -1343,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 完成时间
|
||||
@@ -1917,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) {
|
||||
// 更新任务流程
|
||||
@@ -2009,4 +2255,64 @@ class ProjectTask extends AbstractModel
|
||||
//
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建指定周期内的未完成任务查询(用于周报/日报等)
|
||||
* @param int $userid
|
||||
* @param Carbon $start_time
|
||||
* @param Carbon $end_time
|
||||
* @param bool $includeUpdatedForNoPlan 无计划时间任务是否按周期内更新时间一并纳入
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public static function buildUnfinishedTaskQuery(int $userid, Carbon $start_time, Carbon $end_time, bool $includeUpdatedForNoPlan = true)
|
||||
{
|
||||
return self::query()
|
||||
->join("projects", "projects.id", "=", "project_tasks.project_id")
|
||||
->whereNull("projects.archived_at")
|
||||
->whereNull("project_tasks.complete_at")
|
||||
->whereHas("taskUser", function ($query) use ($userid) {
|
||||
$query->where("userid", $userid);
|
||||
})
|
||||
->where(function ($query) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
|
||||
// 1) 有计划时间:计划时间与给定周期 [start_time, end_time] 有交集
|
||||
$query->where(function ($q1) use ($start_time, $end_time) {
|
||||
$q1->whereNotNull('project_tasks.start_at')
|
||||
->whereNotNull('project_tasks.end_at')
|
||||
->where(function ($q2) use ($start_time, $end_time) {
|
||||
$q2->whereBetween('project_tasks.start_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()])
|
||||
->orWhereBetween('project_tasks.end_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()])
|
||||
->orWhere(function ($q3) use ($start_time, $end_time) {
|
||||
$q3->where('project_tasks.start_at', '<=', $start_time->toDateTimeString())
|
||||
->where('project_tasks.end_at', '>=', $end_time->toDateTimeString());
|
||||
});
|
||||
});
|
||||
});
|
||||
// 2) 无计划时间
|
||||
$query->orWhere(function ($q1) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
|
||||
$q1->whereNull('project_tasks.start_at')
|
||||
->whereNull('project_tasks.end_at')
|
||||
->where(function ($q2) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
|
||||
$q2->whereBetween('project_tasks.created_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()]);
|
||||
if ($includeUpdatedForNoPlan) {
|
||||
$q2->orWhereBetween('project_tasks.updated_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()]);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
->select("project_tasks.*")
|
||||
->orderByDesc("project_tasks.id");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断工作流名称是否为取消态(多语言)
|
||||
* @param string|null $flowItemName
|
||||
* @return bool
|
||||
*/
|
||||
public static function isCanceledFlowName(?string $flowItemName): bool
|
||||
{
|
||||
if (empty($flowItemName)) {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/已取消|Cancelled|취소됨|キャンセル済み|Abgebrochen|Annulé|Dibatalkan|Отменено/', $flowItemName) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@@ -55,6 +55,7 @@ class Setting extends AbstractModel
|
||||
$value['image_compress'] = $value['image_compress'] ?: 'open';
|
||||
$value['image_quality'] = min(100, max(0, intval($value['image_quality']) ?: 90));
|
||||
$value['image_save_local'] = $value['image_save_local'] ?: 'open';
|
||||
$value['task_user_limit'] = min(2000, max(1, intval($value['task_user_limit']) ?: 500));
|
||||
if (!is_array($value['task_default_time']) || count($value['task_default_time']) != 2 || !Timer::isTime($value['task_default_time'][0]) || !Timer::isTime($value['task_default_time'][1])) {
|
||||
$value['task_default_time'] = ['09:00', '18:00'];
|
||||
}
|
||||
@@ -68,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 = [];
|
||||
@@ -77,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) {
|
||||
@@ -105,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
|
||||
@@ -134,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 !== '',
|
||||
};
|
||||
}
|
||||
@@ -164,6 +228,213 @@ class Setting extends AbstractModel
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范自定义微应用配置
|
||||
* @param array $list
|
||||
* @return array
|
||||
*/
|
||||
public static function normalizeCustomMicroApps($list)
|
||||
{
|
||||
if (!is_array($list)) {
|
||||
return [];
|
||||
}
|
||||
$apps = [];
|
||||
foreach ($list as $item) {
|
||||
$app = self::normalizeCustomMicroAppItem($item);
|
||||
if ($app) {
|
||||
$apps[] = $app;
|
||||
}
|
||||
}
|
||||
return $apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户身份过滤可见的自定义微应用
|
||||
* @param array $apps
|
||||
* @param \App\Models\User|null $user
|
||||
* @return array
|
||||
*/
|
||||
public static function filterCustomMicroAppsForUser(array $apps, $user)
|
||||
{
|
||||
if (empty($apps)) {
|
||||
return [];
|
||||
}
|
||||
$isAdmin = $user ? $user->isAdmin() : false;
|
||||
$userId = $user ? intval($user->userid) : 0;
|
||||
$filtered = [];
|
||||
foreach ($apps as $app) {
|
||||
$visible = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']);
|
||||
if (!self::isCustomMicroVisibleTo($visible, $isAdmin, $userId)) {
|
||||
continue;
|
||||
}
|
||||
if (empty($app['menu_items']) || !is_array($app['menu_items'])) {
|
||||
continue;
|
||||
}
|
||||
$menus = array_values(array_filter($app['menu_items'], function ($menu) use ($isAdmin, $userId) {
|
||||
if (!isset($menu['visible_to'])) {
|
||||
return true;
|
||||
}
|
||||
$visible = self::normalizeCustomMicroVisible($menu['visible_to']);
|
||||
return self::isCustomMicroVisibleTo($visible, $isAdmin, $userId);
|
||||
}));
|
||||
if (empty($menus)) {
|
||||
continue;
|
||||
}
|
||||
$app['menu_items'] = $menus;
|
||||
$filtered[] = $app;
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将存储结构转换成 appstore 接口同款格式
|
||||
* @param array $apps
|
||||
* @return array
|
||||
*/
|
||||
public static function formatCustomMicroAppsForResponse(array $apps)
|
||||
{
|
||||
return array_values(array_map(function ($app) {
|
||||
unset($app['visible_to']);
|
||||
if (!empty($app['menu_items']) && is_array($app['menu_items'])) {
|
||||
$app['menu_items'] = array_values(array_map(function ($menu) {
|
||||
$menu['keep_alive'] = isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true;
|
||||
$menu['disable_scope_css'] = (bool)($menu['disable_scope_css'] ?? false);
|
||||
$menu['auto_dark_theme'] = isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true;
|
||||
$menu['transparent'] = (bool)($menu['transparent'] ?? false);
|
||||
if (isset($menu['visible_to'])) {
|
||||
unset($menu['visible_to']);
|
||||
}
|
||||
return $menu;
|
||||
}, $app['menu_items']));
|
||||
}
|
||||
return $app;
|
||||
}, $apps));
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范自定义微应用
|
||||
* @param array $item
|
||||
* @return array|null
|
||||
*/
|
||||
protected static function normalizeCustomMicroAppItem($item)
|
||||
{
|
||||
if (!is_array($item)) {
|
||||
return null;
|
||||
}
|
||||
$id = trim($item['id'] ?? '');
|
||||
if ($id === '') {
|
||||
return null;
|
||||
}
|
||||
$name = Base::newTrim($item['name'] ?? '');
|
||||
$version = Base::newTrim($item['version'] ?? '') ?: 'custom';
|
||||
$menuItems = [];
|
||||
if (isset($item['menu_items']) && is_array($item['menu_items'])) {
|
||||
$menuItems = $item['menu_items'];
|
||||
} elseif (isset($item['menu']) && is_array($item['menu'])) {
|
||||
$menuItems = [$item['menu']];
|
||||
}
|
||||
if (empty($menuItems)) {
|
||||
return null;
|
||||
}
|
||||
$normalizedMenus = [];
|
||||
foreach ($menuItems as $menu) {
|
||||
$formattedMenu = self::normalizeCustomMicroMenuItem($menu, $name ?: $id);
|
||||
if ($formattedMenu) {
|
||||
$normalizedMenus[] = $formattedMenu;
|
||||
}
|
||||
}
|
||||
if (empty($normalizedMenus)) {
|
||||
return null;
|
||||
}
|
||||
return Base::newTrim([
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'version' => $version,
|
||||
'menu_items' => $normalizedMenus,
|
||||
'visible_to' => self::normalizeCustomMicroVisible($item['visible_to'] ?? 'admin'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范自定义微应用菜单项
|
||||
* @param array $menu
|
||||
* @param string $fallbackLabel
|
||||
* @return array|null
|
||||
*/
|
||||
protected static function normalizeCustomMicroMenuItem($menu, $fallbackLabel = '')
|
||||
{
|
||||
if (!is_array($menu)) {
|
||||
return null;
|
||||
}
|
||||
$url = trim($menu['url'] ?? '');
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
$location = trim($menu['location'] ?? 'application');
|
||||
$label = trim($menu['label'] ?? $fallbackLabel);
|
||||
$type = strtolower(trim($menu['type'] ?? 'iframe'));
|
||||
$payload = [
|
||||
'location' => $location,
|
||||
'label' => $label,
|
||||
'icon' => Base::newTrim($menu['icon'] ?? ''),
|
||||
'url' => $url,
|
||||
'type' => $type,
|
||||
'keep_alive' => isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true,
|
||||
'disable_scope_css' => (bool)($menu['disable_scope_css'] ?? false),
|
||||
'auto_dark_theme' => isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true,
|
||||
'transparent' => (bool)($menu['transparent'] ?? false),
|
||||
];
|
||||
if (!empty($menu['background'])) {
|
||||
$payload['background'] = Base::newTrim($menu['background']);
|
||||
}
|
||||
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
|
||||
$payload['capsule'] = Base::newTrim($menu['capsule']);
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范自定义微应用可见范围
|
||||
* @param mixed $value
|
||||
* @return array
|
||||
*/
|
||||
protected static function normalizeCustomMicroVisible($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$list = array_filter(array_map('trim', $value));
|
||||
} else {
|
||||
$list = array_filter(array_map('trim', explode(',', (string)$value)));
|
||||
}
|
||||
if (empty($list)) {
|
||||
return ['admin'];
|
||||
}
|
||||
if (in_array('all', $list)) {
|
||||
return ['all'];
|
||||
}
|
||||
return array_values($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断自定义微应用是否可见
|
||||
* @param array $visible
|
||||
* @param bool $isAdmin
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
*/
|
||||
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
|
||||
{
|
||||
if (in_array('all', $visible)) {
|
||||
return true;
|
||||
}
|
||||
if ($isAdmin && in_array('admin', $visible)) {
|
||||
return true;
|
||||
}
|
||||
if ($userId > 0 && in_array((string)$userId, $visible, true)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱地址(过滤忽略地址)
|
||||
* @param $array
|
||||
@@ -229,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ namespace App\Models;
|
||||
use App\Exceptions\ApiException;
|
||||
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;
|
||||
|
||||
@@ -22,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 头像
|
||||
@@ -52,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)
|
||||
@@ -63,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)
|
||||
@@ -313,7 +320,7 @@ class User extends AbstractModel
|
||||
*/
|
||||
public function deleteUser($reason)
|
||||
{
|
||||
return AbstractModel::transaction(function () use ($reason) {
|
||||
$ret = AbstractModel::transaction(function () use ($reason) {
|
||||
// 删除原因
|
||||
$userDelete = UserDelete::createInstance([
|
||||
'operator' => User::userid(),
|
||||
@@ -334,6 +341,7 @@ class User extends AbstractModel
|
||||
//
|
||||
return $this->delete();
|
||||
});
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,7 +415,14 @@ class User extends AbstractModel
|
||||
$dialog?->joinGroup($user->userid, 0);
|
||||
}
|
||||
}
|
||||
return $user->find($user->userid);
|
||||
$createdUser = $user->find($user->userid);
|
||||
if (!$createdUser->bot) {
|
||||
// Manticore 索引同步
|
||||
AbstractObserver::taskDeliver(new ManticoreSyncTask('user_sync', $createdUser->toArray()));
|
||||
// 触发 user_onboard hook
|
||||
Apps::dispatchUserHook($createdUser, 'user_onboard', 'onboard');
|
||||
}
|
||||
return $createdUser;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -767,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);
|
||||
|
||||
@@ -129,8 +129,8 @@ class UserDevice extends AbstractModel
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match("/android_kuaifan_eeui/i", $ua)) {
|
||||
// Android 客户端
|
||||
if (preg_match("/android_(kuaifan_eeui|dootask_expo)/i", $ua, $m)) {
|
||||
// Android 客户端(兼容旧 EEUI 与新 Expo 壳)
|
||||
$result['app_type'] = 'Android';
|
||||
if ($dd->getBrandName() && $dd->getModel()) {
|
||||
// 厂商+型号
|
||||
@@ -145,9 +145,9 @@ class UserDevice extends AbstractModel
|
||||
// 平板
|
||||
$result['app_name'] = 'Phablet';
|
||||
}
|
||||
$result['app_version'] = self::getAfterVersion($ua, 'kuaifan_eeui/');
|
||||
} elseif (preg_match("/ios_kuaifan_eeui/i", $ua)) {
|
||||
// iOS 客户端
|
||||
$result['app_version'] = self::getAfterVersion($ua, $m[1] . '/');
|
||||
} elseif (preg_match("/ios_(kuaifan_eeui|dootask_expo)/i", $ua, $m)) {
|
||||
// iOS 客户端(兼容旧 EEUI 与新 Expo 壳)
|
||||
$result['app_type'] = 'iOS';
|
||||
if (preg_match("/(macintosh|ipad)/i", $ua)) {
|
||||
// iPad
|
||||
@@ -156,7 +156,7 @@ class UserDevice extends AbstractModel
|
||||
// iPhone
|
||||
$result['app_name'] = 'iPhone';
|
||||
}
|
||||
$result['app_version'] = self::getAfterVersion($ua, 'kuaifan_eeui/');
|
||||
$result['app_version'] = self::getAfterVersion($ua, $m[1] . '/');
|
||||
} elseif (preg_match("/dootask/i", $ua)) {
|
||||
// DooTask 客户端
|
||||
$result['app_type'] = $osInfo['name'];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace App\Models;
|
||||
* @property string $key
|
||||
* @property string|null $fd
|
||||
* @property string|null $path
|
||||
* @property string|null $platform 平台类型:android, ios, win, mac, web
|
||||
* @property int|null $userid
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
@@ -27,6 +28,7 @@ namespace App\Models;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereKey($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket wherePath($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket wherePlatform($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,7 +23,7 @@ class AI
|
||||
'qianwen',
|
||||
'wenxin'
|
||||
];
|
||||
protected const OPENAI_DEFAULT_MODEL = 'gpt-5-mini';
|
||||
protected const OPENAI_DEFAULT_MODEL = 'gpt-5.1-mini';
|
||||
|
||||
protected $post = [];
|
||||
protected $headers = [];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -232,7 +247,10 @@ class AI
|
||||
$authParams['model_name'] = $thinkMatch[1];
|
||||
}
|
||||
|
||||
$authResult = Ihttp::ihttp_post('http://nginx/ai/invoke/auth', $authParams, 30);
|
||||
$authResult = Ihttp::ihttp_request('http://nginx/ai/invoke/auth', $authParams, [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Authorization' => 'Bearer ' . Base::token(),
|
||||
], 30);
|
||||
if (Base::isError($authResult)) {
|
||||
return Base::retError($authResult['msg']);
|
||||
}
|
||||
@@ -252,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;
|
||||
}
|
||||
|
||||
/** ******************************************************************************************** */
|
||||
/** ******************************************************************************************** */
|
||||
/** ******************************************************************************************** */
|
||||
@@ -268,6 +381,9 @@ class AI
|
||||
{
|
||||
Apps::isInstalledThrow('ai');
|
||||
|
||||
$extParams = $extParams ?: [];
|
||||
$extHeaders = $extHeaders ?: [];
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
return Base::retError("语音文件不存在");
|
||||
}
|
||||
@@ -284,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',
|
||||
@@ -370,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);
|
||||
|
||||
@@ -451,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);
|
||||
|
||||
@@ -539,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);
|
||||
|
||||
@@ -643,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;
|
||||
@@ -709,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,
|
||||
@@ -717,16 +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'] ?? '';
|
||||
return str_starts_with($model, 'gpt-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';
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,12 @@
|
||||
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;
|
||||
use App\Module\Ihttp;
|
||||
|
||||
class Apps
|
||||
{
|
||||
@@ -22,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');
|
||||
@@ -51,10 +55,77 @@ class Apps
|
||||
'office' => 'OnlyOffice',
|
||||
'drawio' => 'Drawio',
|
||||
'minder' => 'Minder',
|
||||
'search' => 'ZincSearch',
|
||||
'manticore' => 'Manticore Search',
|
||||
default => $appId,
|
||||
};
|
||||
throw new ApiException("应用「{$name}」未安装", [], 0, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = '', array $changedFields = []): void
|
||||
{
|
||||
$appKey = env('APP_KEY', '');
|
||||
if (empty($appKey)) {
|
||||
info('[appstore_hook] APP_KEY is empty, skip dispatchUserHook');
|
||||
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' => $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,
|
||||
];
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . md5($appKey),
|
||||
'Version' => Base::getVersion(),
|
||||
];
|
||||
|
||||
$resp = Ihttp::ihttp_request($url, json_encode($payload, JSON_UNESCAPED_UNICODE), $headers, 5);
|
||||
if (Base::isError($resp)) {
|
||||
info('[appstore_hook] dispatch fail', [
|
||||
'url' => $url,
|
||||
'payload' => $payload,
|
||||
'error' => $resp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1301,7 +1301,7 @@ class Base
|
||||
/**
|
||||
* 获取或设置
|
||||
* @param $setname // 配置名称
|
||||
* @param bool $array // 保存内容
|
||||
* @param bool|array $array // 保存内容
|
||||
* @param bool $isUpdate // 保存内容为更新模式,默认否
|
||||
* @return array
|
||||
*/
|
||||
@@ -1828,13 +1828,27 @@ class Base
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是App移动端
|
||||
* 是否是PC端(包括 Electron 桌面端和 Web 浏览器)
|
||||
* @param string|null $platform 平台类型,不传则自动获取
|
||||
* @return bool
|
||||
*/
|
||||
public static function isPc($platform = null)
|
||||
{
|
||||
if ($platform === null) {
|
||||
$platform = self::platform();
|
||||
}
|
||||
return in_array($platform, ['win', 'mac', 'web']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是App移动端(兼容旧 EEUI 壳与新 Expo 壳)
|
||||
* @return bool
|
||||
*/
|
||||
public static function isEEUIApp()
|
||||
{
|
||||
$userAgent = strtolower(Request::server('HTTP_USER_AGENT'));
|
||||
return str_contains($userAgent, 'kuaifan_eeui');
|
||||
return str_contains($userAgent, 'kuaifan_eeui')
|
||||
|| str_contains($userAgent, 'dootask_expo');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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);
|
||||
|
||||
@@ -64,7 +64,7 @@ class WebSocketService implements WebSocketHandlerInterface
|
||||
'ud' => $userid,
|
||||
],
|
||||
]));
|
||||
$this->userOn($fd, $userid);
|
||||
$this->userOn($fd, $userid, $get['platform']);
|
||||
} else {
|
||||
// 用户不存在
|
||||
$server->push($fd, Base::array2json([
|
||||
@@ -105,6 +105,11 @@ class WebSocketService implements WebSocketHandlerInterface
|
||||
|
||||
// 握手信息
|
||||
case 'handshake':
|
||||
// 更新 PC 端活跃时间
|
||||
$row = WebSocket::whereFd($frame->fd)->first();
|
||||
if ($row && Base::isPc($row->platform)) {
|
||||
Cache::put("user_pc_active:{$row->userid}", time(), 60);
|
||||
}
|
||||
break;
|
||||
|
||||
// 访问状态
|
||||
@@ -166,17 +171,27 @@ class WebSocketService implements WebSocketHandlerInterface
|
||||
* 用户上线
|
||||
* @param $fd
|
||||
* @param $userid
|
||||
* @param $platform
|
||||
* @return void
|
||||
*/
|
||||
private function userOn($fd, $userid)
|
||||
private function userOn($fd, $userid, $platform = 'web')
|
||||
{
|
||||
// 校验平台类型
|
||||
if (!in_array($platform, ['android', 'ios', 'win', 'mac', 'web'])) {
|
||||
$platform = 'web';
|
||||
}
|
||||
WebSocket::updateInsert([
|
||||
'key' => md5($fd . '@' . $userid)
|
||||
], [
|
||||
'fd' => $fd,
|
||||
'userid' => $userid,
|
||||
'platform' => $platform,
|
||||
]);
|
||||
OnlineData::online($userid);
|
||||
// PC 端上线时更新活跃时间
|
||||
if (Base::isPc($platform)) {
|
||||
Cache::put("user_pc_active:{$userid}", time(), 60);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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;
|
||||
@@ -631,8 +628,8 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
$currentTime = Carbon::now()->toDateTimeString();
|
||||
$contextLines = [
|
||||
"您是:{$botUser->nickname}(ID: {$botUser->userid})",
|
||||
"当前对话ID:{$dialog->id}",
|
||||
"当前系统时间:{$currentTime}"
|
||||
"当前对话ID(dialog_id):{$dialog->id}",
|
||||
"当前系统时间(now):{$currentTime}",
|
||||
];
|
||||
|
||||
if ($dialog->type === 'group') {
|
||||
@@ -640,14 +637,14 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
case 'project':
|
||||
$projectInfo = Project::whereDialogId($dialog->id)->first();
|
||||
if ($projectInfo) {
|
||||
$contextLines[] = "场景:项目群聊「{$projectInfo->name}」(ID: {$projectInfo->id})";
|
||||
$contextLines[] = "场景:项目群聊「{$projectInfo->name}」(project_id: {$projectInfo->id})";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task':
|
||||
$taskInfo = ProjectTask::with(['content'])->whereDialogId($dialog->id)->first();
|
||||
if ($taskInfo) {
|
||||
$contextLines[] = "场景:任务群聊「{$taskInfo->name}」(ID: {$taskInfo->id})";
|
||||
$contextLines[] = "场景:任务群聊「{$taskInfo->name}」(task_id: {$taskInfo->id})";
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
namespace App\Tasks;
|
||||
|
||||
use App\Models\UmengAlias;
|
||||
use App\Models\WebSocketDialogMsgRead;
|
||||
use App\Module\Base;
|
||||
use Cache;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
|
||||
/**
|
||||
* 推送友盟消息
|
||||
@@ -11,6 +14,7 @@ class PushUmengMsg extends AbstractTask
|
||||
{
|
||||
protected $userid = 0;
|
||||
protected $array = [];
|
||||
protected $endPush = []; // 需要在 end() 方法中处理的延迟推送列表
|
||||
|
||||
/**
|
||||
* @param array|int $userid
|
||||
@@ -32,11 +36,68 @@ class PushUmengMsg extends AbstractTask
|
||||
if ($setting['push'] !== 'open') {
|
||||
return;
|
||||
}
|
||||
UmengAlias::pushMsgToUserid($this->userid, $this->array);
|
||||
|
||||
// 消息ID
|
||||
$msgId = isset($this->array['extra']['msg_id']) ? intval($this->array['extra']['msg_id']) : 0;
|
||||
|
||||
// 处理用户列表
|
||||
$userids = is_array($this->userid) ? $this->userid : [$this->userid];
|
||||
$directPushUsers = []; // 直接推送的用户
|
||||
$delayedPushUsers = []; // 需要延迟推送的用户
|
||||
|
||||
foreach ($userids as $uid) {
|
||||
if ($this->getDelay() > 0) {
|
||||
// 已经延迟过,检查消息是否已读
|
||||
if ($msgId > 0) {
|
||||
$isRead = WebSocketDialogMsgRead::whereMsgId($msgId)
|
||||
->whereUserid($uid)
|
||||
->whereNotNull('read_at')
|
||||
->exists();
|
||||
if ($isRead) {
|
||||
// 已读,跳过推送
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 未读或无法判断,执行推送
|
||||
$directPushUsers[] = $uid;
|
||||
} else {
|
||||
// 首次推送,检查 PC 端是否活跃
|
||||
$lastActive = Cache::get("user_pc_active:{$uid}");
|
||||
$isPcActive = $lastActive && (time() - $lastActive) < 60;
|
||||
|
||||
if ($isPcActive) {
|
||||
// PC 端活跃,需要延迟推送
|
||||
$delayedPushUsers[] = $uid;
|
||||
} else {
|
||||
// PC 端不活跃,直接推送
|
||||
$directPushUsers[] = $uid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接推送
|
||||
if ($directPushUsers) {
|
||||
UmengAlias::pushMsgToUserid($directPushUsers, $this->array);
|
||||
}
|
||||
|
||||
// 创建延迟推送任务
|
||||
if ($delayedPushUsers) {
|
||||
$this->endPush[] = [
|
||||
'userid' => $delayedPushUsers,
|
||||
'array' => $this->array,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function end()
|
||||
{
|
||||
|
||||
if (empty($this->endPush)) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->endPush as $item) {
|
||||
$task = new PushUmengMsg($item['userid'], $item['array']);
|
||||
$task->delay(10);
|
||||
Task::deliver($task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,10 @@ class WebSocketDialogMsgTask extends AbstractTask
|
||||
'description' => "MID:{$msg->id}",
|
||||
'seconds' => 3600,
|
||||
'badge' => 1,
|
||||
'extra' => [
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
'msg_id' => $msg->id,
|
||||
]
|
||||
];
|
||||
$this->endArray[] = new PushUmengMsg($uids->toArray(), $umengMsg);
|
||||
}
|
||||
|
||||
@@ -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
97
cmd
97
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 "修改成功"
|
||||
@@ -776,35 +833,31 @@ case "$1" in
|
||||
;;
|
||||
"appbuild"|"buildapp")
|
||||
shift 1
|
||||
# 移动端已迁移到独立仓库 dootask-app(Expo + EAS Build),但前端资源的
|
||||
# post-processing(生成 config.js、把 manifest 里的 css/js 注入 index.html、
|
||||
# 拷贝 language/)仍然走 electron/build.js 的 startBuild({id:'app'}) 分支。
|
||||
# 产物在 electron/public/;实际移动端打包在 dootask-app 仓库执行。
|
||||
electron_operate app "$@"
|
||||
echo ""
|
||||
echo "前端资源已构建至 electron/public/"
|
||||
echo "同步到 dootask-app:cp -r electron/public/* ~/wwwroot/dootask-app/assets/web/"
|
||||
;;
|
||||
"electron")
|
||||
shift 1
|
||||
electron_operate "$@"
|
||||
;;
|
||||
"eeui")
|
||||
shift 1
|
||||
cli="$@"
|
||||
por=""
|
||||
if [[ "$cli" == "build" ]]; then
|
||||
cli="build --simple"
|
||||
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}
|
||||
;;
|
||||
"npm")
|
||||
shift 1
|
||||
npm "$@"
|
||||
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,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddWebSocketsPlatform extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('web_sockets', function (Blueprint $table) {
|
||||
$table->string('platform', 20)->nullable()->default('')->after('path')->comment('平台类型:android, ios, win, mac, web');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('web_sockets', function (Blueprint $table) {
|
||||
$table->dropColumn('platform');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Module\Base;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class UpdateSettingMicroappMenuType extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$row = Setting::whereName('microapp_menu')->first();
|
||||
if (!$row) {
|
||||
return;
|
||||
}
|
||||
$data = Base::string2array($row->setting);
|
||||
if (empty($data) || !is_array($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$changed = false;
|
||||
foreach ($data as $appIndex => $app) {
|
||||
if (!is_array($app)) {
|
||||
continue;
|
||||
}
|
||||
$menuItems = [];
|
||||
if (isset($app['menu_items']) && is_array($app['menu_items'])) {
|
||||
$menuItems = $app['menu_items'];
|
||||
} elseif (isset($app['menu']) && is_array($app['menu'])) {
|
||||
$menuItems = [$app['menu']];
|
||||
}
|
||||
if (empty($menuItems)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$newMenuItems = [];
|
||||
foreach ($menuItems as $menu) {
|
||||
if (!is_array($menu)) {
|
||||
$newMenuItems[] = $menu;
|
||||
continue;
|
||||
}
|
||||
if (!isset($menu['type']) && isset($menu['url_type'])) {
|
||||
$menu['type'] = $menu['url_type'];
|
||||
unset($menu['url_type']);
|
||||
$changed = true;
|
||||
} elseif (isset($menu['url_type'])) {
|
||||
unset($menu['url_type']);
|
||||
$changed = true;
|
||||
}
|
||||
$newMenuItems[] = $menu;
|
||||
}
|
||||
|
||||
if (isset($app['menu_items']) && is_array($app['menu_items'])) {
|
||||
$data[$appIndex]['menu_items'] = $newMenuItems;
|
||||
} elseif (isset($app['menu']) && is_array($app['menu'])) {
|
||||
$data[$appIndex]['menu'] = $newMenuItems[0] ?? $app['menu'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$row->updateInstance(['setting' => $data]);
|
||||
$row->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// No-op: do not revert settings payload.
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateAiAssistantSessionsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('ai_assistant_sessions')) {
|
||||
return;
|
||||
}
|
||||
Schema::create('ai_assistant_sessions', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('userid')->default(0)->comment('用户ID');
|
||||
$table->string('session_key', 100)->default('')->comment('场景分类key');
|
||||
$table->string('session_id', 100)->default('')->comment('前端生成的会话ID');
|
||||
$table->string('scene_key', 200)->default('')->comment('具体场景标识');
|
||||
$table->string('title', 255)->default('')->comment('会话标题');
|
||||
$table->longText('data')->nullable()->comment('responses JSON');
|
||||
$table->longText('images')->nullable()->comment('图片映射 {imageId: relativePath}');
|
||||
$table->timestamps();
|
||||
$table->index('userid', 'idx_userid');
|
||||
$table->unique(['userid', 'session_key', 'session_id'], 'uk_user_session');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('ai_assistant_sessions');
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user