Compare commits
411 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d1b08e89 | ||
|
|
6b54b7b1c5 | ||
|
|
adc7fb0d07 | ||
|
|
f969c8145c | ||
|
|
20b5daba50 | ||
|
|
aa2e0acaba | ||
|
|
e57736bcc1 | ||
|
|
a8db8dde7b | ||
|
|
b1d5652bc7 | ||
|
|
025f45df0a | ||
|
|
981a5c9f0f | ||
|
|
88cfd40abe | ||
|
|
cdcf0ff5f3 | ||
|
|
42e355149c | ||
|
|
518364d70d | ||
|
|
f25340c0b3 | ||
|
|
24f607f442 | ||
|
|
6fbddbe77c | ||
|
|
21ba2665b9 | ||
|
|
0888f599a4 | ||
|
|
ef7293704b | ||
|
|
8cd4669b90 | ||
|
|
7f7a82b4b8 | ||
|
|
0863e5529a | ||
|
|
e0ad8ce6c1 | ||
|
|
9f4e5a8335 | ||
|
|
587db459bf | ||
|
|
5b87714acf | ||
|
|
bc54ac9462 | ||
|
|
7e5b31cfb2 | ||
|
|
d81b4ed273 | ||
|
|
0c1a913134 | ||
|
|
7dc641e69e | ||
|
|
18336c870e | ||
|
|
e43588c3b2 | ||
|
|
64649b514e | ||
|
|
24710289e1 | ||
|
|
2a3f05e06f | ||
|
|
0d31106b0f | ||
|
|
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 | ||
|
|
b892d92614 | ||
|
|
b259f083d4 | ||
|
|
38aa9fe2fb | ||
|
|
863dd3a53e | ||
|
|
bea5058df8 | ||
|
|
31c157f58f | ||
|
|
8af6887daa | ||
|
|
eb9b7b4f86 | ||
|
|
cf78766a37 | ||
|
|
944824b552 | ||
|
|
477bb1ac8f | ||
|
|
29df864ecb | ||
|
|
bcf897b7e0 | ||
|
|
e63890c755 | ||
|
|
f3725215bd | ||
|
|
c43e305ea7 | ||
|
|
b9215e2410 | ||
|
|
19d79ab055 | ||
|
|
64d4492806 | ||
|
|
0790eae8c6 | ||
|
|
e10e2c27c1 | ||
|
|
d30b38d4b9 | ||
|
|
f6e4ed7c60 | ||
|
|
7a6bbfac75 | ||
|
|
425d6f9a06 | ||
|
|
58c760bb77 | ||
|
|
3ffdce5e7a | ||
|
|
8e518a044a | ||
|
|
a5adbf80a9 | ||
|
|
0b6c478b4f | ||
|
|
0434bde16f | ||
|
|
0deb3113b5 | ||
|
|
ecb52c76b9 | ||
|
|
69c66053b7 | ||
|
|
892ad395a7 | ||
|
|
e801c09c0f | ||
|
|
ad560a8555 | ||
|
|
e75aa5c2b9 | ||
|
|
e83fd7af1b | ||
|
|
eaec8ef994 | ||
|
|
3339e6b442 | ||
|
|
4c2425c758 | ||
|
|
80d1e6469e | ||
|
|
2fad6394ee | ||
|
|
4bfe33a37f | ||
|
|
130c8bf3b1 | ||
|
|
b9df277104 | ||
|
|
97e1f321ca | ||
|
|
4933930afd | ||
|
|
ab4640382d | ||
|
|
e4cfa4b405 | ||
|
|
789062e85e | ||
|
|
5370bee369 | ||
|
|
2f972488a1 | ||
|
|
6f7656802f | ||
|
|
7d98c5493e | ||
|
|
e0443aa336 | ||
|
|
39ff0d1516 | ||
|
|
1b9c0ee4b8 | ||
|
|
d48287f93a | ||
|
|
717e87cfa9 | ||
|
|
708b488af8 | ||
|
|
d60d3f374b | ||
|
|
8b87a2bc40 | ||
|
|
d0da517503 | ||
|
|
66002ff401 | ||
|
|
bdfc8bdd0c | ||
|
|
98e4668969 | ||
|
|
c92b9bf0fb | ||
|
|
b4cbfd2ae9 | ||
|
|
dd7eee277e | ||
|
|
ab76185434 | ||
|
|
6d97bf1e88 | ||
|
|
49701fcd09 | ||
|
|
40f04d9860 | ||
|
|
d58dd25dbb | ||
|
|
9b2731607b | ||
|
|
a8d2d6f13f | ||
|
|
7c21782ab5 | ||
|
|
f59bdaf5e0 | ||
|
|
6ffd169784 |
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 一气呵成" → 必须先让用户确认
|
||||
432
.github/workflows/ios-publish.yml
vendored
Normal file
432
.github/workflows/ios-publish.yml
vendored
Normal file
@@ -0,0 +1,432 @@
|
||||
name: "iOS Publish"
|
||||
|
||||
# Required GitHub Secrets:
|
||||
#
|
||||
# IOS_CERTIFICATE_BASE64 - Apple distribution certificate (.p12) encoded in base64
|
||||
# IOS_CERTIFICATE_PASSWORD - Password for the .p12 certificate
|
||||
# IOS_PROVISION_PROFILE_BASE64 - App Store provisioning profile (.mobileprovision) encoded in base64
|
||||
# IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 - Share extension App Store provisioning profile (.mobileprovision) encoded in base64
|
||||
# ASC_API_KEY_P8_BASE64 - App Store Connect API key (.p8) encoded in base64
|
||||
# ASC_API_KEY_ID - App Store Connect API Key ID
|
||||
# ASC_ISSUER_ID - App Store Connect Issuer ID
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ios-publish-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare-assets:
|
||||
name: Prepare iOS Assets
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
version: ${{ steps.get-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from package.json
|
||||
id: get-version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install electron dependencies
|
||||
run: |
|
||||
pushd electron
|
||||
npm install
|
||||
popd
|
||||
|
||||
- name: Init mobile submodule
|
||||
run: |
|
||||
git submodule init
|
||||
git submodule update --remote "resources/mobile"
|
||||
|
||||
- name: Build app assets
|
||||
run: ./cmd appbuild publish
|
||||
|
||||
- name: Upload iOS platform artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-platform
|
||||
path: resources/mobile/platforms/ios/
|
||||
retention-days: 1
|
||||
|
||||
build-ios:
|
||||
name: Build & Submit iOS
|
||||
needs: prepare-assets
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 60
|
||||
environment: build
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Init mobile submodule
|
||||
run: |
|
||||
git submodule init
|
||||
git submodule update --remote "resources/mobile"
|
||||
|
||||
- name: Download prepared assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-platform
|
||||
path: resources/mobile/platforms/ios/
|
||||
|
||||
- name: Select Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Install CocoaPods
|
||||
run: |
|
||||
if [ -f "resources/mobile/platforms/ios/eeuiApp/Podfile" ]; then
|
||||
cd resources/mobile/platforms/ios/eeuiApp
|
||||
pod install
|
||||
fi
|
||||
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -hex 20)
|
||||
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
# Import certificate
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
|
||||
echo "$IOS_CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH"
|
||||
security import "$CERTIFICATE_PATH" \
|
||||
-P "$IOS_CERTIFICATE_PASSWORD" \
|
||||
-A \
|
||||
-t cert \
|
||||
-f pkcs12 \
|
||||
-k "$KEYCHAIN_PATH"
|
||||
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Import provisioning profile
|
||||
env:
|
||||
IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
|
||||
IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
APP_PROFILE_PATH=$RUNNER_TEMP/app.mobileprovision
|
||||
SHARE_PROFILE_PATH=$RUNNER_TEMP/share-extension.mobileprovision
|
||||
APP_PROFILE_PLIST=$RUNNER_TEMP/app-profile.plist
|
||||
SHARE_PROFILE_PLIST=$RUNNER_TEMP/share-extension-profile.plist
|
||||
|
||||
echo "$IOS_PROVISION_PROFILE_BASE64" | base64 --decode > "$APP_PROFILE_PATH"
|
||||
echo "$IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64" | base64 --decode > "$SHARE_PROFILE_PATH"
|
||||
|
||||
security cms -D -i "$APP_PROFILE_PATH" > "$APP_PROFILE_PLIST"
|
||||
security cms -D -i "$SHARE_PROFILE_PATH" > "$SHARE_PROFILE_PLIST"
|
||||
|
||||
APP_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$APP_PROFILE_PLIST")
|
||||
SHARE_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$SHARE_PROFILE_PLIST")
|
||||
IOS_TEAM_ID=$(/usr/libexec/PlistBuddy -c "Print :TeamIdentifier:0" "$APP_PROFILE_PLIST")
|
||||
APP_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$APP_PROFILE_PLIST")
|
||||
SHARE_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$SHARE_PROFILE_PLIST")
|
||||
|
||||
if [ "$APP_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task" ]; then
|
||||
echo "Expected app profile for $IOS_TEAM_ID.com.dootask.task, got $APP_PROFILE_APP_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SHARE_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task.shareExtension" ]; then
|
||||
echo "Expected share extension profile for $IOS_TEAM_ID.com.dootask.task.shareExtension, got $SHARE_PROFILE_APP_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$APP_PROFILE_PLIST" >/dev/null; then
|
||||
echo "The DooTask app profile must include Push Notifications."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$APP_PROFILE_PLIST" | grep -q "group.im.dootask"; then
|
||||
echo "The DooTask app profile must include App Group group.im.dootask."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$SHARE_PROFILE_PLIST" | grep -q "group.im.dootask"; then
|
||||
echo "The share extension profile must include App Group group.im.dootask."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp "$APP_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
cp "$SHARE_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
|
||||
echo "APP_PROFILE_NAME=$APP_PROFILE_NAME" >> $GITHUB_ENV
|
||||
echo "SHARE_PROFILE_NAME=$SHARE_PROFILE_NAME" >> $GITHUB_ENV
|
||||
echo "IOS_TEAM_ID=$IOS_TEAM_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Configure manual signing
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ruby <<'RUBY'
|
||||
require 'xcodeproj'
|
||||
|
||||
project_path = 'resources/mobile/platforms/ios/eeuiApp/eeuiApp.xcodeproj'
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
|
||||
{
|
||||
'DooTask' => ENV.fetch('APP_PROFILE_NAME'),
|
||||
'ShareExtension' => ENV.fetch('SHARE_PROFILE_NAME')
|
||||
}.each do |target_name, profile_name|
|
||||
target = project.targets.find { |item| item.name == target_name }
|
||||
abort "Target #{target_name} not found in #{project_path}" unless target
|
||||
|
||||
target.build_configurations.each do |config|
|
||||
next unless config.name == 'Release'
|
||||
|
||||
config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
|
||||
config.build_settings['DEVELOPMENT_TEAM'] = ENV.fetch('IOS_TEAM_ID')
|
||||
config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
|
||||
config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = profile_name
|
||||
end
|
||||
end
|
||||
|
||||
project.save
|
||||
RUBY
|
||||
|
||||
- name: Resolve iOS build number
|
||||
env:
|
||||
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ruby <<'RUBY'
|
||||
require 'base64'
|
||||
require 'json'
|
||||
require 'net/http'
|
||||
require 'openssl'
|
||||
require 'uri'
|
||||
|
||||
BUNDLE_ID = 'com.dootask.task'
|
||||
VERSION_CONFIG_PATH = 'resources/mobile/platforms/ios/eeuiApp/Config/Version.xcconfig'
|
||||
|
||||
def base64url(value)
|
||||
Base64.urlsafe_encode64(value).delete('=')
|
||||
end
|
||||
|
||||
def jwt_es256_signature(private_key, unsigned)
|
||||
der_signature = private_key.sign('SHA256', unsigned)
|
||||
sequence = OpenSSL::ASN1.decode(der_signature)
|
||||
|
||||
sequence.value.map { |integer|
|
||||
integer.value.to_s(2).rjust(32, "\0")[-32, 32]
|
||||
}.join
|
||||
end
|
||||
|
||||
def asc_token
|
||||
key_id = ENV.fetch('ASC_API_KEY_ID')
|
||||
issuer_id = ENV.fetch('ASC_ISSUER_ID')
|
||||
private_key = OpenSSL::PKey.read(Base64.decode64(ENV.fetch('ASC_API_KEY_P8_BASE64')))
|
||||
now = Time.now.to_i
|
||||
|
||||
header = { alg: 'ES256', kid: key_id, typ: 'JWT' }
|
||||
payload = {
|
||||
iss: issuer_id,
|
||||
iat: now,
|
||||
exp: now + 20 * 60,
|
||||
aud: 'appstoreconnect-v1'
|
||||
}
|
||||
|
||||
unsigned = "#{base64url(header.to_json)}.#{base64url(payload.to_json)}"
|
||||
signature = jwt_es256_signature(private_key, unsigned)
|
||||
"#{unsigned}.#{base64url(signature)}"
|
||||
end
|
||||
|
||||
def asc_get(path, params, token)
|
||||
uri = URI::HTTPS.build(
|
||||
host: 'api.appstoreconnect.apple.com',
|
||||
path: path,
|
||||
query: URI.encode_www_form(params)
|
||||
)
|
||||
|
||||
request_uri = uri
|
||||
loop do
|
||||
response = Net::HTTP.start(request_uri.host, request_uri.port, use_ssl: true) do |http|
|
||||
request = Net::HTTP::Get.new(request_uri)
|
||||
request['Authorization'] = "Bearer #{token}"
|
||||
http.request(request)
|
||||
end
|
||||
|
||||
unless response.is_a?(Net::HTTPSuccess)
|
||||
abort "App Store Connect API request failed: #{response.code} #{response.body}"
|
||||
end
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
yield parsed
|
||||
|
||||
next_link = parsed.dig('links', 'next')
|
||||
break unless next_link
|
||||
|
||||
request_uri = URI(next_link)
|
||||
end
|
||||
end
|
||||
|
||||
token = asc_token
|
||||
app_id = nil
|
||||
|
||||
asc_get('/v1/apps', { 'filter[bundleId]' => BUNDLE_ID, 'limit' => 1 }, token) do |page|
|
||||
app_id = page.fetch('data').first&.fetch('id')
|
||||
end
|
||||
|
||||
abort "App Store Connect app not found for bundle id #{BUNDLE_ID}" unless app_id
|
||||
|
||||
existing_versions = []
|
||||
asc_get('/v1/builds', {
|
||||
'filter[app]' => app_id,
|
||||
'fields[builds]' => 'version',
|
||||
'limit' => 200
|
||||
}, token) do |page|
|
||||
existing_versions.concat(
|
||||
page.fetch('data').map { |build| build.dig('attributes', 'version').to_s }
|
||||
)
|
||||
end
|
||||
|
||||
max_build_number = existing_versions
|
||||
.select { |version| version.match?(/\A\d+\z/) }
|
||||
.map(&:to_i)
|
||||
.max || 0
|
||||
|
||||
next_build_number = max_build_number + 1
|
||||
config_content = File.exist?(VERSION_CONFIG_PATH) ? File.read(VERSION_CONFIG_PATH) : ''
|
||||
|
||||
if config_content.match?(/^VERSION_CODE\s*=/)
|
||||
config_content = config_content.gsub(/^VERSION_CODE\s*=.*$/, "VERSION_CODE = #{next_build_number}")
|
||||
else
|
||||
config_content = "#{config_content.rstrip}\nVERSION_CODE = #{next_build_number}\n"
|
||||
end
|
||||
|
||||
File.write(VERSION_CONFIG_PATH, config_content)
|
||||
File.open(ENV.fetch('GITHUB_ENV'), 'a') { |file| file.puts "IOS_BUILD_NUMBER=#{next_build_number}" }
|
||||
|
||||
puts "Latest App Store Connect build number: #{max_build_number}"
|
||||
puts "Resolved iOS build number: #{next_build_number}"
|
||||
RUBY
|
||||
|
||||
- name: Build archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd resources/mobile/platforms/ios/eeuiApp
|
||||
xcodebuild archive \
|
||||
-workspace eeuiApp.xcworkspace \
|
||||
-scheme eeuiApp \
|
||||
-configuration Release \
|
||||
-destination "generic/platform=iOS" \
|
||||
-archivePath $RUNNER_TEMP/eeuiApp.xcarchive \
|
||||
-allowProvisioningUpdates \
|
||||
DEVELOPMENT_TEAM=$IOS_TEAM_ID \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
| xcpretty
|
||||
|
||||
if [ ! -d "$RUNNER_TEMP/eeuiApp.xcarchive" ]; then
|
||||
echo "Archive was not created at $RUNNER_TEMP/eeuiApp.xcarchive"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Export IPA
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd resources/mobile/platforms/ios/eeuiApp
|
||||
|
||||
# Generate ExportOptions.plist
|
||||
cat > $RUNNER_TEMP/ExportOptions.plist << PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>teamID</key>
|
||||
<string>${IOS_TEAM_ID}</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.dootask.task</key>
|
||||
<string>${APP_PROFILE_NAME}</string>
|
||||
<key>com.dootask.task.shareExtension</key>
|
||||
<string>${SHARE_PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath $RUNNER_TEMP/eeuiApp.xcarchive \
|
||||
-exportOptionsPlist $RUNNER_TEMP/ExportOptions.plist \
|
||||
-exportPath $RUNNER_TEMP/ipa-output \
|
||||
-allowProvisioningUpdates \
|
||||
| xcpretty
|
||||
|
||||
- name: Submit to App Store Connect
|
||||
env:
|
||||
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Prepare API key
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > ~/private_keys/AuthKey_${ASC_API_KEY_ID}.p8
|
||||
|
||||
# Find and upload IPA
|
||||
IPA_PATH=$(find $RUNNER_TEMP/ipa-output -name "*.ipa" | head -1)
|
||||
if [ -z "$IPA_PATH" ]; then
|
||||
echo "No IPA file found in $RUNNER_TEMP/ipa-output"
|
||||
exit 1
|
||||
fi
|
||||
echo "Uploading: $IPA_PATH"
|
||||
|
||||
xcrun altool --upload-app \
|
||||
-f "$IPA_PATH" \
|
||||
--type ios \
|
||||
--apiKey "$ASC_API_KEY_ID" \
|
||||
--apiIssuer "$ASC_ISSUER_ID"
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true
|
||||
rm -f $RUNNER_TEMP/certificate.p12
|
||||
rm -f $RUNNER_TEMP/app.mobileprovision
|
||||
rm -f $RUNNER_TEMP/share-extension.mobileprovision
|
||||
rm -f $RUNNER_TEMP/app-profile.plist
|
||||
rm -f $RUNNER_TEMP/share-extension-profile.plist
|
||||
rm -rf ~/private_keys
|
||||
88
.github/workflows/publish.yml
vendored
88
.github/workflows/publish.yml
vendored
@@ -52,53 +52,18 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// 获取最新的 tag
|
||||
const { data: tags } = await github.rest.repos.listTags({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 1
|
||||
});
|
||||
const fs = require('fs');
|
||||
const version = '${{ needs.check-version.outputs.version }}';
|
||||
|
||||
// 获取提交日志
|
||||
// 从 CHANGELOG.md 提取当前版本段落
|
||||
let changelog = '';
|
||||
if (tags.length > 0) {
|
||||
const { data: commits } = await github.rest.repos.compareCommits({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
base: tags[0].name,
|
||||
head: 'HEAD'
|
||||
});
|
||||
|
||||
// 按类型分组提交
|
||||
const groups = {
|
||||
'feat:': { title: '## Features', commits: new Set() },
|
||||
'fix:': { title: '## Bug Fixes', commits: new Set() },
|
||||
'perf:': { title: '## Performance Improvements', commits: new Set() }
|
||||
};
|
||||
|
||||
// 分类收集提交,使用 Set 去重
|
||||
commits.commits.forEach(commit => {
|
||||
const message = commit.commit.message.split('\n')[0].trim();
|
||||
for (const [prefix, group] of Object.entries(groups)) {
|
||||
if (message.startsWith(prefix)) {
|
||||
// 移除前缀后添加到对应分组
|
||||
const cleanMessage = message.slice(prefix.length).trim();
|
||||
group.commits.add(cleanMessage); // 使用 Set.add 自动去重
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 生成更新日志
|
||||
const sections = [];
|
||||
for (const group of Object.values(groups)) {
|
||||
if (group.commits.size > 0) {
|
||||
sections.push(`${group.title}\n\n${Array.from(group.commits).map(msg => `- ${msg}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length > 0) {
|
||||
changelog = '# Changelog\n\n' + sections.join('\n\n');
|
||||
const changelogPath = 'CHANGELOG.md';
|
||||
if (fs.existsSync(changelogPath)) {
|
||||
const content = fs.readFileSync(changelogPath, 'utf-8');
|
||||
const regex = new RegExp(`## \\[${version.replace(/\./g, '\\.')}\\][\\s\\S]*?(?=\\n## \\[|$)`);
|
||||
const match = content.match(regex);
|
||||
if (match) {
|
||||
changelog = match[0].trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +71,8 @@ jobs:
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `v${{ needs.check-version.outputs.version }}`,
|
||||
name: `${{ needs.check-version.outputs.version }}`,
|
||||
tag_name: `v${version}`,
|
||||
name: version,
|
||||
body: changelog || 'No significant changes in this release.',
|
||||
draft: true,
|
||||
prerelease: false
|
||||
@@ -215,7 +180,11 @@ jobs:
|
||||
- name: (Android) Upload File
|
||||
if: matrix.build_type == 'android'
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
run: |
|
||||
node ./electron/build.js android-upload
|
||||
|
||||
@@ -253,7 +222,11 @@ jobs:
|
||||
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
@@ -263,7 +236,11 @@ jobs:
|
||||
- name: (Windows) Build Client
|
||||
if: matrix.build_type == 'windows'
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
shell: bash
|
||||
@@ -294,11 +271,16 @@ jobs:
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
- name: Publish Official
|
||||
- name: Upload Changelog & Publish to Website
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -23,6 +23,9 @@
|
||||
vars.yaml
|
||||
|
||||
# IDE and editor files
|
||||
.cursor/*
|
||||
!.cursor/rules/
|
||||
!.cursor/rules/**
|
||||
.idea
|
||||
.vscode
|
||||
.windsurfrules
|
||||
@@ -57,5 +60,7 @@ laravels.pid
|
||||
.DS_Store
|
||||
|
||||
# Documentation
|
||||
AGENTS.md
|
||||
README_LOCAL.md
|
||||
|
||||
# playwright
|
||||
.playwright-mcp/
|
||||
|
||||
280
CHANGELOG.md
280
CHANGELOG.md
@@ -2,6 +2,286 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.7.67]
|
||||
|
||||
### Features
|
||||
|
||||
- 聊天待办现在可以设置提醒时间,到点会引用原消息并提醒相关人员,避免遗漏重要事项。
|
||||
- 团队管理支持管理员创建或批量导入员工账号,并可填写部门、职位等信息,添加成员更方便。
|
||||
- 系统设置新增聊天待办权限控制,可限制其他人员设置或取消聊天待办。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 设置内容没有变化时不再重复保存,减少无效操作,让使用更稳定。
|
||||
|
||||
### Documentation
|
||||
|
||||
- 补充路由使用限制说明,帮助使用者更清楚地了解规则。
|
||||
- 统一回复语言偏好说明,确保整段回复使用简体中文。
|
||||
|
||||
## [1.7.55]
|
||||
|
||||
### Features
|
||||
|
||||
- 新增部门负责人只读视角,可查看部门成员的项目和任务,并按可见性设置控制展示范围。
|
||||
- 群组、项目和部门支持主负责人 + 副负责人,协作管理更灵活。
|
||||
- 新增共享任务模板,支持跨项目使用、搜索和使用统计,复用常用任务更方便。
|
||||
- 管理页侧边栏支持拖拽调整宽度,使用不同屏幕时更顺手。
|
||||
- 优化任务添加界面,模板浏览和加载提示更清晰。
|
||||
- 项目归档设置选择系统默认规则时,会显示对应提示,减少误操作。
|
||||
- 聊天消息中的表格显示更稳定,单元格内容不再随意换行。
|
||||
- 支持按需调整翻译使用的模型,便于适配不同使用场景。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复权限变更过程中可能出现的可见性或访问异常。
|
||||
- 修复 AI 自动分析开关状态判断不准确的问题。
|
||||
- 修复用户详情页在部分情况下出现横向滚动的问题。
|
||||
- 优化应用发布流程,提升发布稳定性。
|
||||
|
||||
## [1.7.29]
|
||||
|
||||
### Features
|
||||
|
||||
- AI 助手聊天记录现在可自动保存,换设备或重新打开后也能继续查看历史对话。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 改善 AI 助手中长图的显示清晰度,减少图片被压缩后变模糊的问题。
|
||||
- 修复部分企业账号环境下用户搜索失败、密码规则异常的问题。
|
||||
|
||||
## [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
|
||||
|
||||
- 工作报告全面升级:支持创建、模板生成、查看列表与详情,AI 一键整理与分析,并可标记已读/未读。
|
||||
- AI 助手更好用:可自选偏好的 AI 模型,默认模型升级,支持语言偏好设置,界面与入口更清晰。
|
||||
- 新增聊天输入历史,可使用上下方向键切换历史记录。
|
||||
- 支持应用排序,可以自定义应用列表顺序。
|
||||
- 文本与内容处理增强:自动提取文本,内容解析更准确;长文本预览更友好。
|
||||
- 资料与社交完善:新增生日、地址、个人简介;个性标签管理;在用户详情页可创建群组、查看共同群组,页面样式更统一。
|
||||
- 设备与交互优化:触屏体验更顺手;iPad 适配与横屏显示更合理;导出菜单、链接打开、列表滚动和右键菜单体验更好;隐私政策页面更易读。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复 Android 分享页元素重叠,内容显示更清晰。
|
||||
- 修正对话框尺寸计算,避免偶发的发送失败。
|
||||
|
||||
### Performance
|
||||
|
||||
- AI 响应更快,等待时间更短。
|
||||
- 语音识别更稳定、更准确。
|
||||
- 滚动与交互更顺滑,操作更流畅。
|
||||
|
||||
## [1.3.55]
|
||||
|
||||
### Features
|
||||
|
||||
62
CLAUDE.md
Normal file
62
CLAUDE.md
Normal file
@@ -0,0 +1,62 @@
|
||||
## 项目概述
|
||||
|
||||
Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
|
||||
|
||||
## 开发命令
|
||||
|
||||
所有命令通过 `./cmd` 脚本执行(不要直接运行 `php artisan` 等):
|
||||
|
||||
- `./cmd artisan ...` / `./cmd composer ...` / `./cmd php ...` — PHP 相关命令
|
||||
|
||||
### AI 不要主动执行的命令
|
||||
|
||||
以下命令仅由用户人工触发,AI 不要主动跑——包括"任务完成后 sanity check"、"看下能不能编译"等场景:
|
||||
|
||||
- `./cmd dev` — 用户已自行运行 dev server,改完会自己 reload;AI 再跑会争抢进程
|
||||
- `./cmd prod` / `./cmd build` — 发版才用,走 `/release` 流程
|
||||
|
||||
前端代码改动只做 Edit/Write,不要为了"验证"启动 dev server。用户明确说"跑一下 / 出包"时除外。
|
||||
|
||||
## Gotchas
|
||||
|
||||
### LaravelS/Swoole
|
||||
|
||||
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
|
||||
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
|
||||
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
|
||||
- 长生命周期逻辑(WebSocket、定时器)应复用现有模式,避免阻塞协程/事件循环
|
||||
|
||||
### 后端
|
||||
|
||||
- **非 REST 路由**:API 控制器(继承 `InvokeController`)在 `routes/web.php` 按资源注册路由,URL 段映射为控制器方法(如 `api/project/lists` → `lists()`,带 action 则用双下划线:`api/project/invite/join` → `invite__join()`)
|
||||
- 路由最多两段:方法名最多一个双下划线(`method__action`),不支持 `method__action__xxx`(无对应路由,访问 404)
|
||||
- **响应格式**:统一使用 `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)`——禁止拼接翻译
|
||||
|
||||
## Playwright 测试
|
||||
|
||||
- Playwright 测试结果放在 `tests/playwright-results/`,包含测试环境、测试用例、结果截图等信息
|
||||
|
||||
## 交互规范
|
||||
|
||||
- **提问时附带建议**:当需要向用户提问或请求澄清时,应同时提供具体的建议选项或推荐方案,帮助用户快速决策,而非仅抛出开放式问题
|
||||
|
||||
## 语言偏好
|
||||
|
||||
- 回复一律使用简体中文,除非用户明确要求其他语言
|
||||
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;
|
||||
}
|
||||
}
|
||||
307
app/Http/Controllers/Api/AssistantController.php
Normal file
307
app/Http/Controllers/Api/AssistantController.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\AiAssistantSession;
|
||||
use App\Models\User;
|
||||
use App\Module\AI;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* @apiDefine assistant
|
||||
*
|
||||
* 助手
|
||||
*/
|
||||
class AssistantController extends AbstractController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
Apps::isInstalledThrow('ai');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/assistant/auth 生成授权码
|
||||
*
|
||||
* @apiDescription 需要token身份,生成 AI 流式会话的 stream_key
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup assistant
|
||||
* @apiName auth
|
||||
*
|
||||
* @apiParam {String} model_type 模型类型
|
||||
* @apiParam {String} model_name 模型名称
|
||||
* @apiParam {JSON} context 上下文数组
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {String} data.stream_key 流式会话凭证
|
||||
*/
|
||||
public function auth()
|
||||
{
|
||||
$user = User::auth();
|
||||
$user->checkChatInformation();
|
||||
|
||||
$modelType = trim(Request::input('model_type', ''));
|
||||
$modelName = trim(Request::input('model_name', ''));
|
||||
$contextInput = Request::input('context', []);
|
||||
|
||||
return AI::createStreamKey($modelType, $modelName, $contextInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/assistant/models 获取AI模型
|
||||
*
|
||||
* @apiDescription 获取所有AI机器人模型设置
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup assistant
|
||||
* @apiName models
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function models()
|
||||
{
|
||||
$setting = Base::setting('aibotSetting');
|
||||
$setting = array_filter($setting, function ($value, $key) {
|
||||
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
|
||||
}, ARRAY_FILTER_USE_BOTH);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -64,6 +66,14 @@ class FileController extends AbstractController
|
||||
* @apiParam {Number|String} id
|
||||
* - Number 文件ID(需要登录)
|
||||
* - String 链接码(不需要登录,用于预览)
|
||||
* @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 返回信息(错误描述)
|
||||
@@ -72,11 +82,15 @@ class FileController extends AbstractController
|
||||
public function one()
|
||||
{
|
||||
$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)) {
|
||||
$user = User::auth();
|
||||
$file = File::permissionFind(intval($id), $user, 0, $permission);
|
||||
$file = File::permissionFind(intval($id), $user, $with_url === 'yes' ? 1 : 0, $permission);
|
||||
} elseif ($id) {
|
||||
$fileLink = FileLink::whereCode($id)->first();
|
||||
$file = $fileLink?->file;
|
||||
@@ -88,12 +102,12 @@ class FileController extends AbstractController
|
||||
}
|
||||
return Base::retError($msg, $data);
|
||||
}
|
||||
|
||||
|
||||
// 如果文件不允许游客访问,则需要登录
|
||||
if (!$file->guest_access) {
|
||||
User::auth();
|
||||
}
|
||||
|
||||
|
||||
$fileLink->increment("num");
|
||||
} else {
|
||||
return Base::retError('参数错误');
|
||||
@@ -101,20 +115,74 @@ class FileController extends AbstractController
|
||||
//
|
||||
$array = $file->toArray();
|
||||
$array['permission'] = $permission;
|
||||
|
||||
// 如果请求返回文件URL
|
||||
if ($with_url === 'yes') {
|
||||
$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 返回信息(错误描述)
|
||||
@@ -135,6 +203,7 @@ class FileController extends AbstractController
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索自己的
|
||||
$builder = File::whereUserid($user->userid);
|
||||
if ($id) {
|
||||
@@ -142,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}%");
|
||||
}
|
||||
@@ -164,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()) {
|
||||
@@ -399,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;
|
||||
@@ -411,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;
|
||||
@@ -744,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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ use App\Exceptions\ApiException;
|
||||
use App\Models\AbstractModel;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\Report;
|
||||
use App\Models\ReportAnalysis;
|
||||
use App\Models\ReportLink;
|
||||
use App\Models\ReportReceive;
|
||||
use App\Models\User;
|
||||
@@ -50,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']) {
|
||||
@@ -58,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']}%");
|
||||
}
|
||||
@@ -98,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);
|
||||
});
|
||||
@@ -110,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']}%");
|
||||
}
|
||||
@@ -326,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));
|
||||
@@ -361,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[] = [
|
||||
@@ -376,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);
|
||||
@@ -407,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);
|
||||
|
||||
@@ -421,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();
|
||||
}
|
||||
|
||||
@@ -501,9 +538,111 @@ class ReportController extends AbstractController
|
||||
$one->report_link = $link;
|
||||
$link->increment("num");
|
||||
}
|
||||
$analysis = ReportAnalysis::query()
|
||||
->whereRid($one->id)
|
||||
->whereUserid($user->userid)
|
||||
->first();
|
||||
if ($analysis) {
|
||||
$updatedAt = $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null;
|
||||
$one->setAttribute('ai_analysis', [
|
||||
'id' => $analysis->id,
|
||||
'text' => $analysis->analysis_text,
|
||||
'model' => $analysis->model,
|
||||
'updated_at' => $updatedAt,
|
||||
]);
|
||||
} else {
|
||||
$one->setAttribute('ai_analysis', null);
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", $one);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/report/analysave 保存工作汇报 AI 分析
|
||||
*
|
||||
* @apiDescription 需要token身份,仅支持报告提交人或接收人保存分析
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup report
|
||||
* @apiName analysave
|
||||
*
|
||||
* @apiParam {Number} id 报告ID
|
||||
* @apiParam {String} text 分析内容(Markdown)
|
||||
* @apiParam {String} [model] 分析使用的模型标识(可选)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {Number} data.id 分析记录ID
|
||||
* @apiSuccess {String} data.text 分析内容(Markdown)
|
||||
* @apiSuccess {String} data.updated_at 最近更新时间
|
||||
*/
|
||||
public function analysave(): array
|
||||
{
|
||||
$user = User::auth();
|
||||
$id = intval(Request::input("id"));
|
||||
if ($id <= 0) {
|
||||
return Base::retError("缺少ID参数");
|
||||
}
|
||||
$text = trim((string)Request::input('text', ''));
|
||||
if ($text === '') {
|
||||
return Base::retError("分析内容不能为空");
|
||||
}
|
||||
$model = trim((string)Request::input('model', ''));
|
||||
|
||||
$report = Report::getOne($id);
|
||||
if (!$this->userCanAccessReport($report, $user)) {
|
||||
return Base::retError("无权访问该工作汇报");
|
||||
}
|
||||
|
||||
$analysis = ReportAnalysis::query()
|
||||
->whereRid($report->id)
|
||||
->whereUserid($user->userid)
|
||||
->first();
|
||||
|
||||
if (!$analysis) {
|
||||
$analysis = ReportAnalysis::fillInstance([
|
||||
'rid' => $report->id,
|
||||
'userid' => $user->userid,
|
||||
]);
|
||||
}
|
||||
|
||||
$viewerRole = $user->profession ?: (is_array($user->identity) && !empty($user->identity) ? implode('/', $user->identity) : null);
|
||||
$focusMeta = null;
|
||||
$focus = Request::input('focus');
|
||||
if (is_array($focus)) {
|
||||
$focusMeta = array_filter(array_map('trim', $focus));
|
||||
} elseif (is_string($focus) && trim($focus) !== '') {
|
||||
$focusMeta = [trim($focus)];
|
||||
}
|
||||
|
||||
$meta = array_filter([
|
||||
'viewer_role' => $viewerRole,
|
||||
'viewer_name' => $user->nickname ?? null,
|
||||
'focus' => $focusMeta,
|
||||
], function ($value) {
|
||||
if (is_array($value)) {
|
||||
return !empty($value);
|
||||
}
|
||||
return $value !== null && $value !== '';
|
||||
});
|
||||
|
||||
$analysis->updateInstance([
|
||||
'model' => $model,
|
||||
'analysis_text' => $text,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
$analysis->save();
|
||||
|
||||
$analysis->refresh();
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'id' => $analysis->id,
|
||||
'text' => $analysis->analysis_text,
|
||||
'model' => $analysis->model,
|
||||
'updated_at' => $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/mark 标记已读/未读
|
||||
*
|
||||
@@ -691,4 +830,22 @@ class ReportController extends AbstractController
|
||||
}
|
||||
return Base::retSuccess("success", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否有权限查看/分析指定工作汇报
|
||||
* @param Report $report
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
protected function userCanAccessReport(Report $report, User $user): bool
|
||||
{
|
||||
if ($report->userid === $user->userid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ReportReceive::query()
|
||||
->whereRid($report->id)
|
||||
->whereUserid($user->userid)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
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', 'voice2text', 'translation', '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 返回信息(错误描述)
|
||||
@@ -71,8 +71,6 @@ class SystemController extends AbstractController
|
||||
'project_invite',
|
||||
'chat_information',
|
||||
'anon_message',
|
||||
'voice2text',
|
||||
'translation',
|
||||
'convert_video',
|
||||
'compress_video',
|
||||
'e2e_message',
|
||||
@@ -82,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',
|
||||
@@ -94,6 +93,9 @@ class SystemController extends AbstractController
|
||||
'file_upload_limit',
|
||||
'unclaimed_task_reminder',
|
||||
'unclaimed_task_reminder_time',
|
||||
'task_ai_auto_analyze',
|
||||
'department_owner_project_view',
|
||||
'todo_set_permission',
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
}
|
||||
@@ -106,12 +108,6 @@ class SystemController extends AbstractController
|
||||
return Base::retError('自动归档时间不可大于100天!');
|
||||
}
|
||||
}
|
||||
if ($all['voice2text'] == 'open' && !Setting::AIOpen()) {
|
||||
return Base::retError('开启语音转文字功能需要先设置 AI 助理。');
|
||||
}
|
||||
if ($all['translation'] == 'open' && !Setting::AIOpen()) {
|
||||
return Base::retError('开启翻译功能需要先设置 AI 助理。');
|
||||
}
|
||||
if ($all['system_alias'] == env('APP_NAME')) {
|
||||
$all['system_alias'] = '';
|
||||
}
|
||||
@@ -138,8 +134,6 @@ class SystemController extends AbstractController
|
||||
$setting['project_invite'] = $setting['project_invite'] ?: 'open';
|
||||
$setting['chat_information'] = $setting['chat_information'] ?: 'optional';
|
||||
$setting['anon_message'] = $setting['anon_message'] ?: 'open';
|
||||
$setting['voice2text'] = $setting['voice2text'] ?: 'close';
|
||||
$setting['translation'] = $setting['translation'] ?: 'close';
|
||||
$setting['convert_video'] = $setting['convert_video'] ?: 'close';
|
||||
$setting['compress_video'] = $setting['compress_video'] ?: 'close';
|
||||
$setting['e2e_message'] = $setting['e2e_message'] ?: 'close';
|
||||
@@ -149,12 +143,15 @@ class SystemController extends AbstractController
|
||||
$setting['archived_day'] = floatval($setting['archived_day']) ?: 7;
|
||||
$setting['task_visible'] = $setting['task_visible'] ?: 'close';
|
||||
$setting['all_group_mute'] = $setting['all_group_mute'] ?: 'open';
|
||||
$setting['todo_set_permission'] = $setting['todo_set_permission'] ?: 'open';
|
||||
$setting['all_group_autoin'] = $setting['all_group_autoin'] ?: 'yes';
|
||||
$setting['user_private_chat_mute'] = $setting['user_private_chat_mute'] ?: 'open';
|
||||
$setting['user_group_chat_mute'] = $setting['user_group_chat_mute'] ?: 'open';
|
||||
$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['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close';
|
||||
$setting['server_timezone'] = config('app.timezone');
|
||||
$setting['server_version'] = Base::getVersion();
|
||||
//
|
||||
@@ -286,49 +283,17 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/ai AI助手设置(限管理员)
|
||||
* AI助手设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName setting__ai
|
||||
*
|
||||
* @apiParam {String} type
|
||||
* - get: 获取(默认)
|
||||
* - save: 保存设置(参数:['ai_provider', 'ai_api_key', 'ai_api_url', 'ai_proxy'])
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function setting__ai()
|
||||
{
|
||||
User::auth('admin');
|
||||
//
|
||||
$type = trim(Request::input('type'));
|
||||
if ($type == 'save') {
|
||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
||||
return Base::retError('当前环境禁止修改');
|
||||
}
|
||||
$all = Base::newTrim(Request::input());
|
||||
foreach ($all as $key => $value) {
|
||||
if (!in_array($key, [
|
||||
'ai_provider',
|
||||
'ai_api_key',
|
||||
'ai_api_url',
|
||||
'ai_proxy',
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
}
|
||||
}
|
||||
$setting = Base::setting('aiSetting', Base::newTrim($all));
|
||||
} else {
|
||||
$setting = Base::setting('aiSetting');
|
||||
}
|
||||
//
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot 获取会议设置、保存AI机器人设置(限管理员)
|
||||
* @api {get} api/system/setting/aibot 获取AI设置、保存AI机器人设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -386,62 +351,23 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot_models 获取AI模型
|
||||
* 获取AI模型
|
||||
*
|
||||
* @apiDescription 获取所有AI机器人模型设置
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName aibot_models
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function setting__aibot_models()
|
||||
{
|
||||
$setting = Base::setting('aibotSetting');
|
||||
$setting = array_filter($setting, function($value, $key) {
|
||||
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
|
||||
}, ARRAY_FILTER_USE_BOTH);
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot_defmodels 获取AI默认模型
|
||||
* 获取AI默认模型
|
||||
*
|
||||
* @apiDescription 获取AI机器人默认模型
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName setting__aibot_defmodels
|
||||
*
|
||||
* @apiParam {String} type AI类型
|
||||
* @apiParam {String} [base_url] 基础URL(仅 type=ollama 时有效)
|
||||
* @apiParam {String} [key] Key(仅 type=ollama 时有效)
|
||||
* @apiParam {String} [agency] 使用代理(仅 type=ollama 时有效)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function setting__aibot_defmodels()
|
||||
{
|
||||
$type = trim(Request::input('type'));
|
||||
if ($type == 'ollama') {
|
||||
$baseUrl = trim(Request::input('base_url'));
|
||||
$key = trim(Request::input('key'));
|
||||
$agency = trim(Request::input('agency'));
|
||||
if (empty($baseUrl)) {
|
||||
return Base::retError('请先填写 Base URL');
|
||||
}
|
||||
return AI::ollamaModels($baseUrl, $key, $agency);
|
||||
}
|
||||
$models = Setting::AIBotDefaultModels($type);
|
||||
if (empty($models)) {
|
||||
return Base::retError('未找到默认模型');
|
||||
}
|
||||
return Base::retSuccess('success', [
|
||||
'models' => $models
|
||||
]);
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -536,6 +462,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');
|
||||
@@ -672,6 +616,7 @@ class SystemController extends AbstractController
|
||||
'ldap_password',
|
||||
'ldap_user_dn',
|
||||
'ldap_base_dn',
|
||||
'ldap_login_attr',
|
||||
'ldap_sync_local'
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
@@ -685,6 +630,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('{}'));
|
||||
@@ -777,32 +723,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 创建项目模板
|
||||
*
|
||||
@@ -1321,6 +1297,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)) {
|
||||
@@ -1329,7 +1307,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 = [];
|
||||
@@ -1366,9 +1344,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;
|
||||
|
||||
@@ -34,10 +34,16 @@ use App\Models\WebSocketDialogUser;
|
||||
use App\Models\UserTaskBrowse;
|
||||
use App\Models\UserFavorite;
|
||||
use App\Models\UserRecentItem;
|
||||
use App\Models\UserTag;
|
||||
use App\Models\UserTagRecognition;
|
||||
use App\Models\UserAppSort;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\UserEmailVerification;
|
||||
use App\Module\AgoraIO\AgoraTokenGenerator;
|
||||
use Swoole\Coroutine;
|
||||
use App\Module\UserImport;
|
||||
use App\Module\UserImportTemplate;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
/**
|
||||
* @apiDefine users
|
||||
@@ -297,6 +303,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 返回数据
|
||||
@@ -304,10 +312,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;
|
||||
@@ -317,6 +326,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);
|
||||
}
|
||||
|
||||
@@ -374,10 +391,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);
|
||||
@@ -386,9 +407,22 @@ class UsersController extends AbstractController
|
||||
$data['nickname_original'] = $user->getRawOriginal('nickname');
|
||||
$data['department_name'] = $user->getDepartmentName();
|
||||
$data['department_owner'] = UserDepartment::where('parent_id',0)->where('owner_userid', $user->userid)->exists(); // 适用默认部门下第1级负责人才能添加部门OKR
|
||||
$data['managed_departments'] = UserDepartment::getManagedDepartments($user->userid)->toArray();
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/info/managed_departments 获取我可切换负责人视角的部门列表
|
||||
*/
|
||||
public function info__managed_departments()
|
||||
{
|
||||
$user = User::auth();
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/info/departments 获取我的部门列表
|
||||
*
|
||||
@@ -446,6 +480,9 @@ class UsersController extends AbstractController
|
||||
* @apiParam {String} [tel] 电话
|
||||
* @apiParam {String} [nickname] 昵称
|
||||
* @apiParam {String} [profession] 职位/职称
|
||||
* @apiParam {String} [birthday] 生日(格式:YYYY-MM-DD)
|
||||
* @apiParam {String} [address] 地址
|
||||
* @apiParam {String} [introduction] 个人简介
|
||||
* @apiParam {String} [lang] 语言(比如:zh/en)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
@@ -509,6 +546,40 @@ class UsersController extends AbstractController
|
||||
$upLdap['employeeType'] = $profession;
|
||||
}
|
||||
}
|
||||
// 生日
|
||||
if (Arr::exists($data, 'birthday')) {
|
||||
$birthday = trim((string) Request::input('birthday'));
|
||||
if ($birthday === '') {
|
||||
$user->birthday = null;
|
||||
} else {
|
||||
try {
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthday)) {
|
||||
$birthdayDate = Carbon::createFromFormat('Y-m-d', $birthday);
|
||||
} else {
|
||||
$birthdayDate = Carbon::parse($birthday);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return Base::retError('生日格式错误');
|
||||
}
|
||||
$user->birthday = $birthdayDate->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
// 地址
|
||||
if (Arr::exists($data, 'address')) {
|
||||
$address = trim((string) Request::input('address'));
|
||||
if (mb_strlen($address) > 100) {
|
||||
return Base::retError('地址最多只能设置100个字');
|
||||
}
|
||||
$user->address = $address ?: null;
|
||||
}
|
||||
// 个人简介
|
||||
if (Arr::exists($data, 'introduction')) {
|
||||
$introduction = trim((string) Request::input('introduction'));
|
||||
if (mb_strlen($introduction) > 500) {
|
||||
return Base::retError('个人简介最多只能设置500个字');
|
||||
}
|
||||
$user->introduction = $introduction ?: null;
|
||||
}
|
||||
// 语言
|
||||
if (Arr::exists($data, 'lang')) {
|
||||
$lang = trim(Request::input('lang'));
|
||||
@@ -622,7 +693,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']}%")
|
||||
@@ -786,13 +862,75 @@ class UsersController extends AbstractController
|
||||
$basic = UserDelete::userid2basic($id);
|
||||
}
|
||||
if ($basic) {
|
||||
//
|
||||
$retArray[] = $basic;
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', $retArray);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/extra 获取会员扩展信息
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName extra
|
||||
*
|
||||
* @apiParam {Number} [userid] 会员ID(不传默认为当前用户)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function extra()
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$userid = intval(Request::input('userid'));
|
||||
if ($userid <= 0) {
|
||||
$userid = $user->userid;
|
||||
}
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('会员不存在');
|
||||
}
|
||||
|
||||
$user = User::query()
|
||||
->select(['userid', 'birthday', 'address', 'introduction'])
|
||||
->whereUserid($userid)
|
||||
->first();
|
||||
|
||||
$birthday = null;
|
||||
$address = null;
|
||||
$introduction = null;
|
||||
|
||||
if ($user) {
|
||||
$birthday = $user->birthday;
|
||||
$address = $user->address;
|
||||
$introduction = $user->introduction;
|
||||
} else {
|
||||
$deleted = UserDelete::whereUserid($userid)->first();
|
||||
if (empty($deleted) || empty($deleted->cache)) {
|
||||
return Base::retError('会员不存在');
|
||||
}
|
||||
$birthday = $deleted->cache['birthday'] ?? null;
|
||||
$address = $deleted->cache['address'] ?? null;
|
||||
$introduction = $deleted->cache['introduction'] ?? null;
|
||||
}
|
||||
|
||||
$tagMeta = UserTag::listWithMeta($userid, $user);
|
||||
|
||||
$data = [
|
||||
'userid' => $userid,
|
||||
'birthday' => $birthday,
|
||||
'address' => $address,
|
||||
'introduction' => $introduction,
|
||||
'personal_tags' => $tagMeta['top'],
|
||||
'personal_tags_total' => $tagMeta['total'],
|
||||
];
|
||||
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/lists 会员列表(限管理员)
|
||||
*
|
||||
@@ -1128,7 +1266,7 @@ class UsersController extends AbstractController
|
||||
User::passwordPolicy($password);
|
||||
$upArray['encrypt'] = Base::generatePassword(6);
|
||||
$upArray['password'] = Doo::md5s($password, $upArray['encrypt']);
|
||||
$upArray['changepass'] = 1;
|
||||
$upArray['changepass'] = intval($data['changepass'] ?? 1) === 1 ? 1 : 0;
|
||||
$upLdap['userPassword'] = $password;
|
||||
}
|
||||
// 昵称
|
||||
@@ -1205,6 +1343,98 @@ class UsersController extends AbstractController
|
||||
return Base::retSuccess($msg, $userInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/createuser 创建用户(管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份(管理员)
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName createuser
|
||||
*
|
||||
* @apiParam {String} email 邮箱
|
||||
* @apiParam {String} password 初始密码
|
||||
* @apiParam {String} nickname 昵称
|
||||
* @apiParam {String} [profession] 职位/职称(可选,2-20字)
|
||||
* @apiParam {Array} [department] 部门ID列表(可选,最多10个)
|
||||
*/
|
||||
public function createuser()
|
||||
{
|
||||
User::auth('admin');
|
||||
$email = trim(Request::input('email'));
|
||||
$password = trim(Request::input('password'));
|
||||
$nickname = trim(Request::input('nickname'));
|
||||
$changePass = intval(Request::input('changepass', 1)) === 1;
|
||||
$profession = trim((string)Request::input('profession', ''));
|
||||
$department = Request::input('department', []);
|
||||
$user = User::createByAdmin($email, $password, $nickname, [
|
||||
'changePass' => $changePass,
|
||||
'profession' => $profession,
|
||||
'department' => is_array($department) ? $department : [],
|
||||
]);
|
||||
return Base::retSuccess('创建成功', $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/import/preview 批量导入预览(管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份(管理员)。上传 Excel/CSV(列顺序:邮箱、昵称、初始密码、职位(选填)),仅解析+校验、不创建账号
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName import__preview
|
||||
*/
|
||||
public function import__preview()
|
||||
{
|
||||
User::auth('admin');
|
||||
$file = Request::file('file');
|
||||
if (empty($file)) {
|
||||
return Base::retError('请选择文件');
|
||||
}
|
||||
$ext = strtolower($file->getClientOriginalExtension());
|
||||
if (!in_array($ext, ['xls', 'xlsx', 'csv'])) {
|
||||
return Base::retError('仅支持 xls/xlsx/csv 文件');
|
||||
}
|
||||
$sheets = Excel::toArray(new UserImport, $file);
|
||||
$sheet = $sheets[0] ?? [];
|
||||
$rows = User::parseImportRows($sheet);
|
||||
if (empty($rows)) {
|
||||
return Base::retError('文件中没有可导入的数据');
|
||||
}
|
||||
return Base::retSuccess('解析完成', User::importPreview($rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/import 批量导入用户(管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份(管理员)。提交预览确认后的行数据 rows(每行 {email,nickname,password,profession},可选 department[])进行创建
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName import
|
||||
*/
|
||||
public function import()
|
||||
{
|
||||
User::auth('admin');
|
||||
$rows = Request::input('rows');
|
||||
if (!is_array($rows) || empty($rows)) {
|
||||
return Base::retError('没有可导入的数据');
|
||||
}
|
||||
$changePass = intval(Request::input('changepass', 1)) === 1;
|
||||
$result = User::importUsers($rows, $changePass);
|
||||
return Base::retSuccess('导入完成', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/import/template 下载批量导入模板(管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName import__template
|
||||
*/
|
||||
public function import__template()
|
||||
{
|
||||
User::auth('admin');
|
||||
return Excel::download(new UserImportTemplate, 'user_import_template.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/email/verification 邮箱验证
|
||||
*
|
||||
@@ -1450,6 +1680,228 @@ class UsersController extends AbstractController
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/tags/lists 获取个性标签列表
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName tags__lists
|
||||
*
|
||||
* @apiParam {Number} [userid] 会员ID(不传默认为当前用户)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccessExample {json} data:
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "认真负责",
|
||||
"recognition_total": 3,
|
||||
"recognized": true,
|
||||
"can_edit": true,
|
||||
"can_delete": true
|
||||
}
|
||||
],
|
||||
"top": [ ],
|
||||
"total": 1
|
||||
}
|
||||
*/
|
||||
public function tags__lists()
|
||||
{
|
||||
$viewer = User::auth();
|
||||
$userid = intval(Request::input('userid')) ?: $viewer->userid;
|
||||
$target = User::whereUserid($userid)->first();
|
||||
if (empty($target)) {
|
||||
return Base::retError('会员不存在');
|
||||
}
|
||||
return Base::retSuccess('success', UserTag::listWithMeta($target->userid, $viewer));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/tags/add 新增个性标签
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName tags__add
|
||||
*
|
||||
* @apiParam {Number} [userid] 会员ID(不传默认为当前用户)
|
||||
* @apiParam {String} name 标签名称(1-20个字符)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据,同“获取个性标签列表”
|
||||
*/
|
||||
public function tags__add()
|
||||
{
|
||||
$viewer = User::auth();
|
||||
$userid = intval(Request::input('userid')) ?: $viewer->userid;
|
||||
$target = User::whereUserid($userid)->first();
|
||||
if (empty($target)) {
|
||||
return Base::retError('会员不存在');
|
||||
}
|
||||
|
||||
$name = trim((string) Request::input('name'));
|
||||
if ($name === '') {
|
||||
return Base::retError('请输入个性标签');
|
||||
}
|
||||
if (mb_strlen($name) > 20) {
|
||||
return Base::retError('标签名称最多只能设置20个字');
|
||||
}
|
||||
if (UserTag::where('user_id', $userid)->where('name', $name)->exists()) {
|
||||
return Base::retError('标签已存在');
|
||||
}
|
||||
if (UserTag::where('user_id', $userid)->count() >= 100) {
|
||||
return Base::retError('每位会员最多添加100个标签');
|
||||
}
|
||||
|
||||
$tag = UserTag::create([
|
||||
'user_id' => $userid,
|
||||
'name' => $name,
|
||||
'created_by' => $viewer->userid,
|
||||
'updated_by' => $viewer->userid,
|
||||
]);
|
||||
$tag->save();
|
||||
|
||||
return Base::retSuccess('添加成功', UserTag::listWithMeta($userid, $viewer));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/tags/update 修改个性标签
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName tags__update
|
||||
*
|
||||
* @apiParam {Number} tag_id 标签ID
|
||||
* @apiParam {String} name 标签名称(1-20个字符)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据,同“获取个性标签列表”
|
||||
*/
|
||||
public function tags__update()
|
||||
{
|
||||
$viewer = User::auth();
|
||||
$tagId = intval(Request::input('tag_id'));
|
||||
$name = trim((string) Request::input('name'));
|
||||
if ($tagId <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
if ($name === '') {
|
||||
return Base::retError('请输入个性标签');
|
||||
}
|
||||
if (mb_strlen($name) > 20) {
|
||||
return Base::retError('标签名称最多只能设置20个字');
|
||||
}
|
||||
$tag = UserTag::find($tagId);
|
||||
if (empty($tag)) {
|
||||
return Base::retError('标签不存在');
|
||||
}
|
||||
if (!$tag->canManage($viewer)) {
|
||||
return Base::retError('无权操作该标签');
|
||||
}
|
||||
if ($name !== $tag->name && UserTag::where('user_id', $tag->user_id)->where('name', $name)->where('id', '!=', $tag->id)->exists()) {
|
||||
return Base::retError('标签已存在');
|
||||
}
|
||||
|
||||
if ($name !== $tag->name) {
|
||||
$tag->updateInstance([
|
||||
'name' => $name,
|
||||
'updated_by' => $viewer->userid,
|
||||
]);
|
||||
} else {
|
||||
$tag->updateInstance([
|
||||
'updated_by' => $viewer->userid,
|
||||
]);
|
||||
}
|
||||
$tag->save();
|
||||
|
||||
return Base::retSuccess('保存成功', UserTag::listWithMeta($tag->user_id, $viewer));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/tags/delete 删除个性标签
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName tags__delete
|
||||
*
|
||||
* @apiParam {Number} tag_id 标签ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据,同“获取个性标签列表”
|
||||
*/
|
||||
public function tags__delete()
|
||||
{
|
||||
$viewer = User::auth();
|
||||
$tagId = intval(Request::input('tag_id'));
|
||||
if ($tagId <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
$tag = UserTag::find($tagId);
|
||||
if (empty($tag)) {
|
||||
return Base::retError('标签不存在');
|
||||
}
|
||||
if (!$tag->canManage($viewer)) {
|
||||
return Base::retError('无权操作该标签');
|
||||
}
|
||||
|
||||
$userId = $tag->user_id;
|
||||
$tag->delete();
|
||||
|
||||
return Base::retSuccess('删除成功', UserTag::listWithMeta($userId, $viewer));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/tags/recognize 认可个性标签
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName tags__recognize
|
||||
*
|
||||
* @apiParam {Number} tag_id 标签ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据,同“获取个性标签列表”
|
||||
*/
|
||||
public function tags__recognize()
|
||||
{
|
||||
$viewer = User::auth();
|
||||
$tagId = intval(Request::input('tag_id'));
|
||||
if ($tagId <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
$tag = UserTag::find($tagId);
|
||||
if (empty($tag)) {
|
||||
return Base::retError('标签不存在');
|
||||
}
|
||||
|
||||
$recognition = UserTagRecognition::where('tag_id', $tagId)
|
||||
->where('user_id', $viewer->userid)
|
||||
->first();
|
||||
if ($recognition) {
|
||||
$recognition->delete();
|
||||
$message = '已取消认可';
|
||||
} else {
|
||||
UserTagRecognition::create([
|
||||
'tag_id' => $tagId,
|
||||
'user_id' => $viewer->userid,
|
||||
]);
|
||||
$message = '认可成功';
|
||||
}
|
||||
|
||||
return Base::retSuccess($message, UserTag::listWithMeta($tag->user_id, $viewer));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/meeting/link 【会议】获取分享链接
|
||||
*
|
||||
@@ -1801,6 +2253,65 @@ class UsersController extends AbstractController
|
||||
return Base::retSuccess($id > 0 ? '保存成功' : '新建成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/department/adddeputy 任命部门管理员(限管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName department__adddeputy
|
||||
*
|
||||
* @apiParam {Number} id 部门 id
|
||||
* @apiParam {Number} userid 部门管理员 userid
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
*/
|
||||
public function department__adddeputy()
|
||||
{
|
||||
User::auth('admin');
|
||||
$id = intval(Request::input('id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
$dept = UserDepartment::find($id);
|
||||
if (empty($dept)) {
|
||||
return Base::retError('部门不存在或已被删除');
|
||||
}
|
||||
|
||||
// ApiException 由框架统一捕获并 retError 转换
|
||||
$dept->addDeputy($userid);
|
||||
|
||||
Cache::forever("UserDepartment::rand", Base::generatePassword());
|
||||
return Base::retSuccess('任命成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/department/deldeputy 罢免部门管理员(限管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName department__deldeputy
|
||||
*
|
||||
* @apiParam {Number} id 部门 id
|
||||
* @apiParam {Number} userid 要罢免的部门管理员 userid
|
||||
*/
|
||||
public function department__deldeputy()
|
||||
{
|
||||
User::auth('admin');
|
||||
$id = intval(Request::input('id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
$dept = UserDepartment::find($id);
|
||||
if (empty($dept)) {
|
||||
return Base::retError('部门不存在或已被删除');
|
||||
}
|
||||
|
||||
$dept->delDeputy($userid);
|
||||
Cache::forever("UserDepartment::rand", Base::generatePassword());
|
||||
return Base::retSuccess('罢免成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/department/del 删除部门(限管理员)
|
||||
*
|
||||
@@ -2181,7 +2692,8 @@ class UsersController extends AbstractController
|
||||
'users.nickname',
|
||||
'users.userimg',
|
||||
'user_bots.clear_day',
|
||||
'user_bots.webhook_url'
|
||||
'user_bots.webhook_url',
|
||||
'user_bots.webhook_events'
|
||||
])
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
@@ -2191,6 +2703,7 @@ class UsersController extends AbstractController
|
||||
$bot['name'] = $bot['nickname'];
|
||||
$bot['avatar'] = $bot['userimg'];
|
||||
$bot['system_name'] = UserBot::systemBotName($bot['name']);
|
||||
$bot['webhook_events'] = UserBot::normalizeWebhookEvents($bot['webhook_events'] ?? null, empty($bot['webhook_events']));
|
||||
unset($bot['userid'], $bot['nickname'], $bot['userimg']);
|
||||
}
|
||||
|
||||
@@ -2242,11 +2755,13 @@ class UsersController extends AbstractController
|
||||
'avatar' => $botUser->userimg,
|
||||
'clear_day' => 0,
|
||||
'webhook_url' => '',
|
||||
'webhook_events' => [UserBot::WEBHOOK_EVENT_MESSAGE],
|
||||
'system_name' => UserBot::systemBotName($botUser->email),
|
||||
];
|
||||
if ($userBot) {
|
||||
$data['clear_day'] = $userBot->clear_day;
|
||||
$data['webhook_url'] = $userBot->webhook_url;
|
||||
$data['webhook_events'] = $userBot->webhook_events;
|
||||
}
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
@@ -2327,6 +2842,9 @@ class UsersController extends AbstractController
|
||||
if (Arr::exists($data, 'webhook_url')) {
|
||||
$upBot['webhook_url'] = trim($data['webhook_url']);
|
||||
}
|
||||
if (Arr::exists($data, 'webhook_events')) {
|
||||
$upBot['webhook_events'] = UserBot::normalizeWebhookEvents($data['webhook_events'], false);
|
||||
}
|
||||
//
|
||||
if ($upUser) {
|
||||
$botUser->updateInstance($upUser);
|
||||
@@ -2343,11 +2861,13 @@ class UsersController extends AbstractController
|
||||
'avatar' => $botUser->userimg,
|
||||
'clear_day' => 0,
|
||||
'webhook_url' => '',
|
||||
'webhook_events' => [UserBot::WEBHOOK_EVENT_MESSAGE],
|
||||
'system_name' => UserBot::systemBotName($botUser->email),
|
||||
];
|
||||
if ($userBot) {
|
||||
$data['clear_day'] = $userBot->clear_day;
|
||||
$data['webhook_url'] = $userBot->webhook_url;
|
||||
$data['webhook_events'] = $userBot->webhook_events;
|
||||
}
|
||||
return Base::retSuccess($botId ? '修改成功' : '添加成功', $data);
|
||||
}
|
||||
@@ -2491,7 +3011,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)) {
|
||||
@@ -2856,7 +3380,7 @@ class UsersController extends AbstractController
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
//
|
||||
ProjectTask::userTask($task_id, null, null);
|
||||
ProjectTask::findForDepartmentView($task_id, null, null);
|
||||
//
|
||||
UserTaskBrowse::recordBrowse($user->userid, $task_id);
|
||||
//
|
||||
@@ -3124,6 +3648,51 @@ class UsersController extends AbstractController
|
||||
return Base::retSuccess('删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/appsort 获取个人应用排序
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName appsort
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function appsort()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sorts = UserAppSort::getSorts($user->userid);
|
||||
return Base::retSuccess('success', [
|
||||
'sorts' => $sorts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/appsort/save 保存个人应用排序
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName appsort__save
|
||||
*
|
||||
* @apiParam {Object} sorts 排序配置,示例:{"base":["micro:calendar"],"admin":["system:ldap"]}
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function appsort__save()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sorts = UserAppSort::normalizeSorts(Request::input('sorts'));
|
||||
$record = UserAppSort::saveSorts($user->userid, $sorts);
|
||||
return Base::retSuccess('保存成功', [
|
||||
'sorts' => $record->sorts ?? $sorts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/favorites 获取用户收藏列表
|
||||
*
|
||||
@@ -3347,4 +3916,5 @@ class UsersController extends AbstractController
|
||||
//
|
||||
return Base::retSuccess('success', ['favorited' => $isFavorited]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ 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\TodoRemindTask;
|
||||
use App\Tasks\AiTaskLoopTask;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
use Laravolt\Avatar\Avatar;
|
||||
|
||||
@@ -269,10 +271,14 @@ class IndexController extends InvokeController
|
||||
Task::deliver(new JokeSoupTask());
|
||||
// 未领取任务通知
|
||||
Task::deliver(new UnclaimedTaskRemindTask());
|
||||
// 待办提醒
|
||||
Task::deliver(new TodoRemindTask());
|
||||
// 关闭会议室
|
||||
Task::deliver(new CloseMeetingRoomTask());
|
||||
// ZincSearch 同步
|
||||
Task::deliver(new ZincSearchSyncTask());
|
||||
// Manticore Search 同步
|
||||
Task::deliver(new ManticoreSyncTask());
|
||||
// AI 任务建议
|
||||
Task::deliver(new AiTaskLoopTask());
|
||||
|
||||
return "success";
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -20,9 +20,7 @@ use Illuminate\Support\Facades\DB;
|
||||
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|static with($relations)
|
||||
* @method static \Illuminate\Database\Query\Builder|static select($columns = [])
|
||||
* @method static \Illuminate\Database\Query\Builder|static whereIn($column, $values, $boolean = 'and', $not = false)
|
||||
* @method static \Illuminate\Database\Query\Builder|static whereNotIn($column, $values, $boolean = 'and')
|
||||
* @method static \Illuminate\Pagination\LengthAwarePaginator paginate(callable $callback)
|
||||
* @method int change(array $array)
|
||||
* @method int remove()
|
||||
* @mixin \Eloquent
|
||||
@@ -53,6 +51,8 @@ class AbstractModel extends Model
|
||||
|
||||
'read_at',
|
||||
'done_at',
|
||||
'remind_at',
|
||||
'reminded_at',
|
||||
|
||||
'created_at',
|
||||
'updated_at',
|
||||
|
||||
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));
|
||||
|
||||
@@ -152,6 +152,23 @@ class FileContent extends AbstractModel
|
||||
return Base::retSuccess('success', [ 'content' => $content ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件访问URL
|
||||
* @param int $fileId 文件ID
|
||||
* @return string|null 返回完整的文件URL,如果文件无内容则返回null
|
||||
*/
|
||||
public static function getFileUrl($fileId)
|
||||
{
|
||||
$content = self::whereFid($fileId)->orderByDesc('id')->first();
|
||||
if ($content) {
|
||||
$contentData = Base::json2array($content->content ?: []);
|
||||
if (!empty($contentData['url'])) {
|
||||
return Base::fillUrl($contentData['url']);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件内容
|
||||
* @param $id
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ use Request;
|
||||
* @property int|null $personal 是否个人项目
|
||||
* @property string|null $archive_method 自动归档方式
|
||||
* @property int|null $archive_days 自动归档天数
|
||||
* @property string|null $ai_auto_analyze AI自动分析
|
||||
* @property string|null $task_template_share 共享模板开关
|
||||
* @property string|null $department_owner_view 部门负责人视角可见开关
|
||||
* @property string|null $user_simple 成员总数|1,2,3
|
||||
* @property int|null $dialog_id 聊天会话ID
|
||||
* @property \Illuminate\Support\Carbon|null $archived_at 归档时间
|
||||
@@ -48,6 +51,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)
|
||||
@@ -76,6 +80,7 @@ class Project extends AbstractModel
|
||||
|
||||
protected $appends = [
|
||||
'owner_userid',
|
||||
'deputy_userids',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -91,6 +96,58 @@ class Project extends AbstractModel
|
||||
return $this->appendattrs['owner_userid'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目管理员 userid 列表
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyUseridsAttribute(): array
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
return [];
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目负责人(与 project_users.owner=1 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereUserid($userid)
|
||||
->whereOwner(ProjectUser::OWNER_PRIMARY)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目管理员(与 project_users.owner=2 一致)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereUserid($userid)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(含项目管理员)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
@@ -164,6 +221,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
|
||||
@@ -214,21 +283,40 @@ class Project extends AbstractModel
|
||||
return;
|
||||
}
|
||||
AbstractModel::transaction(function() {
|
||||
$userids = $this->relationUserids();
|
||||
// 拉所有项目成员 + 各自 owner 值
|
||||
$userOwnerMap = ProjectUser::whereProjectId($this->id)
|
||||
->pluck('owner', 'userid');
|
||||
$userids = $userOwnerMap->keys()->map(fn($v) => (int)$v)->toArray();
|
||||
foreach ($userids as $userid) {
|
||||
$owner = (int)$userOwnerMap[$userid];
|
||||
// 巧合:编码完全一致 owner 0/1/2 → role 0/1/2
|
||||
$role = $owner;
|
||||
WebSocketDialogUser::updateInsert([
|
||||
'dialog_id' => $this->dialog_id,
|
||||
'userid' => $userid,
|
||||
], [
|
||||
'important' => 1
|
||||
], function () use ($userid) {
|
||||
'important' => 1,
|
||||
'role' => $role,
|
||||
], function () use ($userid, $role) {
|
||||
return [
|
||||
'important' => 1,
|
||||
'role' => $role,
|
||||
'bot' => User::isBot($userid) ? 1 : 0,
|
||||
];
|
||||
});
|
||||
}
|
||||
WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove();
|
||||
WebSocketDialogUser::whereDialogId($this->dialog_id)
|
||||
->whereNotIn('userid', $userids)
|
||||
->whereImportant(1)
|
||||
->remove();
|
||||
// 同步 dialog.owner_id 到主负责人(owner=1):前端「群主」标签依赖此字段,
|
||||
// 必须随项目主负责人变更(含用户离职转移)一起刷新,否则会显示已离职用户
|
||||
$primaryUserid = $userOwnerMap->search(ProjectUser::OWNER_PRIMARY);
|
||||
if ($primaryUserid !== false && (int)$primaryUserid > 0) {
|
||||
WebSocketDialog::whereId($this->dialog_id)
|
||||
->where('owner_id', '!=', (int)$primaryUserid)
|
||||
->update(['owner_id' => (int)$primaryUserid]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -365,7 +453,7 @@ class Project extends AbstractModel
|
||||
// 处理所有者权限
|
||||
if (isset($data['owner'])) {
|
||||
$owners = ProjectUser::whereProjectId($data['id'])
|
||||
->whereOwner(1)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
$recipients = [
|
||||
@@ -586,7 +674,7 @@ class Project extends AbstractModel
|
||||
$column['project_id'] = $project->id;
|
||||
ProjectColumn::createInstance($column)->save();
|
||||
}
|
||||
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project');
|
||||
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project', $project->userid);
|
||||
if (empty($dialog)) {
|
||||
throw new ApiException('创建项目聊天室失败');
|
||||
}
|
||||
@@ -608,7 +696,9 @@ class Project extends AbstractModel
|
||||
* 获取项目信息(用于判断会员是否存在项目内)
|
||||
* @param int $project_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool $mustOwner true:仅限项目负责人, false:仅限非项目负责人, null:不限制
|
||||
* @param null|bool|string $mustOwner true:负责人或项目管理员都可(共享操作);
|
||||
* 'primary':仅负责人(转让/删除/任命项目管理员等独占操作);
|
||||
* false:仅限非负责人;null:不限制
|
||||
* @return self
|
||||
*/
|
||||
public static function userProject($project_id, $archived = true, $mustOwner = null)
|
||||
@@ -626,9 +716,39 @@ class Project extends AbstractModel
|
||||
if ($mustOwner === true && !$project->owner) {
|
||||
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
if ($mustOwner === 'primary' && (int)$project->owner !== 1) {
|
||||
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
if ($mustOwner === false && $project->owner) {
|
||||
throw new ApiException('禁止项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目(含部门负责人只读视角兜底)
|
||||
* @param int $project_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool|string $mustOwner 仅限 null 时尝试部门只读视角
|
||||
* @return self
|
||||
*/
|
||||
public static function findForDepartmentView($project_id, $archived = true, $mustOwner = null)
|
||||
{
|
||||
$user = User::auth();
|
||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
||||
if (UserDepartment::isDepartmentReadonlyProject($departmentView, intval($project_id)) && $mustOwner === null) {
|
||||
$project = self::allData()->where('projects.id', intval($project_id))->first();
|
||||
if (empty($project)) {
|
||||
throw new ApiException('项目不存在或已被删除', [ 'project_id' => $project_id ], -4001);
|
||||
}
|
||||
if ($archived === true && $project->archived_at != null) {
|
||||
throw new ApiException('项目已归档', [ 'project_id' => $project_id ], -4001);
|
||||
}
|
||||
if ($archived === false && $project->archived_at == null) {
|
||||
throw new ApiException('项目未归档', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
return $project;
|
||||
}
|
||||
return self::userProject($project_id, $archived, $mustOwner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 完成时间
|
||||
@@ -1742,7 +1991,9 @@ class ProjectTask extends AbstractModel
|
||||
'dialog_id' => $this->dialog_id,
|
||||
];
|
||||
//
|
||||
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)->whereOwner(1)->pluck('userid')->toArray(); // 项目负责人
|
||||
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray(); // 项目负责人(含项目管理员)
|
||||
//
|
||||
$array = [];
|
||||
if (empty($userids)) {
|
||||
@@ -1917,11 +2168,8 @@ class ProjectTask extends AbstractModel
|
||||
$taskUser->save();
|
||||
}
|
||||
}
|
||||
// 子任务
|
||||
ProjectTask::whereParentId($this->id)->change([
|
||||
'project_id' => $projectId,
|
||||
'column_id' => $columnId,
|
||||
]);
|
||||
// 子任务 - 根据完成状态映射工作流
|
||||
$this->moveSubTasks($projectId, $columnId);
|
||||
//
|
||||
if ($flowItemId) {
|
||||
// 更新任务流程
|
||||
@@ -1948,66 +2196,6 @@ class ProjectTask extends AbstractModel
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成AI上下文
|
||||
* @return array
|
||||
*/
|
||||
public function AIContext()
|
||||
{
|
||||
$contexts = [];
|
||||
if ($this->archived_at) {
|
||||
$contexts[] = "任务状态:已归档";
|
||||
$contexts[] = "归档时间:" . $this->archived_at;
|
||||
} elseif ($this->complete_at) {
|
||||
$contexts[] = "任务状态:已完成";
|
||||
$contexts[] = "完成时间:" . $this->complete_at;
|
||||
} elseif ($this->end_at && Carbon::parse($this->end_at)->lt(Carbon::now())) {
|
||||
$contexts[] = "任务状态:已过期";
|
||||
$contexts[] = "任务截止时间:" . $this->end_at;
|
||||
} else {
|
||||
$contexts[] = "任务状态:进行中";
|
||||
if ($this->start_at) {
|
||||
$contexts[] = "任务开始时间:" . $this->start_at;
|
||||
}
|
||||
if ($this->end_at) {
|
||||
$contexts[] = "任务截止时间:" . $this->end_at;
|
||||
}
|
||||
}
|
||||
$contexts[] = "当前系统时间:" . Carbon::now()->toDateTimeString();
|
||||
if ($this->content) {
|
||||
$taskDesc = $this->content?->getContentInfo();
|
||||
if ($taskDesc) {
|
||||
$descContent = Base::cutStr(Base::html2markdown($taskDesc['content'], ['strip_tags' => true]), 2000);
|
||||
$contexts[] = <<<EOF
|
||||
任务描述:
|
||||
```md
|
||||
{$descContent}
|
||||
```
|
||||
EOF;
|
||||
}
|
||||
}
|
||||
$subTask = ProjectTask::select(['id', 'name', 'complete_at', 'end_at'])->whereParentId($this->id)->get();
|
||||
if ($subTask->isNotEmpty()) {
|
||||
$subTaskContent = $subTask->map(function($item) {
|
||||
if ($item->complete_at) {
|
||||
$status = " (已完成)";
|
||||
} elseif ($item->end_at && Carbon::parse($item->end_at)->lt(Carbon::now())) {
|
||||
$status = " (已过期)";
|
||||
} else {
|
||||
$status = " (进行中)";
|
||||
}
|
||||
return " - {$item->name} {$status}";
|
||||
})->join("\n");
|
||||
if ($subTaskContent) {
|
||||
$contexts[] = <<<EOF
|
||||
子任务列表:
|
||||
{$subTaskContent}
|
||||
EOF;
|
||||
}
|
||||
}
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务
|
||||
* @param $task_id
|
||||
@@ -2069,4 +2257,98 @@ class ProjectTask extends AbstractModel
|
||||
//
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务(含部门负责人只读视角兜底)
|
||||
* @param int $task_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool $trashed true:仅限未删除, false:仅限已删除, null:不限制
|
||||
* @param array $with
|
||||
* @return self
|
||||
*/
|
||||
public static function findForDepartmentView($task_id, $archived = true, $trashed = true, $with = [])
|
||||
{
|
||||
$user = User::auth();
|
||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
||||
if ($departmentView['enabled']) {
|
||||
$builder = self::with($with)->allData()->where('project_tasks.id', intval($task_id));
|
||||
if ($trashed === false) {
|
||||
$builder->onlyTrashed();
|
||||
} elseif ($trashed === null) {
|
||||
$builder->withTrashed();
|
||||
}
|
||||
$task = $builder->first();
|
||||
// 仅"全员可见"(visibility=1)的任务走负责人只读视角;指定成员可见的任务交由 userTask 按可见性校验
|
||||
if (!empty($task) && intval($task->visibility) === 1 && UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id))) {
|
||||
if ($archived === true && $task->archived_at != null) {
|
||||
throw new ApiException('任务已归档', ['task_id' => $task_id]);
|
||||
}
|
||||
if ($archived === false && $task->archived_at == null) {
|
||||
throw new ApiException('任务未归档', ['task_id' => $task_id]);
|
||||
}
|
||||
return $task;
|
||||
}
|
||||
}
|
||||
return self::userTask($task_id, $archived, $trashed, $with);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建指定周期内的未完成任务查询(用于周报/日报等)
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ namespace App\Models;
|
||||
* @property int $sort 排序
|
||||
* @property int $is_default 是否默认模板
|
||||
* @property int $userid 创建人
|
||||
* @property int $use_count 累计使用次数
|
||||
* @property \Illuminate\Support\Carbon|null $last_used_at 最近一次使用时间
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Project $project
|
||||
@@ -52,7 +54,18 @@ class ProjectTaskTemplate extends AbstractModel
|
||||
'content',
|
||||
'sort',
|
||||
'is_default',
|
||||
'userid'
|
||||
'userid',
|
||||
'use_count',
|
||||
'last_used_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -74,4 +87,17 @@ class ProjectTaskTemplate extends AbstractModel
|
||||
{
|
||||
return $this->belongsTo(User::class, 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子递增使用次数并刷新最近使用时间。
|
||||
*/
|
||||
public function incrementUsage(): void
|
||||
{
|
||||
$this->newQuery()
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'use_count' => \DB::raw('use_count + 1'),
|
||||
'last_used_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,36 @@ use App\Module\Base;
|
||||
*/
|
||||
class ProjectUser extends AbstractModel
|
||||
{
|
||||
/** @var int 普通成员编码 */
|
||||
const OWNER_MEMBER = 0;
|
||||
/** @var int 项目负责人编码 */
|
||||
const OWNER_PRIMARY = 1;
|
||||
/** @var int 项目管理员编码 */
|
||||
const OWNER_DEPUTY = 2;
|
||||
|
||||
/**
|
||||
* 是否项目负责人(owner=1)
|
||||
*/
|
||||
public function isPrimaryOwner(): bool
|
||||
{
|
||||
return (int)$this->owner === self::OWNER_PRIMARY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目管理员(owner=2)
|
||||
*/
|
||||
public function isDeputyOwner(): bool
|
||||
{
|
||||
return (int)$this->owner === self::OWNER_DEPUTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(含项目管理员)
|
||||
*/
|
||||
public function isOwner(): bool
|
||||
{
|
||||
return $this->isPrimaryOwner() || $this->isDeputyOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
@@ -61,12 +91,19 @@ class ProjectUser extends AbstractModel
|
||||
foreach ($list as $item) {
|
||||
$row = self::whereProjectId($item->project_id)->whereUserid($newUserid)->first();
|
||||
if ($row) {
|
||||
// 已存在则删除原数据,判断改变已存在的数据
|
||||
$row->owner = max($row->owner, $item->owner);
|
||||
// 已存在:仅当离职用户是项目负责人(owner=1)时把接收人升为项目负责人;
|
||||
// 离职用户是项目管理员(owner=2)时不传项目管理员身份给接收人(spec:项目管理员不替补)
|
||||
if ((int)$item->owner === self::OWNER_PRIMARY) {
|
||||
$row->owner = self::OWNER_PRIMARY;
|
||||
}
|
||||
// owner=2/0:保留接收人原有 owner 值不变
|
||||
$row->save();
|
||||
$item->delete();
|
||||
} else {
|
||||
// 不存在则改变原数据
|
||||
// 不存在:转移时如果离职用户是项目管理员,降级为普通成员(不带项目管理员身份过户给接收人)
|
||||
if ((int)$item->owner === self::OWNER_DEPUTY) {
|
||||
$item->owner = self::OWNER_MEMBER;
|
||||
}
|
||||
$item->userid = $newUserid;
|
||||
$item->save();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
|
||||
/**
|
||||
@@ -26,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
|
||||
@@ -55,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",
|
||||
@@ -78,6 +91,16 @@ class Report extends AbstractModel
|
||||
->withPivot("receive_at", "read");
|
||||
}
|
||||
|
||||
public function aiAnalyses(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReportAnalysis::class, 'rid');
|
||||
}
|
||||
|
||||
public function aiAnalysis(): HasOne
|
||||
{
|
||||
return $this->hasOne(ReportAnalysis::class, 'rid');
|
||||
}
|
||||
|
||||
public function sendUser()
|
||||
{
|
||||
return $this->hasOne(User::class, "userid", "userid");
|
||||
|
||||
58
app/Models/ReportAnalysis.php
Normal file
58
app/Models/ReportAnalysis.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
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';
|
||||
|
||||
protected $fillable = [
|
||||
'rid',
|
||||
'userid',
|
||||
'model',
|
||||
'analysis_text',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Report::class, 'rid');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Exceptions\ApiException;
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Timer;
|
||||
use App\Module\AI;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
@@ -54,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'];
|
||||
}
|
||||
@@ -65,17 +67,9 @@ class Setting extends AbstractModel
|
||||
$value['permission_pack_userids'] = is_array($value['permission_pack_userids']) ? $value['permission_pack_userids'] : [];
|
||||
break;
|
||||
|
||||
// AI 助手设置
|
||||
case 'aiSetting':
|
||||
$value['ai_provider'] = $value['ai_provider'] ?: 'openai';
|
||||
$value['ai_api_key'] = $value['ai_api_key'] ?: '';
|
||||
$value['ai_api_url'] = $value['ai_api_url'] ?: '';
|
||||
$value['ai_proxy'] = $value['ai_proxy'] ?: '';
|
||||
break;
|
||||
|
||||
// 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 = [];
|
||||
@@ -84,17 +78,14 @@ 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) {
|
||||
$content = explode("\n", $content);
|
||||
$content = array_filter($content);
|
||||
}
|
||||
if (empty($content)) {
|
||||
$content = self::AIBotDefaultModels($aiName);
|
||||
}
|
||||
$content = implode("\n", $content);
|
||||
$content = is_array($content) ? implode("\n", $content) : '';
|
||||
break;
|
||||
case 'model':
|
||||
$models = Setting::AIBotModels2Array($array[$key . 's'], true);
|
||||
@@ -116,100 +107,99 @@ class Setting extends AbstractModel
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否开启 AI 助理
|
||||
* 规范任务优先级设置(确保字段完整且仅有一个默认项)
|
||||
* @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
|
||||
*/
|
||||
public static function AIOpen()
|
||||
{
|
||||
return !!Base::settingFind('aiSetting', 'ai_api_key');
|
||||
$setting = Base::setting('aibotSetting');
|
||||
if (!is_array($setting) || empty($setting)) {
|
||||
return false;
|
||||
}
|
||||
foreach (AI::TEXT_MODEL_PRIORITY as $vendor) {
|
||||
if (self::isAIBotVendorEnabled($setting, $vendor)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 机器人默认模型
|
||||
* @param string $ai
|
||||
* @return array
|
||||
* 判断 AI 机器人厂商是否启用
|
||||
* @param array $setting
|
||||
* @param string $vendor
|
||||
* @return bool
|
||||
*/
|
||||
public static function AIBotDefaultModels($ai = 'openai')
|
||||
protected static function isAIBotVendorEnabled(array $setting, string $vendor): bool
|
||||
{
|
||||
return match ($ai) {
|
||||
'openai' => [
|
||||
'gpt-4.1 | GPT-4.1',
|
||||
'gpt-4o | GPT-4o',
|
||||
'gpt-4 | GPT-4',
|
||||
'gpt-4o-mini | GPT-4o Mini',
|
||||
'gpt-4-turbo | GPT-4 Turbo',
|
||||
'o3 (thinking) | GPT-o3',
|
||||
'o1 | GPT-o1',
|
||||
'o4-mini | GPT-o4 Mini',
|
||||
'o3-mini | GPT-o3 Mini',
|
||||
'o1-mini | GPT-o1 Mini',
|
||||
'gpt-3.5-turbo | GPT-3.5 Turbo',
|
||||
'gpt-3.5-turbo-16k | GPT-3.5 Turbo 16K',
|
||||
'gpt-3.5-turbo-0125 | GPT-3.5 Turbo 0125',
|
||||
'gpt-3.5-turbo-1106 | GPT-3.5 Turbo 1106'
|
||||
],
|
||||
'claude' => [
|
||||
'claude-opus-4-0 (thinking) | Claude Opus 4',
|
||||
'claude-sonnet-4-0 (thinking) | Claude Sonnet 4',
|
||||
'claude-3-7-sonnet-latest (thinking) | Claude Sonnet 3.7',
|
||||
'claude-3-5-sonnet-latest | Claude Sonnet 3.5',
|
||||
'claude-3-5-haiku-latest | Claude Haiku 3.5',
|
||||
'claude-3-opus-latest | Claude Opus 3'
|
||||
],
|
||||
'deepseek' => [
|
||||
'deepseek-chat | DeepSeek V3',
|
||||
'deepseek-reasoner | DeepSeek R1'
|
||||
],
|
||||
'gemini' => [
|
||||
'gemini-2.5-pro-preview-05-06 (thinking) | Gemini 2.5 Pro Preview',
|
||||
'gemini-2.0-flash | Gemini 2.0 Flash',
|
||||
'gemini-2.0-flash-lite | Gemini 2.0 Flash-Lite',
|
||||
'gemini-1.5-flash | Gemini 1.5 Flash',
|
||||
'gemini-1.5-flash-8b | Gemini 1.5 Flash 8B',
|
||||
'gemini-1.5-pro | Gemini 1.5 Pro',
|
||||
'gemini-1.0-pro | Gemini 1.0 Pro'
|
||||
],
|
||||
'grok' => [
|
||||
'grok-3-latest | Grok 3',
|
||||
'grok-3-fast-latest | Grok 3 Fast',
|
||||
'grok-3-mini-latest | Grok 3 Mini',
|
||||
'grok-3-mini-fast-latest | Grok 3 Mini Fast',
|
||||
'grok-2-vision-latest | Grok 2 Vision',
|
||||
'grok-2-latest | Grok 2',
|
||||
],
|
||||
'zhipu' => [
|
||||
'glm-4 | GLM-4',
|
||||
'glm-4-plus | GLM-4 Plus',
|
||||
'glm-4-air | GLM-4 Air',
|
||||
'glm-4-airx | GLM-4 AirX',
|
||||
'glm-4-long | GLM-4 Long',
|
||||
'glm-4-flash | GLM-4 Flash',
|
||||
'glm-4v | GLM-4V',
|
||||
'glm-4v-plus | GLM-4V Plus',
|
||||
'glm-3-turbo | GLM-3 Turbo'
|
||||
],
|
||||
'qianwen' => [
|
||||
'qwen-max | QWEN Max',
|
||||
'qwen-max-latest | QWEN Max Latest',
|
||||
'qwen-turbo | QWEN Turbo',
|
||||
'qwen-turbo-latest | QWEN Turbo Latest',
|
||||
'qwen-plus | QWEN Plus',
|
||||
'qwen-plus-latest | QWEN Plus Latest',
|
||||
'qwen-long | QWEN Long'
|
||||
],
|
||||
'wenxin' => [
|
||||
'ernie-4.0-8k | Ernie 4.0 8K',
|
||||
'ernie-4.0-8k-latest | Ernie 4.0 8K Latest',
|
||||
'ernie-4.0-turbo-128k | Ernie 4.0 Turbo 128K',
|
||||
'ernie-4.0-turbo-8k | Ernie 4.0 Turbo 8K',
|
||||
'ernie-3.5-128k | Ernie 3.5 128K',
|
||||
'ernie-3.5-8k | Ernie 3.5 8K',
|
||||
'ernie-speed-128k | Ernie Speed 128K',
|
||||
'ernie-speed-8k | Ernie Speed 8K',
|
||||
'ernie-lite-8k | Ernie Lite 8K',
|
||||
'ernie-tiny-8k | Ernie Tiny 8K'
|
||||
],
|
||||
default => [],
|
||||
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
|
||||
return match ($vendor) {
|
||||
'ollama' => $key !== '' || !empty($setting['ollama_base_url']),
|
||||
default => $key !== '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,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
|
||||
@@ -303,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,6 +25,9 @@ use Carbon\Carbon;
|
||||
* @property string|null $tel 联系电话
|
||||
* @property string $nickname 昵称
|
||||
* @property string|null $profession 职位/职称
|
||||
* @property string|null $birthday 生日
|
||||
* @property string|null $address 地址
|
||||
* @property string|null $introduction 个人简介
|
||||
* @property string $userimg 头像
|
||||
* @property string|null $encrypt
|
||||
* @property string|null $password 登录密码
|
||||
@@ -49,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)
|
||||
@@ -60,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)
|
||||
@@ -79,6 +89,8 @@ use Carbon\Carbon;
|
||||
*/
|
||||
class User extends AbstractModel
|
||||
{
|
||||
const IMPORT_MAX = 500;
|
||||
|
||||
protected $primaryKey = 'userid';
|
||||
|
||||
protected $hidden = [
|
||||
@@ -310,7 +322,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(),
|
||||
@@ -331,6 +343,7 @@ class User extends AbstractModel
|
||||
//
|
||||
return $this->delete();
|
||||
});
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,7 +417,291 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
|
||||
* @param string $email
|
||||
* @param string $password
|
||||
* @param string $nickname
|
||||
* @param array $options changePass(bool,默认true) / department(int[]) / profession(string)
|
||||
* @return self
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function createByAdmin(string $email, $password, string $nickname, array $options = []): self
|
||||
{
|
||||
$nickname = trim($nickname);
|
||||
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
|
||||
throw new ApiException('昵称需为2-20个字');
|
||||
}
|
||||
$changePass = ($options['changePass'] ?? true) ? 1 : 0;
|
||||
$profession = trim((string)($options['profession'] ?? ''));
|
||||
// 校验前置(reg 之前快速失败,且可在无 Swoole 环境单测)
|
||||
self::assertValidProfession($profession);
|
||||
$departmentIds = self::assertValidDepartments($options['department'] ?? []);
|
||||
// 复用 reg:邮箱校验/查重、passwordPolicy、Doo::userCreate、az/pinyin、全员群、索引同步、user_onboard hook
|
||||
$user = self::reg($email, $password, ['nickname' => $nickname]);
|
||||
// 管理员显式创建的账号视为正式员工,去除系统 reg_identity 可能带上的 temp
|
||||
if (in_array('temp', $user->identity)) {
|
||||
$user->identity = Base::arrayImplode(array_diff($user->identity, ['temp']));
|
||||
}
|
||||
$user->changepass = $changePass; // 复用现有首登强制改密机制
|
||||
if ($profession !== '') {
|
||||
$user->profession = $profession;
|
||||
}
|
||||
if ($departmentIds) {
|
||||
$user->department = Base::arrayImplode($departmentIds);
|
||||
}
|
||||
$user->save();
|
||||
// 设置了部门 → 加入对应部门群(复刻 operation 的 type=department 入群逻辑)
|
||||
if ($departmentIds) {
|
||||
$departments = UserDepartment::whereIn('id', $departmentIds)->get();
|
||||
foreach ($departments as $department) {
|
||||
try {
|
||||
if ($department->dialog_id > 0 && $dialog = WebSocketDialog::find($department->dialog_id)) {
|
||||
$dialog->joinGroup([$user->userid], 0, true);
|
||||
$dialog->pushMsg("groupJoin", null, [$user->userid]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 部门入群为尽力投递:单个部门失败不影响账号创建与其他部门
|
||||
\Log::warning('createByAdmin: 部门入群失败', [
|
||||
'userid' => $user->userid,
|
||||
'department_id' => $department->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将上传表格(Excel::toArray 的二维数组)归一化为导入行
|
||||
* @param array $sheet
|
||||
* @return array [{line, email, nickname, password}]
|
||||
*/
|
||||
public static function parseImportRows(array $sheet): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($sheet as $index => $cells) {
|
||||
if ($index === 0) {
|
||||
continue; // 表头
|
||||
}
|
||||
$email = trim((string)($cells[0] ?? ''));
|
||||
$nickname = trim((string)($cells[1] ?? ''));
|
||||
$password = trim((string)($cells[2] ?? ''));
|
||||
$profession = trim((string)($cells[3] ?? ''));
|
||||
if ($email === '' && $nickname === '' && $password === '') {
|
||||
continue; // 空行(仅职位有值也视为空行跳过)
|
||||
}
|
||||
$rows[] = [
|
||||
'line' => $index + 1, // 电子表格行号(从 1 开始)
|
||||
'email' => $email,
|
||||
'nickname' => $nickname,
|
||||
'password' => $password,
|
||||
'profession' => $profession,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验单条导入行
|
||||
* @param array $row ['email'=>,'nickname'=>,'password'=>,'profession'=>(选填)]
|
||||
* @return string|null 错误文案;null 表示通过
|
||||
*/
|
||||
public static function validateImportRow(array $row): ?string
|
||||
{
|
||||
$email = trim((string)($row['email'] ?? ''));
|
||||
$nickname = trim((string)($row['nickname'] ?? ''));
|
||||
$password = trim((string)($row['password'] ?? ''));
|
||||
if ($email === '' || $nickname === '' || $password === '') {
|
||||
return '邮箱、昵称、初始密码均为必填';
|
||||
}
|
||||
if (!Base::isEmail($email)) {
|
||||
return '邮箱格式不正确';
|
||||
}
|
||||
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
|
||||
return '昵称需为2-20个字';
|
||||
}
|
||||
try {
|
||||
self::passwordPolicy($password);
|
||||
} catch (ApiException $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
// 职位/职称选填,填写则校验 2-20 字
|
||||
try {
|
||||
self::assertValidProfession((string)($row['profession'] ?? ''));
|
||||
} catch (ApiException $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验职位/职称:非空时必须 2-20 字(复用 operation 的现有文案)
|
||||
* @param string $profession
|
||||
* @return void
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function assertValidProfession(string $profession): void
|
||||
{
|
||||
$profession = trim($profession);
|
||||
if ($profession === '') {
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($profession) < 2) {
|
||||
throw new ApiException('职位/职称不可以少于2个字');
|
||||
}
|
||||
if (mb_strlen($profession) > 20) {
|
||||
throw new ApiException('职位/职称最多只能设置20个字');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整并校验部门 ID 列表:转正整数去重、最多 10 个、且每个必须存在
|
||||
* @param mixed $ids
|
||||
* @return int[]
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function assertValidDepartments($ids): array
|
||||
{
|
||||
if (!is_array($ids)) {
|
||||
$ids = [];
|
||||
}
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
||||
if (count($ids) > 10) {
|
||||
throw new ApiException('最多只可加入10个部门');
|
||||
}
|
||||
if ($ids) {
|
||||
$existing = UserDepartment::whereIn('id', $ids)->pluck('id')->map(fn($v) => (int)$v)->all();
|
||||
if (count($existing) < count($ids)) {
|
||||
throw new ApiException('修改部门不存在');
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入用户(部门/职位逐行:department 来自前端逐行设置,profession 来自 Excel 行)
|
||||
* @param array $rows 每行含 email/nickname/password/profession,可选 department(int[])
|
||||
* @param bool $changePass 是否要求首登改密(对本批所有账号生效)
|
||||
* @return array ['total'=>int, 'success'=>int, 'failed'=>[['line','email','reason']]]
|
||||
* @throws ApiException 行数超限
|
||||
*/
|
||||
public static function importUsers(array $rows, bool $changePass = true): array
|
||||
{
|
||||
if (count($rows) > self::IMPORT_MAX) {
|
||||
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
|
||||
}
|
||||
$success = 0;
|
||||
$failed = [];
|
||||
$seen = [];
|
||||
foreach ($rows as $row) {
|
||||
$error = self::validateImportRow($row);
|
||||
if ($error === null) {
|
||||
$emailLower = strtolower(trim((string)$row['email']));
|
||||
if (isset($seen[$emailLower])) {
|
||||
$error = '文件内邮箱重复';
|
||||
} else {
|
||||
$seen[$emailLower] = true;
|
||||
}
|
||||
}
|
||||
if ($error === null) {
|
||||
try {
|
||||
self::createByAdmin($row['email'], $row['password'], $row['nickname'], [
|
||||
'changePass' => $changePass,
|
||||
'department' => $row['department'] ?? [],
|
||||
'profession' => $row['profession'] ?? '',
|
||||
]);
|
||||
$success++;
|
||||
continue;
|
||||
} catch (ApiException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
$failed[] = [
|
||||
'line' => $row['line'] ?? 0,
|
||||
'email' => $row['email'] ?? '',
|
||||
'reason' => $error,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入预览(只解析+校验,不创建任何账号)
|
||||
* 逐行判定 ok/error:必填/邮箱格式/昵称长度/密码策略、文件内邮箱重复、系统中邮箱已存在
|
||||
* @param array $rows parseImportRows 的输出
|
||||
* @return array ['total'=>int,'valid'=>int,'invalid'=>int,'rows'=>[['line','email','nickname','password','status','reason']]]
|
||||
*/
|
||||
public static function importPreview(array $rows): array
|
||||
{
|
||||
if (count($rows) > self::IMPORT_MAX) {
|
||||
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
|
||||
}
|
||||
// 预查系统中已存在的邮箱(小写比较)
|
||||
$emails = [];
|
||||
foreach ($rows as $row) {
|
||||
$e = strtolower(trim((string)($row['email'] ?? '')));
|
||||
if ($e !== '') {
|
||||
$emails[$e] = true;
|
||||
}
|
||||
}
|
||||
$existing = [];
|
||||
if ($emails) {
|
||||
foreach (self::whereIn('email', array_keys($emails))->pluck('email') as $em) {
|
||||
$existing[strtolower($em)] = true;
|
||||
}
|
||||
}
|
||||
$seen = [];
|
||||
$valid = 0;
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$reason = self::validateImportRow($row);
|
||||
$emailLower = strtolower(trim((string)($row['email'] ?? '')));
|
||||
if ($reason === null) {
|
||||
if (isset($seen[$emailLower])) {
|
||||
$reason = '文件内邮箱重复';
|
||||
} else {
|
||||
$seen[$emailLower] = true;
|
||||
if (isset($existing[$emailLower])) {
|
||||
$reason = '邮箱地址已存在';
|
||||
}
|
||||
}
|
||||
}
|
||||
$ok = $reason === null;
|
||||
if ($ok) {
|
||||
$valid++;
|
||||
}
|
||||
$list[] = [
|
||||
'line' => $row['line'] ?? 0,
|
||||
'email' => $row['email'] ?? '',
|
||||
'nickname' => $row['nickname'] ?? '',
|
||||
'password' => $row['password'] ?? '',
|
||||
'profession' => $row['profession'] ?? '',
|
||||
'status' => $ok ? 'ok' : 'error',
|
||||
'reason' => $reason ?? '',
|
||||
];
|
||||
}
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'valid' => $valid,
|
||||
'invalid' => count($rows) - $valid,
|
||||
'rows' => $list,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -764,24 +1061,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}%");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
102
app/Models/UserAppSort.php
Normal file
102
app/Models/UserAppSort.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* App\Models\UserAppSort
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $userid 用户ID
|
||||
* @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)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserAppSort extends AbstractModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'userid',
|
||||
'sorts',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sorts' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取用户排序配置
|
||||
* @param int $userid
|
||||
* @return array
|
||||
*/
|
||||
public static function getSorts(int $userid): array
|
||||
{
|
||||
$record = static::whereUserid($userid)->first();
|
||||
if (!$record) {
|
||||
return self::normalizeSorts([]);
|
||||
}
|
||||
return self::normalizeSorts($record->sorts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存排序配置
|
||||
* @param int $userid
|
||||
* @param array $sorts
|
||||
* @return static
|
||||
*/
|
||||
public static function saveSorts(int $userid, array $sorts): self
|
||||
{
|
||||
return static::updateOrCreate(
|
||||
['userid' => $userid],
|
||||
['sorts' => self::normalizeSorts($sorts)]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化排序数据
|
||||
* @param mixed $sorts
|
||||
* @return array
|
||||
*/
|
||||
public static function normalizeSorts($sorts): array
|
||||
{
|
||||
$result = [
|
||||
'base' => [],
|
||||
'admin' => [],
|
||||
];
|
||||
if (!is_array($sorts)) {
|
||||
return $result;
|
||||
}
|
||||
foreach (['base', 'admin'] as $group) {
|
||||
$list = $sorts[$group] ?? [];
|
||||
if (!is_array($list)) {
|
||||
$list = [];
|
||||
}
|
||||
$normalized = [];
|
||||
foreach ($list as $value) {
|
||||
if (!is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$normalized[] = $value;
|
||||
}
|
||||
$result[$group] = array_values(array_unique($normalized));
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ namespace App\Models;
|
||||
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Extranet;
|
||||
use App\Module\Ihttp;
|
||||
use App\Module\Timer;
|
||||
use App\Tasks\JokeSoupTask;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* App\Models\UserBot
|
||||
@@ -20,6 +21,7 @@ use Carbon\Carbon;
|
||||
* @property \Illuminate\Support\Carbon|null $clear_at 下一次清理时间
|
||||
* @property string|null $webhook_url 消息webhook地址
|
||||
* @property int|null $webhook_num 消息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()
|
||||
@@ -38,12 +40,93 @@ use Carbon\Carbon;
|
||||
* @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
|
||||
*/
|
||||
class UserBot extends AbstractModel
|
||||
{
|
||||
public const WEBHOOK_EVENT_MESSAGE = 'message';
|
||||
public const WEBHOOK_EVENT_DIALOG_OPEN = 'dialog_open';
|
||||
public const WEBHOOK_EVENT_MEMBER_JOIN = 'member_join';
|
||||
public const WEBHOOK_EVENT_MEMBER_LEAVE = 'member_leave';
|
||||
|
||||
protected $casts = [
|
||||
'webhook_events' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取 webhook 事件配置
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return array
|
||||
*/
|
||||
public function getWebhookEventsAttribute(mixed $value): array
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return self::normalizeWebhookEvents(null, true);
|
||||
}
|
||||
return self::normalizeWebhookEvents($value, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 webhook 事件配置
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function setWebhookEventsAttribute(mixed $value): void
|
||||
{
|
||||
$useFallback = $value === null;
|
||||
$this->attributes['webhook_events'] = Base::array2json(self::normalizeWebhookEvents($value, $useFallback));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要触发指定 webhook 事件
|
||||
*
|
||||
* @param string $event
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldDispatchWebhook(string $event): bool
|
||||
{
|
||||
if (!$this->webhook_url) {
|
||||
return false;
|
||||
}
|
||||
if (!preg_match('/^https?:\/\//', $this->webhook_url)) {
|
||||
return false;
|
||||
}
|
||||
return in_array($event, $this->webhook_events ?? [], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 webhook
|
||||
*
|
||||
* @param string $event
|
||||
* @param array $data
|
||||
* @param int $timeout
|
||||
* @return array|null
|
||||
*/
|
||||
public function dispatchWebhook(string $event, array $data, int $timeout = 30): ?array
|
||||
{
|
||||
if (!$this->shouldDispatchWebhook($event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$data['event'] = $event;
|
||||
$result = Ihttp::ihttp_post($this->webhook_url, $data, $timeout);
|
||||
$this->increment('webhook_num');
|
||||
return $result;
|
||||
} catch (Throwable $th) {
|
||||
info(Base::array2json([
|
||||
'webhook_url' => $this->webhook_url,
|
||||
'data' => $data,
|
||||
'error' => $th->getMessage(),
|
||||
]));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否系统机器人
|
||||
@@ -68,6 +151,7 @@ class UserBot extends AbstractModel
|
||||
$name = match ($name) {
|
||||
'system-msg' => '系统消息',
|
||||
'task-alert' => '任务提醒',
|
||||
'todo-alert' => '待办提醒',
|
||||
'check-in' => '签到打卡',
|
||||
'anon-msg' => '匿名消息',
|
||||
'approval-alert' => '审批',
|
||||
@@ -270,16 +354,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);
|
||||
@@ -293,7 +408,7 @@ class UserBot extends AbstractModel
|
||||
$array[] = [
|
||||
'userid' => $UserCheckinMac->userid,
|
||||
'mac' => $UserCheckinMac->mac,
|
||||
'date' => $nowDate,
|
||||
'date' => $targetDate ?: $nowDate,
|
||||
];
|
||||
$checkins[] = [
|
||||
'userid' => $UserCheckinMac->userid,
|
||||
@@ -314,7 +429,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,
|
||||
@@ -349,7 +464,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;
|
||||
@@ -366,12 +482,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',
|
||||
@@ -385,7 +502,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,
|
||||
@@ -400,14 +518,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);
|
||||
}
|
||||
@@ -479,4 +596,42 @@ class UserBot extends AbstractModel
|
||||
}
|
||||
return Base::retSuccess("创建成功。", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可选的 webhook 事件
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function webhookEventOptions(): array
|
||||
{
|
||||
return [
|
||||
self::WEBHOOK_EVENT_MESSAGE,
|
||||
self::WEBHOOK_EVENT_DIALOG_OPEN,
|
||||
self::WEBHOOK_EVENT_MEMBER_JOIN,
|
||||
self::WEBHOOK_EVENT_MEMBER_LEAVE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化 webhook 事件配置
|
||||
*
|
||||
* @param mixed $events
|
||||
* @param bool $useFallback
|
||||
* @return array
|
||||
*/
|
||||
public static function normalizeWebhookEvents(mixed $events, bool $useFallback = true): array
|
||||
{
|
||||
if (is_string($events)) {
|
||||
$events = Base::json2array($events);
|
||||
}
|
||||
if ($events === null) {
|
||||
$events = [];
|
||||
}
|
||||
if (!is_array($events)) {
|
||||
$events = [$events];
|
||||
}
|
||||
$events = array_filter(array_map('strval', $events));
|
||||
$events = array_values(array_intersect($events, self::webhookEventOptions()));
|
||||
return $events ?: ($useFallback ? [self::WEBHOOK_EVENT_MESSAGE] : []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Module\Base;
|
||||
use Cache;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* App\Models\UserDepartment
|
||||
@@ -35,6 +37,10 @@ use Cache;
|
||||
*/
|
||||
class UserDepartment extends AbstractModel
|
||||
{
|
||||
protected $appends = [
|
||||
'deputy_userids',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取所有父级部门
|
||||
* @return array
|
||||
@@ -50,6 +56,55 @@ class UserDepartment extends AbstractModel
|
||||
return $parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门管理员 userid 列表
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyUseridsAttribute(): array
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
return [];
|
||||
}
|
||||
return \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否部门负责人(与 owner_userid 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return (int)$this->owner_userid === (int)$userid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否部门管理员(在 user_department_owners 表里)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(含部门管理员)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存部门
|
||||
* @param $data
|
||||
@@ -65,18 +120,38 @@ class UserDepartment extends AbstractModel
|
||||
}
|
||||
$this->updateInstance($data);
|
||||
//
|
||||
// 防御:新负责人若残留在 user_department_owners 中(如曾是该部门管理员),清理掉
|
||||
// 否则后续 delDeputy / 罢免接口会把当前部门负责人误移出部门
|
||||
if ($this->id && (int)$this->owner_userid > 0) {
|
||||
\DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', (int)$this->owner_userid)
|
||||
->delete();
|
||||
}
|
||||
//
|
||||
if ($this->dialog_id > 0) {
|
||||
// 已有群
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->name = $this->name;
|
||||
$dialog->owner_id = $this->owner_userid;
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->owner_userid, 0, true);
|
||||
// 同步 role:原负责人 role=0、新负责人 role=1(部门管理员 role=2 保留不动)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->owner_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'name' => $dialog->name,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -86,16 +161,33 @@ class UserDepartment extends AbstractModel
|
||||
if (empty($dialog)) {
|
||||
throw new ApiException("选择现有聊天群不存在");
|
||||
}
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->name = $this->name;
|
||||
$dialog->owner_id = $this->owner_userid;
|
||||
$dialog->group_type = 'department';
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->owner_userid, 0, true);
|
||||
// 同步 role:原负责人 role=0、新负责人 role=1、原部门管理员 role=0
|
||||
// 原部门管理员清零:避免 dialog_users.role=2 与 user_department_owners 不一致
|
||||
// (部门管理员关系不带过来,须通过 addDeputy 显式重新任命)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', '!=', $this->owner_userid)
|
||||
->where('role', 2)
|
||||
->update(['role' => 0]);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->owner_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'name' => $dialog->name,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'group_type' => $dialog->group_type,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'notice', [
|
||||
'notice' => User::nickname() . " 将此群改为部门群"
|
||||
@@ -116,6 +208,12 @@ class UserDepartment extends AbstractModel
|
||||
$oldUser->department = array_diff($oldUser->department, [$this->id]);
|
||||
$oldUser->department = "," . implode(",", $oldUser->department) . ",";
|
||||
$oldUser->save();
|
||||
// 原主从 users.department 移除后也要退出部门群(保持成员关系=群关系一致)
|
||||
// checkDelete=false:业务流程跳过 owner_id/important 校验
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
$dialog?->exitGroup($oldUser->userid, 'remove', false, true);
|
||||
}
|
||||
}
|
||||
if ($newUser) {
|
||||
$newUser->department = array_diff($newUser->department, [$this->id]);
|
||||
@@ -126,6 +224,123 @@ class UserDepartment extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 任命部门管理员
|
||||
* - 部门管理员自动加入 users.department(成为部门成员,与负责人对齐)
|
||||
* - 部门管理员自动加入部门群 + 设 role=2
|
||||
* - 幂等(已是部门管理员不报错)
|
||||
*
|
||||
* @param int $userid
|
||||
* @return void
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function addDeputy($userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
throw new ApiException('请选择有效的成员');
|
||||
}
|
||||
$user = User::whereUserid($userid)->first();
|
||||
if (!$user) {
|
||||
throw new ApiException('该用户不存在');
|
||||
}
|
||||
if ((int)$this->owner_userid === (int)$userid) {
|
||||
throw new ApiException('不能将部门负责人任命为部门管理员');
|
||||
}
|
||||
|
||||
AbstractModel::transaction(function () use ($userid, $user) {
|
||||
// 写部门管理员表(unique key 自动幂等)
|
||||
\DB::table('user_department_owners')->insertOrIgnore([
|
||||
'department_id' => $this->id,
|
||||
'userid' => $userid,
|
||||
]);
|
||||
|
||||
// 加入 users.department(成为部门成员,与负责人对齐)
|
||||
$userDeptIds = $user->department; // accessor 返回数组
|
||||
if (!in_array($this->id, $userDeptIds)) {
|
||||
$userDeptIds = array_merge($userDeptIds, [$this->id]);
|
||||
$user->department = "," . implode(",", $userDeptIds) . ",";
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 加部门管理员入部门群 + 设 role=2 + important=true
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
// joinGroup($userid, $inviter, $important=null, $pushMsg=true)
|
||||
// important=true:部门管理员成员关系不可被普通群操作打散
|
||||
$dialog->joinGroup($userid, 0, true, true);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->update(['role' => 2]);
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 罢免部门管理员
|
||||
* - 删部门管理员表记录
|
||||
* - 从 users.department 移除该部门 ID(与负责人"离开部门"对齐)
|
||||
* - 退出部门群(成员关系=群关系一致)
|
||||
* - 幂等
|
||||
*
|
||||
* @param int $userid
|
||||
* @return void
|
||||
*/
|
||||
public function delDeputy($userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防御:当前部门负责人不能被罢免(saveDepartment 应已清理残留,此处兜底)
|
||||
// 仅清理 user_department_owners 中的悬挂记录,绝不联动移除其部门成员关系/部门群成员
|
||||
if ((int)$this->owner_userid === (int)$userid) {
|
||||
\DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
AbstractModel::transaction(function () use ($userid) {
|
||||
$deleted = \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->delete();
|
||||
|
||||
if ($deleted > 0) {
|
||||
// 从 users.department 移除该部门 ID
|
||||
$user = User::whereUserid($userid)->first();
|
||||
if ($user) {
|
||||
$userDeptIds = $user->department;
|
||||
if (in_array($this->id, $userDeptIds)) {
|
||||
$userDeptIds = array_diff($userDeptIds, [$this->id]);
|
||||
$user->department = "," . implode(",", $userDeptIds) . ",";
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
|
||||
// 退出部门群(exitGroup 会清除 dialog_users 记录,role 随之消失)
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
// checkDelete=false:业务流程跳过 owner_id/important 校验
|
||||
$dialog->exitGroup($userid, 'remove', false, true);
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除部门
|
||||
* @return void
|
||||
@@ -148,6 +363,8 @@ class UserDepartment extends AbstractModel
|
||||
// 解散群组
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
$dialog?->deleteDialog();
|
||||
// 清理部门管理员记录(防悬挂)
|
||||
\DB::table('user_department_owners')->where('department_id', $this->id)->delete();
|
||||
//
|
||||
$this->delete();
|
||||
}
|
||||
@@ -160,6 +377,7 @@ class UserDepartment extends AbstractModel
|
||||
*/
|
||||
public static function transfer($originalUserid, $newUserid)
|
||||
{
|
||||
// 部门负责人转让(保持现有逻辑)
|
||||
self::whereOwnerUserid($originalUserid)->chunkById(100, function ($list) use ($originalUserid, $newUserid) {
|
||||
/** @var self $item */
|
||||
foreach ($list as $item) {
|
||||
@@ -168,6 +386,11 @@ class UserDepartment extends AbstractModel
|
||||
]);
|
||||
}
|
||||
});
|
||||
// 部门管理员离职清理(新增):直接删除离职用户的所有部门管理员记录
|
||||
// 不需要清群 role —— UserTransfer::exitDialog 会把人踢出所有群,role 随成员关系一起消失
|
||||
\DB::table('user_department_owners')
|
||||
->where('userid', $originalUserid)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,6 +413,93 @@ class UserDepartment extends AbstractModel
|
||||
return array_unique($subIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户可切换负责人视角的部门(正负责人 + 部门管理员)
|
||||
* @param int $userid
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public static function getManagedDepartments($userid)
|
||||
{
|
||||
$userid = intval($userid);
|
||||
if ($userid <= 0) {
|
||||
return collect();
|
||||
}
|
||||
$deputyDepartmentIds = \DB::table('user_department_owners')
|
||||
->where('userid', $userid)
|
||||
->pluck('department_id')
|
||||
->map(fn($v) => intval($v))
|
||||
->toArray();
|
||||
|
||||
return self::select(['id', 'name', 'parent_id', 'owner_userid'])
|
||||
->where(function ($query) use ($userid, $deputyDepartmentIds) {
|
||||
$query->where('owner_userid', $userid);
|
||||
if ($deputyDepartmentIds) {
|
||||
$query->orWhereIn('id', $deputyDepartmentIds);
|
||||
}
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户选择的负责人视角部门范围(含所有下级部门)
|
||||
* @param int $userid
|
||||
* @param array|string|null $selectedIds all/空表示全部可管理部门
|
||||
* @return array
|
||||
*/
|
||||
public static function getManagedDepartmentScopeIds($userid, $selectedIds = null): array
|
||||
{
|
||||
$managedIds = self::getManagedDepartments($userid)->pluck('id')->map(fn($v) => intval($v))->toArray();
|
||||
if (empty($managedIds)) {
|
||||
return [];
|
||||
}
|
||||
if ($selectedIds === 'all' || $selectedIds === null || $selectedIds === '' || $selectedIds === []) {
|
||||
$selected = $managedIds;
|
||||
} else {
|
||||
if (!is_array($selectedIds)) {
|
||||
$selectedIds = explode(',', (string)$selectedIds);
|
||||
}
|
||||
$selected = array_values(array_intersect(
|
||||
array_map('intval', $selectedIds),
|
||||
$managedIds
|
||||
));
|
||||
}
|
||||
if (empty($selected)) {
|
||||
return [];
|
||||
}
|
||||
$scopeIds = [];
|
||||
foreach ($selected as $departmentId) {
|
||||
$scopeIds[] = $departmentId;
|
||||
$scopeIds = array_merge($scopeIds, self::getAllSubDepartmentIds($departmentId));
|
||||
}
|
||||
return array_values(array_unique(array_map('intval', $scopeIds)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取负责人视角可管理的成员 userid
|
||||
* @param int $userid
|
||||
* @param array|string|null $selectedIds
|
||||
* @return array
|
||||
*/
|
||||
public static function getManagedMemberUserids($userid, $selectedIds = null): array
|
||||
{
|
||||
$departmentIds = self::getManagedDepartmentScopeIds($userid, $selectedIds);
|
||||
if (empty($departmentIds)) {
|
||||
return [];
|
||||
}
|
||||
return User::select(['userid'])
|
||||
->where(function ($query) use ($departmentIds) {
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
$query->orWhere('department', 'like', "%,{$departmentId},%");
|
||||
}
|
||||
})
|
||||
->pluck('userid')
|
||||
->map(fn($v) => intval($v))
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取部门基本信息(缓存时间1小时)
|
||||
* @param int|array $ids
|
||||
@@ -232,4 +542,77 @@ class UserDepartment extends AbstractModel
|
||||
return is_array($ids) ? $result : $result->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门负责人视角上下文(只读)。
|
||||
* $defaultAll=true 用于项目内只读辅助接口兜底:前端漏传部门选择时按全部可管理部门判断。
|
||||
*/
|
||||
public static function ownerViewContext(User $user, bool $defaultAll = false): array
|
||||
{
|
||||
$ids = Request::input('department_owner_ids', Request::input('department_ids'));
|
||||
if (($ids === null || $ids === '') && $defaultAll) {
|
||||
$ids = 'all';
|
||||
}
|
||||
$empty = [
|
||||
'enabled' => false,
|
||||
'member_userids' => [],
|
||||
'project_ids' => [],
|
||||
'project_id_map' => [],
|
||||
'own_project_ids' => [],
|
||||
'own_project_id_map' => [],
|
||||
];
|
||||
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
return $empty;
|
||||
}
|
||||
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
|
||||
if (empty($memberUserids)) {
|
||||
return $empty;
|
||||
}
|
||||
// 项目可单独关闭"部门负责人视角可见",关闭后对负责人隐藏(含项目和任务群聊)
|
||||
$projectIds = ProjectUser::whereIn('project_users.userid', $memberUserids)
|
||||
->join('projects', 'projects.id', '=', 'project_users.project_id')
|
||||
->whereNull('projects.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('projects.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('projects.department_owner_view');
|
||||
})
|
||||
->distinct()
|
||||
->pluck('projects.id')
|
||||
->map(fn($v) => intval($v))
|
||||
->values()
|
||||
->toArray();
|
||||
$ownProjectIds = ProjectUser::whereUserid($user->userid)
|
||||
->pluck('project_id')
|
||||
->map(fn($v) => intval($v))
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
return [
|
||||
'enabled' => !empty($projectIds),
|
||||
'member_userids' => $memberUserids,
|
||||
'project_ids' => $projectIds,
|
||||
'project_id_map' => array_fill_keys($projectIds, true),
|
||||
'own_project_ids' => $ownProjectIds,
|
||||
'own_project_id_map' => array_fill_keys($ownProjectIds, true),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断项目是否属于部门只读范围(非本人项目)
|
||||
*/
|
||||
public static function isDepartmentReadonlyProject(array $context, int $projectId): bool
|
||||
{
|
||||
return !empty($context['enabled'])
|
||||
&& isset($context['project_id_map'][$projectId])
|
||||
&& !isset($context['own_project_id_map'][$projectId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为项目数据附加部门只读标记
|
||||
*/
|
||||
public static function appendDepartmentReadonlyProject(array $project, array $context): array
|
||||
{
|
||||
$project['department_readonly'] = self::isDepartmentReadonlyProject($context, intval($project['id']));
|
||||
return $project;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
115
app/Models/UserTag.php
Normal file
115
app/Models/UserTag.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
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';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by', 'userid')
|
||||
->select(['userid', 'nickname']);
|
||||
}
|
||||
|
||||
public function recognitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserTagRecognition::class, 'tag_id');
|
||||
}
|
||||
|
||||
public function canManage(User $viewer): bool
|
||||
{
|
||||
return $viewer->isAdmin()
|
||||
|| $viewer->userid === $this->user_id
|
||||
|| $viewer->userid === $this->created_by;
|
||||
}
|
||||
|
||||
public static function listWithMeta(int $targetUserId, ?User $viewer): array
|
||||
{
|
||||
$query = static::query()
|
||||
->where('user_id', $targetUserId)
|
||||
->with(['creator'])
|
||||
->withCount(['recognitions as recognition_total'])
|
||||
->orderByDesc('recognition_total')
|
||||
->orderBy('id');
|
||||
|
||||
$tags = $query->get();
|
||||
|
||||
$viewerId = $viewer?->userid ?? 0;
|
||||
$viewerIsAdmin = $viewer?->isAdmin() ?? false;
|
||||
$viewerIsOwner = $viewerId > 0 && $viewerId === $targetUserId;
|
||||
|
||||
$recognizedIds = [];
|
||||
if ($viewerId > 0 && $tags->isNotEmpty()) {
|
||||
$recognizedIds = UserTagRecognition::query()
|
||||
->where('user_id', $viewerId)
|
||||
->whereIn('tag_id', $tags->pluck('id'))
|
||||
->pluck('tag_id')
|
||||
->all();
|
||||
}
|
||||
$recognizedLookup = array_flip($recognizedIds);
|
||||
|
||||
$list = $tags->map(function (self $tag) use ($viewerId, $viewerIsAdmin, $viewerIsOwner, $recognizedLookup) {
|
||||
$canManage = $viewerIsAdmin || $viewerIsOwner || $viewerId === $tag->created_by;
|
||||
|
||||
return [
|
||||
'id' => $tag->id,
|
||||
'user_id' => $tag->user_id,
|
||||
'name' => $tag->name,
|
||||
'created_by' => $tag->created_by,
|
||||
'created_by_name' => $tag->creator?->nickname ?: '',
|
||||
'recognition_total' => (int) $tag->recognition_total,
|
||||
'recognized' => isset($recognizedLookup[$tag->id]),
|
||||
'can_edit' => $canManage,
|
||||
'can_delete' => $canManage,
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
return [
|
||||
'list' => $list,
|
||||
'top' => array_slice($list, 0, 10),
|
||||
'total' => count($list),
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Models/UserTagRecognition.php
Normal file
52
app/Models/UserTagRecognition.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
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';
|
||||
|
||||
protected $fillable = [
|
||||
'tag_id',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
public function tag(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserTag::class, 'tag_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id', 'userid')
|
||||
->select(['userid', 'nickname']);
|
||||
}
|
||||
}
|
||||
@@ -90,9 +90,15 @@ class UserTransfer extends AbstractModel
|
||||
$dialog->owner_id = $this->new_userid;
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->new_userid, 0);
|
||||
// 同步 role=1:保证 deputy_ids 与 owner_id 一致
|
||||
// 若 new_userid 之前是群管理员(role=2),升为群主后必须从 deputy 列表移出
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->new_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,6 +62,8 @@ class WebSocketDialog extends AbstractModel
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $appends = ['deputy_ids'];
|
||||
|
||||
/**
|
||||
* 头像地址
|
||||
* @param $value
|
||||
@@ -260,6 +262,15 @@ class WebSocketDialog extends AbstractModel
|
||||
$data[$field] = $data[$field] ?? null;
|
||||
}
|
||||
}
|
||||
// DB::table 列表/search/beyond 渠道进入的是 stdClass,不会触发 Eloquent $appends。
|
||||
// 这里统一补齐 deputy_ids,保证群管理员入口和标识在所有会话来源中一致。
|
||||
if (($data['type'] ?? null) === 'group' && !array_key_exists('deputy_ids', $data)) {
|
||||
$data['deputy_ids'] = WebSocketDialogUser::whereDialogId($data['id'])
|
||||
->where('role', 2)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
$data['avatar'] = Base::fillUrl($data['avatar']);
|
||||
|
||||
// 会员必要字段
|
||||
@@ -457,11 +468,12 @@ class WebSocketDialog extends AbstractModel
|
||||
* @param int|array $userid 加入的会员ID或会员ID组
|
||||
* @param int $inviter 邀请人
|
||||
* @param bool|null $important 重要人员(null不修改、bool修改)
|
||||
* @param bool $pushMsg 是否推送消息
|
||||
* @return bool
|
||||
*/
|
||||
public function joinGroup($userid, $inviter, $important = null)
|
||||
public function joinGroup($userid, $inviter, $important = null, $pushMsg = true)
|
||||
{
|
||||
AbstractModel::transaction(function () use ($important, $inviter, $userid) {
|
||||
AbstractModel::transaction(function () use ($important, $inviter, $userid, $pushMsg) {
|
||||
foreach (is_array($userid) ? $userid : [$userid] as $value) {
|
||||
if ($value > 0) {
|
||||
$updateData = [
|
||||
@@ -479,7 +491,7 @@ class WebSocketDialog extends AbstractModel
|
||||
'bot' => User::isBot($value) ? 1 : 0
|
||||
]);
|
||||
}, $isInsert);
|
||||
if ($isInsert) {
|
||||
if ($isInsert && $pushMsg) {
|
||||
WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [
|
||||
'notice' => User::userid2nickname($value) . " 已加入群组"
|
||||
], $inviter, true, true);
|
||||
@@ -487,9 +499,11 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
}
|
||||
});
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
if ($pushMsg) {
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -515,11 +529,40 @@ class WebSocketDialog extends AbstractModel
|
||||
foreach ($list as $item) {
|
||||
if ($checkDelete) {
|
||||
if ($type === 'remove') {
|
||||
// 移出时:如果是全员群仅允许管理员操作,其他群仅群主或邀请人可以操作
|
||||
// 移出时:如果是全员群仅允许管理员操作,其他群主/群管理员/邀请人可以操作
|
||||
if ($this->group_type === 'all') {
|
||||
User::auth("admin");
|
||||
} elseif (!in_array(User::userid(), [$this->owner_id, $item->inviter])) {
|
||||
throw new ApiException('只有群主或邀请人可以移出成员');
|
||||
} else {
|
||||
$actor = User::userid();
|
||||
// 未认证时拒绝
|
||||
if ($actor <= 0) {
|
||||
throw new ApiException('只有群主或邀请人可以移出成员');
|
||||
}
|
||||
|
||||
// 目标是群主或群管理员时的保护
|
||||
$targetIsPrimaryOwner = $this->isPrimaryOwner($item->userid);
|
||||
$targetIsDeputyOwner = $this->isDeputyOwner($item->userid);
|
||||
|
||||
if ($targetIsPrimaryOwner || $targetIsDeputyOwner) {
|
||||
// 普通邀请人不能移出群主或群管理员
|
||||
$actorIsPrimaryOwner = $this->isPrimaryOwner($actor);
|
||||
$actorIsDeputyOwner = $this->isDeputyOwner($actor);
|
||||
|
||||
if (!$actorIsPrimaryOwner && !$actorIsDeputyOwner) {
|
||||
throw new ApiException('普通成员不能移出群主或群管理员');
|
||||
}
|
||||
|
||||
// 群管理员不能移出群主或其他群管理员
|
||||
if ($actorIsDeputyOwner && !$actorIsPrimaryOwner) {
|
||||
throw new ApiException('群管理员不能移出群主或其他群管理员');
|
||||
}
|
||||
}
|
||||
|
||||
// 普通成员:群主、群管理员、邀请人可移出
|
||||
$allowedActor = $this->isOwner($actor) || $actor === (int)$item->inviter;
|
||||
if (!$allowedActor) {
|
||||
throw new ApiException('只有群主、群管理员或邀请人可以移出成员');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($item->userid == $this->owner_id) {
|
||||
@@ -530,6 +573,7 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
}
|
||||
//
|
||||
$item->operator_id = User::userid();
|
||||
$item->delete();
|
||||
//
|
||||
if ($pushMsg) {
|
||||
@@ -546,9 +590,47 @@ class WebSocketDialog extends AbstractModel
|
||||
});
|
||||
});
|
||||
//
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
if ($pushMsg) {
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送成员事件到机器人 webhook
|
||||
* @param string $event
|
||||
* @param int $memberId
|
||||
* @param int $operatorId
|
||||
* @return void
|
||||
*/
|
||||
public function dispatchMemberWebhook(string $event, int $memberId, int $operatorId): void
|
||||
{
|
||||
$botIds = $this->dialogUser()->where('bot', 1)->pluck('userid')->toArray();
|
||||
if (empty($botIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userBots = UserBot::whereIn('bot_id', $botIds)->get();
|
||||
if ($userBots->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$member = User::find($memberId, ['userid', 'nickname', 'email', 'bot'])?->toArray();
|
||||
$operator = $operatorId === $memberId ? $member : User::find($operatorId, ['userid', 'nickname', 'email', 'bot'])?->toArray();
|
||||
|
||||
$payload = [
|
||||
'dialog_id' => $this->id,
|
||||
'dialog_type' => $this->type,
|
||||
'group_type' => $this->group_type,
|
||||
'dialog_name' => $this->getGroupName(),
|
||||
'member' => $member,
|
||||
'operator' => $operator,
|
||||
];
|
||||
|
||||
foreach ($userBots as $userBot) {
|
||||
$userBot->dispatchWebhook($event, $payload, 10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,6 +680,89 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否群主(与 owner_id 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
return $userid > 0 && (int)$this->owner_id === (int)$userid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否群管理员(仅 web_socket_dialog_users.role=2)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return WebSocketDialogUser::where('dialog_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->where('role', 2)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否群主(含群管理员)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有权限设置/取消本会话内「他人」的待办
|
||||
* 放行:群主/群管理员、关联项目负责人/项目管理员、关联任务负责人(及任务所属项目负责人/管理员)
|
||||
*
|
||||
* @param int $userid
|
||||
* @return bool
|
||||
*/
|
||||
public function checkTodoOwnerPermission($userid): bool
|
||||
{
|
||||
$userid = intval($userid);
|
||||
if ($userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
// 群主 / 群管理员
|
||||
if ($this->isOwner($userid)) {
|
||||
return true;
|
||||
}
|
||||
// 关联项目(项目群)负责人 / 项目管理员
|
||||
$project = Project::whereDialogId($this->id)->first();
|
||||
if ($project && $project->isOwner($userid)) {
|
||||
return true;
|
||||
}
|
||||
// 关联任务(任务群)负责人,及任务所属项目负责人 / 管理员
|
||||
$task = ProjectTask::whereDialogId($this->id)->first();
|
||||
if ($task) {
|
||||
if (ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->whereOwner(1)->exists()) {
|
||||
return true;
|
||||
}
|
||||
$taskProject = Project::find($task->project_id);
|
||||
if ($taskProject && $taskProject->isOwner($userid)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 群管理员 userid 列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyIdsAttribute(): array
|
||||
{
|
||||
if (!$this->id) {
|
||||
return [];
|
||||
}
|
||||
return WebSocketDialogUser::where('dialog_id', $this->id)
|
||||
->where('role', 2)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查禁言
|
||||
* @param $userid
|
||||
@@ -783,6 +948,13 @@ class WebSocketDialog extends AbstractModel
|
||||
if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) {
|
||||
return $dialog;
|
||||
}
|
||||
// 部门负责人只读视角:项目/任务群按项目级共享放行(任务数据另按可见性校验,与普通成员一致)
|
||||
if ($projectId > 0 && $checkOwner === false) {
|
||||
$departmentView = UserDepartment::ownerViewContext(User::auth(), true);
|
||||
if (UserDepartment::isDepartmentReadonlyProject($departmentView, $projectId)) {
|
||||
return $dialog;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'okr':
|
||||
@@ -820,6 +992,7 @@ class WebSocketDialog extends AbstractModel
|
||||
WebSocketDialogUser::createInstance([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $value,
|
||||
'role' => ($owner_id > 0 && (int)$value === (int)$owner_id) ? 1 : 0,
|
||||
'bot' => User::isBot($value) ? 1 : 0,
|
||||
'important' => !in_array($group_type, ['user', 'all']),
|
||||
'last_at' => in_array($group_type, ['user', 'department', 'all']) ? Carbon::now() : null,
|
||||
|
||||
@@ -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
|
||||
@@ -382,7 +414,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
* @param array $userids 设置给指定会员
|
||||
* @return mixed
|
||||
*/
|
||||
public function toggleTodoMsg($sender, $userids = [])
|
||||
public function toggleTodoMsg($sender, $userids = [], $remindAt = false)
|
||||
{
|
||||
if (in_array($this->type, ['tag', 'todo', 'notice'])) {
|
||||
return Base::retError('此消息不支持设待办');
|
||||
@@ -391,6 +423,14 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
$current = WebSocketDialogMsgTodo::whereMsgId($this->id)->pluck('userid')->toArray();
|
||||
$cancel = array_diff($current, $userids);
|
||||
$setup = array_diff($userids, $current);
|
||||
// 待办操作权限管控(系统开关:禁止其他人员设置/取消待办)
|
||||
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
|
||||
$affected = array_unique(array_merge($cancel, $setup)); // 本次真正影响到的用户
|
||||
$others = array_diff($affected, [$sender]); // 排除"自己"
|
||||
if ($others && !$dialog->checkTodoOwnerPermission($sender)) {
|
||||
return Base::retError('仅群主、项目/任务负责人可设置或取消他人待办');
|
||||
}
|
||||
}
|
||||
//
|
||||
$this->todo = $setup || count($current) > count($cancel) ? $sender : 0;
|
||||
$this->save();
|
||||
@@ -445,12 +485,39 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
];
|
||||
$dialog->pushMsg('update', $upData);
|
||||
//
|
||||
// 提醒时间:仅当调用方显式传入时处理(false=不传则不动既有提醒)
|
||||
if ($remindAt !== false) {
|
||||
$this->setTodoRemind($userids, $remindAt ?: null);
|
||||
}
|
||||
//
|
||||
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
|
||||
'add' => $addData,
|
||||
'update' => $upData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置/取消本消息指定成员待办的提醒时间(纯数据,无推送)。
|
||||
* 改动会把 reminded_at 重置为 null,使其可再次到点提醒。
|
||||
*
|
||||
* @param array $userids 目标成员
|
||||
* @param string|null $remindAt 提醒时间字符串;null/空 表示取消提醒
|
||||
* @return int 受影响行数
|
||||
*/
|
||||
public function setTodoRemind(array $userids, $remindAt = null)
|
||||
{
|
||||
$userids = array_values(array_filter(array_map('intval', $userids)));
|
||||
if (empty($userids)) {
|
||||
return 0;
|
||||
}
|
||||
return WebSocketDialogMsgTodo::whereMsgId($this->id)
|
||||
->whereIn('userid', $userids)
|
||||
->update([
|
||||
'remind_at' => $remindAt ?: null,
|
||||
'reminded_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发消息
|
||||
* @param array|int $dialogids
|
||||
@@ -460,9 +527,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 +597,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 +620,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 +850,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 +873,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('思考中...');
|
||||
@@ -695,7 +886,6 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
$text = $title;
|
||||
} else {
|
||||
$text = Base::markdown2html($text);
|
||||
$text = self::previewConvertTaskList($text);
|
||||
}
|
||||
}
|
||||
$text = preg_replace("/<img\s+class=\"emoticon\"[^>]*?alt=\"(\S+)\"[^>]*?>/", "[$1]", $text);
|
||||
@@ -711,36 +901,6 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换任务列表
|
||||
* @param $text
|
||||
* @return array|string|string[]|null
|
||||
*/
|
||||
private static function previewConvertTaskList($text) {
|
||||
$pattern = '/:::\s*(create-task-list|create-subtask-list)(.*?):::/s';
|
||||
$replacement = function($matches) {
|
||||
$content = $matches[2];
|
||||
$lines = explode("\n", trim($content));
|
||||
$result = [];
|
||||
$currentTitle = '';
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) continue;
|
||||
|
||||
if (preg_match('/^title:\s*(.+)$/', $line, $titleMatch)) {
|
||||
$currentTitle = $titleMatch[1];
|
||||
$result[] = $currentTitle;
|
||||
} elseif (preg_match('/^desc:\s*(.+)$/', $line, $descMatch)) {
|
||||
if (!empty($currentTitle)) {
|
||||
$result[] = $descMatch[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return implode("\n", $result);
|
||||
};
|
||||
return preg_replace_callback($pattern, $replacement, $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览文件消息
|
||||
* @param $msg
|
||||
@@ -865,8 +1025,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
switch ($this->type) {
|
||||
case "file":
|
||||
// 提取文件消息
|
||||
$msgData = Base::json2array($this->getRawOriginal('msg'));
|
||||
$result = $this->convertMentionFormat("path", $msgData['path'], $msgData['name'], $reserves);
|
||||
$result = " 文件:{$this->msg['name']}(大小:{$this->msg['size']}B,URL:{$this->msg['path']}) ";
|
||||
break;
|
||||
|
||||
case "text":
|
||||
@@ -889,29 +1048,31 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
// 提及任务、文件、报告
|
||||
$result = preg_replace_callback_array([
|
||||
// 用户
|
||||
"/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/" => function () {
|
||||
"/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/" => function ($match) {
|
||||
return "";
|
||||
},
|
||||
|
||||
// 任务
|
||||
"/<span class=\"mention task\" data-id=\"(\d+)\">#?(.*?)<\/span>/" => function ($match) use (&$reserves) {
|
||||
return $this->convertMentionFormat("task", $match[1], $match[2], $reserves);
|
||||
"/<span class=\"mention task\" data-id=\"(\d+)\">#?(.*?)<\/span>/" => function ($match) {
|
||||
return " 任务:{$match[2]} (任务ID:{$match[1]}) ";
|
||||
},
|
||||
|
||||
// 文件
|
||||
"/<a class=\"mention file\" href=\"([^\"']+?)\"[^>]*?>~?(.*?)<\/a>/" => function ($match) use (&$reserves) {
|
||||
$idOrCode = "";
|
||||
if (preg_match("/single\/file\/(.*?)$/", $match[1], $subMatch)) {
|
||||
return $this->convertMentionFormat("file", $subMatch[1], $match[2], $reserves);
|
||||
$idOrCode = " (" . (Base::isNumber($subMatch[1]) ? "文件ID:{$subMatch[1]}" : "文件分享码:{$subMatch[1]}") . ")";
|
||||
}
|
||||
return "";
|
||||
return " 文件:{$match[2]}{$idOrCode} ";
|
||||
},
|
||||
|
||||
// 报告
|
||||
"/<a class=\"mention report\" href=\"([^\"']+?)\"[^>]*?>%?(.*?)<\/a>/" => function ($match) use (&$reserves) {
|
||||
$idOrCode = "";
|
||||
if (preg_match("/single\/report\/detail\/(.*?)$/", $match[1], $subMatch)) {
|
||||
return $this->convertMentionFormat("report", $subMatch[1], $match[2], $reserves);
|
||||
$idOrCode = " (" . (Base::isNumber($subMatch[1]) ? "报告ID:{$subMatch[1]}" : "报告分享码:{$subMatch[1]}") . ")";
|
||||
}
|
||||
return "";
|
||||
return " 工作汇报:{$match[2]}{$idOrCode} ";
|
||||
},
|
||||
], $result);
|
||||
|
||||
@@ -926,35 +1087,15 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return '';
|
||||
}
|
||||
|
||||
// 处理 reserves
|
||||
foreach ($reserves as $rand => $mention) {
|
||||
$result = str_replace($rand, $mention, $result);
|
||||
}
|
||||
|
||||
// 截取最大长度
|
||||
if ($maxLength > 0 && mb_strlen($result) > $maxLength) {
|
||||
$result = mb_substr($result, 0, $maxLength);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
// 规范以斜杠开头的命令
|
||||
$result = preg_replace('/^\s*\\//', '/', $result);
|
||||
|
||||
/**
|
||||
* 转换提及消息格式
|
||||
* 将提及的任务、文件、报告等转换为统一的格式 [type#key#name]
|
||||
*
|
||||
* @param string $type 提及类型(task、file、report、path)
|
||||
* @param string $key 提及对象的唯一标识
|
||||
* @param string $name 提及对象的显示名称
|
||||
* @return string 格式化后的提及字符串
|
||||
*/
|
||||
private function convertMentionFormat($type, $key, $name, &$reserves)
|
||||
{
|
||||
$key = str_replace(['#', '-->'], '', $key);
|
||||
$name = str_replace(['#', '-->'], '', $name);
|
||||
$rand = Base::generatePassword(12);
|
||||
$reserves[$rand] = "<!--{$type}#{$key}#{$name}-->";
|
||||
return $rand;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1279,6 +1420,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"]);
|
||||
}
|
||||
|
||||
@@ -121,4 +121,30 @@ class WebSocketDialogMsgRead extends AbstractModel
|
||||
DB::update($sql, $bindings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记指定会话的历史消息为已读
|
||||
* @param int $dialogId
|
||||
* @param int $sessionId
|
||||
* @param int $chunkSize
|
||||
* @return void
|
||||
*/
|
||||
public static function markSessionMessagesAsRead(int $dialogId, int $sessionId, int $chunkSize = 100): void
|
||||
{
|
||||
if ($dialogId <= 0 || $sessionId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::whereDialogId($dialogId)
|
||||
->whereNull('read_at')
|
||||
->whereIn('msg_id', function ($query) use ($dialogId, $sessionId) {
|
||||
$query->select('id')
|
||||
->from((new WebSocketDialogMsg())->getTable())
|
||||
->where('dialog_id', $dialogId)
|
||||
->where('session_id', $sessionId);
|
||||
})
|
||||
->chunkById($chunkSize, function ($list) {
|
||||
self::onlyMarkRead($list);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\WebSocketDialogMsgTodo
|
||||
*
|
||||
@@ -50,4 +52,21 @@ class WebSocketDialogMsgTodo extends AbstractModel
|
||||
}
|
||||
return $this->appendattrs['msgData'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取到点待提醒的待办行:有提醒时间、未提醒、未完成、提醒时间已到。
|
||||
* 纯查询,无副作用,供 TodoRemindTask 使用。
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public static function dueReminders()
|
||||
{
|
||||
return self::whereNotNull('remind_at')
|
||||
->whereNull('reminded_at')
|
||||
->whereNull('done_at')
|
||||
->where('remind_at', '<=', Carbon::now())
|
||||
->orderBy('msg_id')
|
||||
->orderBy('id')
|
||||
->limit(500)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
1289
app/Module/AI.php
1289
app/Module/AI.php
File diff suppressed because it is too large
Load Diff
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');
|
||||
@@ -44,17 +48,84 @@ class Apps
|
||||
{
|
||||
if (!self::isInstalled($appId)) {
|
||||
$name = match ($appId) {
|
||||
'ai' => 'AI Robot',
|
||||
'ai' => 'AI Assistant',
|
||||
'face' => 'Face check-in',
|
||||
'appstore' => 'AppStore',
|
||||
'approve' => 'Approval',
|
||||
'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
|
||||
*/
|
||||
@@ -1827,6 +1827,19 @@ class Base
|
||||
return $platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是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移动端
|
||||
* @return bool
|
||||
|
||||
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) {
|
||||
|
||||
13
app/Module/UserImport.php
Normal file
13
app/Module/UserImport.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\ToArray;
|
||||
|
||||
class UserImport implements ToArray
|
||||
{
|
||||
public function array(array $array)
|
||||
{
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
21
app/Module/UserImportTemplate.php
Normal file
21
app/Module/UserImportTemplate.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
|
||||
class UserImportTemplate implements FromArray, WithHeadings
|
||||
{
|
||||
public function array(): array
|
||||
{
|
||||
return [
|
||||
['employee@example.com', '张三', 'Abc123456', '工程师'],
|
||||
];
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['邮箱(必填)', '昵称(必填,2-20字)', '初始密码(必填,6-32位)', '职位(选填,2-20字)'];
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +113,9 @@ class ProjectTaskObserver
|
||||
return ProjectUser::whereProjectId($projectTask->project_id)->pluck('userid')->toArray();
|
||||
}
|
||||
if (in_array('projectOwnerUser', $dataType)) {
|
||||
return ProjectUser::whereProjectId($projectTask->project_id)->where('owner', 1)->pluck('userid')->toArray();
|
||||
return ProjectUser::whereProjectId($projectTask->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray();
|
||||
}
|
||||
$array = [];
|
||||
if (in_array('task', $dataType)) {
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
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
|
||||
@@ -30,7 +32,19 @@ 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) {
|
||||
$dialog->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_MEMBER_JOIN, $webSocketDialogUser->userid, intval($webSocketDialogUser->inviter));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +55,7 @@ class WebSocketDialogUserObserver extends AbstractObserver
|
||||
*/
|
||||
public function updated(WebSocketDialogUser $webSocketDialogUser)
|
||||
{
|
||||
self::taskDeliver(new ZincSearchSyncTask('userSync', $webSocketDialogUser->toArray()));
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +67,20 @@ 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) {
|
||||
$operatorId = $webSocketDialogUser->operator_id ?? 0;
|
||||
$dialog->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_MEMBER_LEAVE, $webSocketDialogUser->userid, intval($operatorId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -427,6 +428,7 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
private function handleWebhookRequest($sendText, $replyText, WebSocketDialogMsg $msg, WebSocketDialog $dialog, User $botUser)
|
||||
{
|
||||
$webhookUrl = null;
|
||||
$userBot = null;
|
||||
$extras = ['timestamp' => time()];
|
||||
|
||||
try {
|
||||
@@ -445,7 +447,7 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
}
|
||||
// 判断AI应用是否安装
|
||||
if (!Apps::isInstalled('ai')) {
|
||||
throw new Exception('应用「AI Robot」未安装');
|
||||
throw new Exception('应用「AI Assistant」未安装');
|
||||
}
|
||||
// 整理机器人参数
|
||||
$setting = Base::setting('aibotSetting');
|
||||
@@ -488,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;
|
||||
@@ -507,17 +505,15 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
if (empty($extras['api_key'])) {
|
||||
throw new Exception('机器人未启用。');
|
||||
}
|
||||
$this->generateSystemPromptForAI($msg->userid, $dialog, $extras);
|
||||
$this->generateSystemPromptForAI($msg->userid, $dialog, $botUser, $extras);
|
||||
// 转换提及格式
|
||||
$sendText = self::convertMentionForAI($sendText);
|
||||
$replyText = self::convertMentionForAI($replyText);
|
||||
if ($replyText) {
|
||||
$sendText = <<<EOF
|
||||
<quoted_content>
|
||||
{$replyText}
|
||||
</quoted_content>
|
||||
|
||||
The content within the above quoted_content tags is a citation.
|
||||
上述 quoted_content 标签中的内容为引用。
|
||||
|
||||
{$sendText}
|
||||
EOF;
|
||||
@@ -530,15 +526,10 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
return;
|
||||
}
|
||||
$userBot = UserBot::whereBotId($botUser->userid)->first();
|
||||
if ($userBot) {
|
||||
$userBot->webhook_num++;
|
||||
$userBot->save();
|
||||
$webhookUrl = $userBot->webhook_url;
|
||||
if (!$userBot || !$userBot->shouldDispatchWebhook(UserBot::WEBHOOK_EVENT_MESSAGE)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!preg_match("/^https?:\/\//", $webhookUrl)) {
|
||||
return;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'template', [
|
||||
'type' => 'content',
|
||||
@@ -546,145 +537,60 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
], $botUser->userid, false, false, true); // todo 未能在任务end事件来发送任务
|
||||
return;
|
||||
}
|
||||
//
|
||||
try {
|
||||
$data = [
|
||||
'text' => $sendText,
|
||||
'reply_text' => $replyText,
|
||||
'token' => User::generateToken($botUser),
|
||||
'session_id' => $dialog->session_id,
|
||||
'dialog_id' => $dialog->id,
|
||||
'dialog_type' => $dialog->type,
|
||||
'msg_id' => $msg->id,
|
||||
'msg_uid' => $msg->userid,
|
||||
'mention' => $this->mention ? 1 : 0,
|
||||
'bot_uid' => $botUser->userid,
|
||||
'version' => Base::getVersion(),
|
||||
'extras' => Base::array2json($extras)
|
||||
|
||||
// 基本请求数据
|
||||
$data = [
|
||||
'event' => UserBot::WEBHOOK_EVENT_MESSAGE,
|
||||
'text' => $sendText,
|
||||
'reply_text' => $replyText,
|
||||
'token' => User::generateToken($botUser),
|
||||
'session_id' => $dialog->session_id,
|
||||
'dialog_id' => $dialog->id,
|
||||
'dialog_type' => $dialog->type,
|
||||
'msg_id' => $msg->id,
|
||||
'msg_uid' => $msg->userid,
|
||||
'mention' => $this->mention ? 1 : 0,
|
||||
'bot_uid' => $botUser->userid,
|
||||
'extras' => Base::array2json($extras),
|
||||
'version' => Base::getVersion(),
|
||||
'timestamp' => time(),
|
||||
];
|
||||
// 添加用户信息
|
||||
$userInfo = User::find($msg->userid);
|
||||
if ($userInfo) {
|
||||
$data['msg_user'] = [
|
||||
'userid' => $userInfo->userid,
|
||||
'email' => $userInfo->email,
|
||||
'nickname' => $userInfo->nickname,
|
||||
'profession' => $userInfo->profession,
|
||||
'lang' => $userInfo->lang,
|
||||
'token' => User::generateTokenNoDevice($userInfo, now()->addHour()),
|
||||
];
|
||||
// 添加用户信息
|
||||
$userInfo = User::find($msg->userid);
|
||||
if ($userInfo) {
|
||||
$data['msg_user'] = [
|
||||
'userid' => $userInfo->userid,
|
||||
'email' => $userInfo->email,
|
||||
'nickname' => $userInfo->nickname,
|
||||
'profession' => $userInfo->profession,
|
||||
'lang' => $userInfo->lang,
|
||||
'token' => User::generateTokenNoDevice($userInfo, now()->addHour()),
|
||||
];
|
||||
}
|
||||
// 请求Webhook
|
||||
$result = Ihttp::ihttp_post($webhookUrl, $data, 30);
|
||||
if ($result['data'] && $data = Base::json2array($result['data'])) {
|
||||
if ($data['code'] != 200 && $data['message']) {
|
||||
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'text', [
|
||||
'text' => $result['data']['message']
|
||||
], $botUser->userid, false, false, true);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $th) {
|
||||
info(Base::array2json([
|
||||
'bot_userid' => $botUser->userid,
|
||||
'dialog' => $dialog->id,
|
||||
'msg' => $msg->id,
|
||||
'webhook_url' => $webhookUrl,
|
||||
'error' => $th->getMessage(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为AI机器人转换提及消息格式
|
||||
* 将提及的任务、文件、报告转换为AI可理解的格式,并提取相关内容
|
||||
*
|
||||
* @param string $original 原始消息文本
|
||||
* @return string 转换后的消息文本,包含相关内容的标签
|
||||
* @throws Exception 当提及的对象不存在或读取失败时抛出异常
|
||||
*/
|
||||
public static function convertMentionForAI($original)
|
||||
{
|
||||
$array = [];
|
||||
$original = preg_replace_callback('/<!--(.*?)#(.*?)#(.*?)-->/', function ($match) use (&$array) {
|
||||
// 初始化 tag 内容
|
||||
$pathTag = null;
|
||||
$pathName = null;
|
||||
$pathContent = null;
|
||||
|
||||
// 根据 type 提取 tag 内容
|
||||
switch ($match[1]) {
|
||||
// 任务
|
||||
case 'task':
|
||||
$taskInfo = ProjectTask::with(['content'])->whereId(intval($match[2]))->first();
|
||||
if (!$taskInfo) {
|
||||
throw new Exception("任务不存在或已被删除");
|
||||
}
|
||||
$pathTag = "task_content";
|
||||
$pathName = addslashes($taskInfo->name) . " (ID:{$taskInfo->id})";
|
||||
$pathContent = implode("\n", $taskInfo->AIContext());
|
||||
break;
|
||||
|
||||
// 文件
|
||||
case 'file':
|
||||
$fileInfo = FileContent::idOrCodeToContent($match[2]);
|
||||
if (!$fileInfo || !isset($fileInfo->content['url'])) {
|
||||
throw new Exception("文件不存在或已被删除");
|
||||
}
|
||||
$urlPath = public_path($fileInfo->content['url']);
|
||||
if (!file_exists($urlPath)) {
|
||||
throw new Exception("文件不存在或已被删除");
|
||||
}
|
||||
$fileResult = TextExtractor::extractFile($urlPath);
|
||||
if (Base::isError($fileResult)) {
|
||||
throw new Exception("文件读取失败:" . $fileResult['msg']);
|
||||
}
|
||||
$pathTag = "file_content";
|
||||
$pathName = addslashes($match[3]) . " (ID:{$fileInfo->id})";
|
||||
$pathContent = $fileResult['data'];
|
||||
break;
|
||||
|
||||
// 文件路径
|
||||
case 'path':
|
||||
$urlPath = public_path($match[2]);
|
||||
if (!file_exists($urlPath)) {
|
||||
throw new Exception("文件不存在或已被删除");
|
||||
}
|
||||
$fileResult = TextExtractor::extractFile($urlPath);
|
||||
if (Base::isError($fileResult)) {
|
||||
throw new Exception("文件读取失败:" . $fileResult['msg']);
|
||||
}
|
||||
$pathTag = "file_content";
|
||||
$pathName = addslashes($match[3]);
|
||||
$pathContent = $fileResult['data'];
|
||||
break;
|
||||
|
||||
// 报告
|
||||
case 'report':
|
||||
$reportInfo = Report::idOrCodeToContent($match[2]);
|
||||
if (!$reportInfo) {
|
||||
throw new Exception("报告不存在或已被删除");
|
||||
}
|
||||
$pathTag = "report_content";
|
||||
$pathName = addslashes($match[3]) . " (ID:{$reportInfo->id})";
|
||||
$pathContent = Base::html2markdown($reportInfo->content);
|
||||
break;
|
||||
}
|
||||
|
||||
// 如果提取到 tag 内容,则添加到 contents 数组中
|
||||
if ($pathTag) {
|
||||
$array[] = "<{$pathTag} path=\"{$pathName}\">\n{$pathContent}\n</{$pathTag}>";
|
||||
return "`{$pathName}` (see below for {$pathTag} tag)";
|
||||
}
|
||||
|
||||
return "";
|
||||
}, $original);
|
||||
|
||||
// 添加 tag 内容
|
||||
if ($array) {
|
||||
$original .= "\n\n" . implode("\n\n", $array);
|
||||
}
|
||||
|
||||
return $original;
|
||||
$result = null;
|
||||
if ($userBot) {
|
||||
$result = $userBot->dispatchWebhook(UserBot::WEBHOOK_EVENT_MESSAGE, $data);
|
||||
} else {
|
||||
try {
|
||||
$result = Ihttp::ihttp_post($webhookUrl, $data, 30);
|
||||
} catch (\Throwable $th) {
|
||||
info(Base::array2json([
|
||||
'webhook_url' => $webhookUrl,
|
||||
'data' => $data,
|
||||
'error' => $th->getMessage(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
if ($result && isset($result['data'])) {
|
||||
$responseData = Base::json2array($result['data']);
|
||||
if (($responseData['code'] ?? 0) === 200 && !empty($responseData['message'])) {
|
||||
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'text', [
|
||||
'text' => $responseData['message']
|
||||
], $botUser->userid, false, false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -693,158 +599,83 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
*
|
||||
* @param int|null $userid 用户ID
|
||||
* @param WebSocketDialog $dialog 对话对象
|
||||
* @param User $botUser 机器人用户对象
|
||||
* @param array $extras 额外参数数组,通过引用传递以修改system_message
|
||||
* @return void
|
||||
*/
|
||||
private function generateSystemPromptForAI($userid, WebSocketDialog $dialog, array &$extras)
|
||||
private function generateSystemPromptForAI($userid, WebSocketDialog $dialog, User $botUser, array &$extras)
|
||||
{
|
||||
// 构建结构化的系统提示词
|
||||
$sections = [];
|
||||
|
||||
// 基础角色设定(如果有)
|
||||
if (!empty($extras['system_message'])) {
|
||||
$sections[] = <<<EOF
|
||||
<role_setting>
|
||||
{$extras['system_message']}
|
||||
</role_setting>
|
||||
EOF;
|
||||
}
|
||||
|
||||
// 上下文信息(项目、任务、部门等)+ 操作指令
|
||||
switch ($dialog->type) {
|
||||
// 用户对话
|
||||
case "user":
|
||||
$aiPrompt = WebSocketDialogConfig::where([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $userid,
|
||||
'type' => 'ai_prompt',
|
||||
])->value('value');
|
||||
if ($aiPrompt) {
|
||||
return $aiPrompt;
|
||||
}
|
||||
break;
|
||||
|
||||
// 群组对话
|
||||
case "group":
|
||||
switch ($dialog->group_type) {
|
||||
// 用户群
|
||||
case 'user':
|
||||
break;
|
||||
|
||||
// 项目群
|
||||
case 'project':
|
||||
$projectInfo = Project::whereDialogId($dialog->id)->first();
|
||||
if ($projectInfo) {
|
||||
$projectDesc = $projectInfo->desc ?: "-";
|
||||
$projectStatus = $projectInfo->archived_at ? '已归档' : '正在进行中';
|
||||
$sections[] = <<<EOF
|
||||
<context_info>
|
||||
当前我在项目【{$projectInfo->name}】中
|
||||
项目描述:{$projectDesc}
|
||||
项目状态:{$projectStatus}
|
||||
</context_info>
|
||||
EOF;
|
||||
|
||||
$sections[] = <<<EOF
|
||||
<instructions>
|
||||
如果你判断我想要或需要添加任务,请按照以下格式回复:
|
||||
|
||||
::: create-task-list
|
||||
title: 任务标题1
|
||||
desc: 任务描述1
|
||||
|
||||
title: 任务标题2
|
||||
desc: 任务描述2
|
||||
:::
|
||||
</instructions>
|
||||
EOF;
|
||||
}
|
||||
break;
|
||||
|
||||
// 任务群
|
||||
case 'task':
|
||||
$taskInfo = ProjectTask::with(['content'])->whereDialogId($dialog->id)->first();
|
||||
if ($taskInfo) {
|
||||
$taskContext = implode("\n", $taskInfo->AIContext());
|
||||
$sections[] = <<<EOF
|
||||
<context_info>
|
||||
当前我在任务【{$taskInfo->name}】中
|
||||
当前时间:{$taskInfo->updated_at}
|
||||
任务ID:{$taskInfo->id}
|
||||
{$taskContext}
|
||||
</context_info>
|
||||
EOF;
|
||||
|
||||
$sections[] = <<<EOF
|
||||
<instructions>
|
||||
如果你判断我想要或需要添加子任务,请按照以下格式回复:
|
||||
|
||||
::: create-subtask-list
|
||||
title: 子任务标题1
|
||||
title: 子任务标题2
|
||||
:::
|
||||
</instructions>
|
||||
EOF;
|
||||
}
|
||||
break;
|
||||
|
||||
// 部门群
|
||||
case 'department':
|
||||
$userDepartment = UserDepartment::whereDialogId($dialog->id)->first();
|
||||
if ($userDepartment) {
|
||||
$sections[] = <<<EOF
|
||||
<context_info>
|
||||
当前我在【{$userDepartment->name}】的部门群聊中
|
||||
</context_info>
|
||||
EOF;
|
||||
}
|
||||
break;
|
||||
|
||||
// 全体成员群
|
||||
case 'all':
|
||||
$sections[] = <<<EOF
|
||||
<context_info>
|
||||
当前我在【全体成员】的群聊中
|
||||
</context_info>
|
||||
EOF;
|
||||
break;
|
||||
}
|
||||
|
||||
// 聊天历史
|
||||
if ($dialog->type === 'group') {
|
||||
$chatHistory = $this->getRecentChatHistory($dialog, 15);
|
||||
if ($chatHistory) {
|
||||
$sections[] = <<<EOF
|
||||
<chat_history>
|
||||
{$chatHistory}
|
||||
</chat_history>
|
||||
EOF;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 更新系统提示词
|
||||
if (!empty($sections)) {
|
||||
$extras['system_message'] = implode("\n\n", $sections);
|
||||
// 用户自定义提示词(私聊场景优先使用)
|
||||
$customPrompt = null;
|
||||
if ($dialog->type === 'user') {
|
||||
$customPrompt = WebSocketDialogConfig::where([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $userid,
|
||||
'type' => 'ai_prompt',
|
||||
])->value('value');
|
||||
}
|
||||
|
||||
// 添加标签说明
|
||||
$tagDescs = [
|
||||
'role_setting' => '你的基础角色和行为定义',
|
||||
'instructions' => '特定功能的操作指令',
|
||||
'context_info' => '当前环境和状态信息',
|
||||
'chat_history' => '最近的对话历史记录',
|
||||
$prompt = [];
|
||||
|
||||
// 1. 基础角色(自定义提示词优先)
|
||||
if ($customPrompt) {
|
||||
$prompt[] = $customPrompt;
|
||||
} elseif (!empty($extras['system_message'])) {
|
||||
$prompt[] = $extras['system_message'];
|
||||
}
|
||||
|
||||
// 2. 上下文信息
|
||||
$currentTime = Carbon::now()->toDateTimeString();
|
||||
$contextLines = [
|
||||
"您是:{$botUser->nickname}(ID: {$botUser->userid})",
|
||||
"当前对话ID(dialog_id):{$dialog->id}",
|
||||
"当前系统时间(now):{$currentTime}",
|
||||
];
|
||||
$useTags = [];
|
||||
foreach ($tagDescs as $tag => $desc) {
|
||||
if (str_contains($extras['system_message'], '<' . $tag . '>')) {
|
||||
$useTags[] = '- <' . $tag . '>: ' . $desc;
|
||||
|
||||
if ($dialog->type === 'group') {
|
||||
switch ($dialog->group_type) {
|
||||
case 'project':
|
||||
$projectInfo = Project::whereDialogId($dialog->id)->first();
|
||||
if ($projectInfo) {
|
||||
$contextLines[] = "场景:项目群聊「{$projectInfo->name}」(project_id: {$projectInfo->id})";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task':
|
||||
$taskInfo = ProjectTask::with(['content'])->whereDialogId($dialog->id)->first();
|
||||
if ($taskInfo) {
|
||||
$contextLines[] = "场景:任务群聊「{$taskInfo->name}」(task_id: {$taskInfo->id})";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'department':
|
||||
$userDepartment = UserDepartment::whereDialogId($dialog->id)->first();
|
||||
if ($userDepartment) {
|
||||
$contextLines[] = "场景:部门群聊「{$userDepartment->name}」";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'all':
|
||||
$contextLines[] = "场景:全体成员群聊";
|
||||
break;
|
||||
}
|
||||
|
||||
// 3. 聊天历史(仅群聊)
|
||||
$chatHistory = $this->getRecentChatHistory($dialog, 15);
|
||||
if ($chatHistory) {
|
||||
$prompt[] = implode("\n", $contextLines);
|
||||
$prompt[] = "最近的对话记录:\n{$chatHistory}";
|
||||
} else {
|
||||
$prompt[] = implode("\n", $contextLines);
|
||||
}
|
||||
} else {
|
||||
$prompt[] = implode("\n", $contextLines);
|
||||
}
|
||||
if (!empty($useTags)) {
|
||||
$extras['system_message'] = "以下信息按标签组织:\n" . implode("\n", $useTags) . "\n\n" . $extras['system_message'];
|
||||
}
|
||||
|
||||
// 4. 条件性提示块(用户上下文 + 格式指南)
|
||||
$prompt[] = PromptPlaceholder::buildOptionalPrompts($userid, $dialog);
|
||||
|
||||
$extras['system_message'] = implode("\n----\n", array_filter($prompt));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -881,14 +712,14 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
// 使用XML标签格式,确保AI能清晰识别边界
|
||||
// 对用户名进行HTML转义,防止特殊字符破坏格式
|
||||
$safeUserName = htmlspecialchars($userName, ENT_QUOTES, 'UTF-8');
|
||||
return "<message user=\"{$safeUserName}\">\n{$content}\n</message>";
|
||||
return "<message userid=\"{$message->userid}\" nickname=\"{$safeUserName}\">\n{$content}\n</message>";
|
||||
})
|
||||
->reverse() // 反转集合,让时间顺序正确(最早的在前)
|
||||
->filter() // 过滤掉空内容的消息
|
||||
->values() // 重新索引数组
|
||||
->toArray();
|
||||
|
||||
return empty($chatMessages) ? null : implode("\n\n", $chatMessages);
|
||||
return empty($chatMessages) ? null : implode("\n", $chatMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
app/Tasks/TodoRemindTask.php
Normal file
86
app/Tasks/TodoRemindTask.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tasks;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogMsgTodo;
|
||||
use App\Module\Doo;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* 待办提醒:到点由 todo-alert 机器人在原会话发一条「引用原消息 + @被指派成员」的普通文本
|
||||
* (同一消息同批到点的成员合并一条)。
|
||||
*/
|
||||
class TodoRemindTask extends AbstractTask
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造提醒文本:每个被提醒成员一个 @ span + 提示语。
|
||||
* 直接拼 <span class="mention user" data-id> 是因为 sendMsg 不会调用 formatMsg,
|
||||
* 文本会原样入库,msgJoinGroup 据此 span 正则提取 @。
|
||||
*/
|
||||
public static function buildRemindText(array $mentionUserids): string
|
||||
{
|
||||
$nicknames = User::whereIn('userid', $mentionUserids)->pluck('nickname', 'userid');
|
||||
$mentionText = '';
|
||||
foreach ($mentionUserids as $uid) {
|
||||
$name = $nicknames[$uid] ?? $uid;
|
||||
$mentionText .= "<span class=\"mention user\" data-id=\"{$uid}\">@{$name}</span> ";
|
||||
}
|
||||
return $mentionText . Doo::translate('你有一条待办到提醒时间啦');
|
||||
}
|
||||
|
||||
public function start()
|
||||
{
|
||||
$rows = WebSocketDialogMsgTodo::dueReminders();
|
||||
if ($rows->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
$botUser = User::botGetOrCreate('todo-alert');
|
||||
if (empty($botUser)) {
|
||||
return;
|
||||
}
|
||||
foreach ($rows->groupBy('msg_id') as $msgId => $group) {
|
||||
$rowIds = $group->pluck('id')->toArray();
|
||||
$userids = $group->pluck('userid')->map('intval')->values()->toArray();
|
||||
//
|
||||
$msg = WebSocketDialogMsg::find($msgId);
|
||||
$dialog = $msg ? WebSocketDialog::find($msg->dialog_id) : null;
|
||||
if (empty($msg) || empty($dialog)) {
|
||||
// 原消息/会话已不存在:标记已提醒,避免空转重复扫描
|
||||
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
|
||||
continue;
|
||||
}
|
||||
//
|
||||
$memberIds = $dialog->dialogUser->pluck('userid')->map('intval')->values()->toArray();
|
||||
$mentionUserids = array_values(array_intersect($userids, $memberIds));
|
||||
if (empty($mentionUserids)) {
|
||||
// 被指派人都已退群:没人可 @,标记已提醒避免空转重复扫描
|
||||
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
|
||||
continue;
|
||||
}
|
||||
$res = WebSocketDialogMsg::sendMsg(
|
||||
"reply-{$msg->id}", // 引用原消息 → reply_data 自动填充
|
||||
$dialog->id,
|
||||
'text', // 普通文本
|
||||
['text' => self::buildRemindText($mentionUserids)],
|
||||
$botUser->userid,
|
||||
false, false, false // push_self / push_retry / push_silence
|
||||
);
|
||||
//
|
||||
if (\App\Module\Base::isSuccess($res)) {
|
||||
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function end()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ class WebSocketDialogMsgTask extends AbstractTask
|
||||
];
|
||||
// 机器人收到消处理
|
||||
$botUser = User::whereUserid($userid)->whereBot(1)->first();
|
||||
if ($botUser) {
|
||||
if ($botUser) { // 避免机器人处理自己发送的消息
|
||||
$this->endArray[] = new BotReceiveMsgTask($botUser->userid, $msg->id, $mentions, $this->client);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user