Compare commits
180 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c3fa91fb | ||
|
|
c03867304e | ||
|
|
b595120d62 | ||
|
|
8e66f0bfb3 | ||
|
|
e9ea1adc5d | ||
|
|
2eee171a50 | ||
|
|
fd6a8a3650 | ||
|
|
84a90b7760 | ||
|
|
7335c59b68 | ||
|
|
035c9d9d3d | ||
|
|
36da18af79 | ||
|
|
363badbc97 | ||
|
|
9be6265220 | ||
|
|
be53e6c6ac | ||
|
|
4eab130313 | ||
|
|
c706c515ee | ||
|
|
8a576595ce | ||
|
|
8c809bbff1 | ||
|
|
08ed396444 | ||
|
|
f5eb84589f | ||
|
|
daca384822 | ||
|
|
0a6e944a9a | ||
|
|
e0d1b08e89 | ||
|
|
6b54b7b1c5 | ||
|
|
adc7fb0d07 | ||
|
|
f969c8145c | ||
|
|
20b5daba50 | ||
|
|
aa2e0acaba | ||
|
|
e57736bcc1 | ||
|
|
a8db8dde7b | ||
|
|
635f6e5d5a | ||
|
|
4875574c6e | ||
|
|
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 |
@@ -1,55 +0,0 @@
|
||||
# Graphiti 长期记忆集成
|
||||
|
||||
本项目使用 Graphiti 作为「长期记忆层」,用于持久化用户偏好、工作流程、重要约束和关键事实。
|
||||
|
||||
**统一 group_id**: `dootask-main`
|
||||
|
||||
## 任务开始前(读取记忆)
|
||||
|
||||
在进行实质性工作(写代码、设计方案、做大改动)前,应先通过 Graphiti 查询已有记忆:
|
||||
|
||||
- 使用节点搜索(如 `search_nodes`)在 `group_id = "dootask-main"` 下查找与当前任务相关的 Preference / Procedure / Requirement
|
||||
- 使用事实搜索(如 `search_facts`)查找相关事实与实体关系
|
||||
- 查询语句中可包含:任务类型(Bug 修复 / 重构 / 新功能等)、涉及模块(任务、项目、对话、WebSocket、报表等)以及关键字 `dootask`
|
||||
|
||||
发现与当前任务高度相关的偏好 / 流程 / 约束时,应优先遵守;如存在冲突,应在回答中说明并做合理选择。
|
||||
|
||||
## 什么时候写入 Graphiti
|
||||
|
||||
| 类型 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **偏好 (Preferences)** | 用户表达持续性偏好时 | 语言、输出格式、技术选型 |
|
||||
| **流程 (Procedures)** | 形成稳定的开发/发布/调试流程时 | 可复用步骤 |
|
||||
| **约束 (Requirements)** | 项目长期有效的决策 | 不再支持某版本、架构约定 |
|
||||
| **事实 (Facts)** | 模块边界、服务调用关系、外部集成方式 | AgoraIO、Manticore Search |
|
||||
|
||||
### 写入建议
|
||||
|
||||
- 默认使用 `source: "text"`,在 `episode_body` 中用简洁结构化自然语言描述背景、类型、范围、具体内容
|
||||
- 需要结构化数据时可用 `source: "json"`,保证 `episode_body` 是合法 JSON 字符串
|
||||
- 所有写入默认使用 `group_id: "dootask-main"`
|
||||
|
||||
## 更新与更正
|
||||
|
||||
- 偏好 / 流程发生变化时,新增一条 episode 说明新约定,并标明这是对旧习惯的更新
|
||||
- 用户要求「忘记」某些记忆时,可通过删除或更正相关 episode / 关系的方式处理
|
||||
- 尽量通过新增 episode 记录「更正 / 废弃说明」,而不是直接改写历史事实
|
||||
|
||||
## 使用原则
|
||||
|
||||
- **尊重已存偏好**:编码风格、回答结构、工具选择等应对齐已知偏好
|
||||
- **遵循已有流程**:若图谱中已有与当前任务匹配的 Procedure,应尽量按步骤执行
|
||||
- **利用事实**:理解系统行为、模块边界、历史决策时优先查已存 Facts
|
||||
- **代码优先**:如 Graphiti 与当前代码实际冲突,应以代码实际为准,并视情况新增 episode 更新事实
|
||||
|
||||
## 不要写入的内容
|
||||
|
||||
- 敏感信息(密钥、密码、隐私数据)
|
||||
- 只与当前一次任务相关、未来不会复用的临时信息
|
||||
- 体量巨大的原始数据(完整日志、长脚本全文),应只存摘要和关键结论
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **先查再做**:在提出方案或改动架构前,优先查阅 Graphiti 中已有的设计、偏好和约束
|
||||
2. **能复用就沉淀**:只要发现某个偏好 / 流程 / 约束未来会反复用到,就尽快写入 Graphiti
|
||||
3. **保持一致**:确保 Graphiti 中的记忆与实际代码长期保持一致,避免「记忆漂移」
|
||||
119
.claude/skills/dootask-backup/SKILL.md
Normal file
119
.claude/skills/dootask-backup/SKILL.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
description: 备份 DooTask 数据:数据库(必须)+ public/uploads(排除 tmp,可选)+ docker/appstore/config(可选)。汇总到临时目录并附 README 说明,打包到 backup/ 按日期命名。只读取源数据、绝不删改,失败即停。
|
||||
---
|
||||
|
||||
# DooTask 数据备份
|
||||
|
||||
**刚性技能**——前置检查 → 选可选项 → 确认 → 执行 → 报告。只读取源数据生成归档,**绝不删除或修改任何源数据/既有备份**。任何一步失败立即停止。
|
||||
|
||||
## 备份范围
|
||||
|
||||
| 项 | 来源 | 是否必须 | 说明 |
|
||||
|----|------|---------|------|
|
||||
| 数据库 | `./cmd mysql backup` 产出的 `.sql.gz` | **必须** | 脚本内部用 mysqldump 导出当前库 |
|
||||
| 上传文件 | `public/uploads`(**排除 `public/uploads/tmp`**) | 可选 | 头像/聊天/任务/文件等真实上传数据;`tmp` 是临时目录,可重建,不备份 |
|
||||
| 应用配置 | `docker/appstore/config` | 可选 | 应用市场各应用的配置;含 **root 属主子目录**,收集时可能需 sudo |
|
||||
|
||||
> `docker/appstore/apps` **不在备份范围**——可从应用市场重新安装,无需备份。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
1. **工作目录**:在项目根(存在 `cmd`、`docker-compose.yml`)
|
||||
2. **数据库容器**:`mariadb` 容器在跑(DB 备份依赖它;不在则提示用户先 `./cmd up` 起服务)
|
||||
3. **磁盘空间**:确认 `backup/` 所在盘空间足够(数据库 dump 可能较大)
|
||||
4. **选可选项**:询问用户本次是否包含 `public/uploads` 和 `docker/appstore/config`(**默认两个都含**)
|
||||
|
||||
检查通过、可选项确定后,汇报本次将备份哪些项,**向用户确认一次**再执行。
|
||||
|
||||
## 执行
|
||||
|
||||
用一个统一时间戳贯穿全程:`TS=$(date +%Y%m%d_%H%M%S)`,临时目录 `WORK="tmp/dootask-backup-${TS}"`。
|
||||
|
||||
### 1) 建临时工作目录
|
||||
```shell
|
||||
mkdir -p "$WORK"
|
||||
```
|
||||
(`tmp/` 已被 gitignore,安全)
|
||||
|
||||
### 2) 数据库(必须)
|
||||
```shell
|
||||
./cmd mysql backup
|
||||
```
|
||||
脚本会把 dump 写到 `docker/mysql/backup/<库名>_<时间戳>.sql.gz` 并打印「备份文件:...」。**取该次产出的最新 dump** 复制进工作目录(不用关心它原始落在哪):
|
||||
```shell
|
||||
DB_FILE=$(ls -t docker/mysql/backup/*.sql.gz | head -1)
|
||||
cp "$DB_FILE" "$WORK/"
|
||||
```
|
||||
|
||||
### 3) public/uploads(可选,排除 tmp)
|
||||
```shell
|
||||
rsync -a --exclude='tmp' public/uploads/ "$WORK/uploads/"
|
||||
```
|
||||
> 无 rsync 时用 tar 管道:`mkdir -p "$WORK/uploads" && tar cf - --exclude='./tmp' -C public/uploads . | tar xf - -C "$WORK/uploads"`
|
||||
|
||||
### 4) docker/appstore/config(可选)
|
||||
```shell
|
||||
cp -a docker/appstore/config "$WORK/appstore-config"
|
||||
```
|
||||
> 含 root 属主子目录,若报 `permission denied`:改用 `sudo cp -a ...`,随后把整个工作目录属主归还当前用户,保证后续打包/清理不受阻:
|
||||
> ```shell
|
||||
> sudo chown -R "$(id -u):$(id -g)" "$WORK"
|
||||
> ```
|
||||
|
||||
### 5) 写 README.md(备份说明)
|
||||
在 `$WORK/README.md` 写明本次备份信息,便于日后识别与还原。模板:
|
||||
```markdown
|
||||
# DooTask 备份 — <TS>
|
||||
|
||||
- 备份时间:<人类可读时间>
|
||||
- DooTask 版本:<取自 package.json 的 version>
|
||||
- 包含内容:
|
||||
- 数据库:<DB dump 文件名>(来源 mysqldump 当前库)
|
||||
- 上传文件:uploads/(来源 public/uploads,已排除 tmp) ← 未选则写「未包含」
|
||||
- 应用配置:appstore-config/(来源 docker/appstore/config) ← 未选则写「未包含」
|
||||
- 各项大小:<du -sh 列出工作目录内各项>
|
||||
|
||||
## 还原提示
|
||||
- 数据库:`gunzip < <db>.sql.gz | mysql -u<user> -p<pass> <库名>`,或用 `./cmd mysql recovery` 选对应文件还原。
|
||||
- 上传文件:将 uploads/ 内容覆盖回项目 public/uploads/。
|
||||
- 应用配置:将 appstore-config/ 覆盖回 docker/appstore/config/。
|
||||
```
|
||||
|
||||
### 6) 打包到 backup/,清理临时目录
|
||||
```shell
|
||||
mkdir -p backup
|
||||
tar czf "backup/dootask_backup_${TS}.tar.gz" -C tmp "dootask-backup-${TS}"
|
||||
rm -rf "$WORK"
|
||||
```
|
||||
|
||||
## 报告
|
||||
|
||||
向用户报告:
|
||||
- 最终归档路径:`backup/dootask_backup_<TS>.tar.gz`
|
||||
- 归档大小(`ls -lh`)
|
||||
- 实际包含了哪些项(数据库 + 视选择含/不含 uploads、appstore-config)
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,原样报告错误
|
||||
- **不要**自动重试、不要静默跳过某一项(可选项是否包含由前置确认决定,不在执行中临时变更)
|
||||
- DB 备份失败(如 mariadb 未运行)→ 停止,提示用户起服务后重试
|
||||
- 打包前若工作目录有 root 属主残留导致 tar/rm 失败 → `sudo chown` 归还属主后继续,不要删源数据
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 为"省空间"删除源数据或既有备份 | 只读取源数据生成归档,源数据一律不动 |
|
||||
| 备份 `public/uploads/tmp` | 排除 tmp(临时、可重建) |
|
||||
| 把 `docker/appstore/apps` 也打进去 | 不在范围,可从应用市场重装 |
|
||||
| 遇 config 的 root 子目录就跳过该项 | `sudo` 收集后 chown 归还,完整备份 |
|
||||
| 不写 README 直接打包 | 每个归档自带 README,便于日后识别还原 |
|
||||
| 把归档写进 git | 归档放 `backup/`(已 gitignore),不提交 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "源数据太大,删点旧的再备份" → 不,备份只读不删
|
||||
- "config 有 root 目录,跳过算了" → 不,sudo 收集后归还属主
|
||||
- "apps 也一起备了更全" → 不,apps 不在范围
|
||||
- "tmp 里临时文件顺手也备了" → 不,明确排除 `public/uploads/tmp`
|
||||
76
.claude/skills/dootask-fix-permission/SKILL.md
Normal file
76
.claude/skills/dootask-fix-permission/SKILL.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: dootask-fix-permission
|
||||
description: 修复 DooTask 可写目录(bootstrap/cache、docker、public、storage)的属主/权限:chown 回当前用户 + 目录 chmod 775,对齐 install 的赋权逻辑,赋权不删数据。
|
||||
---
|
||||
|
||||
# DooTask 目录权限修复
|
||||
|
||||
容器内进程常以 **root** 写入挂载目录(`storage`、`public/uploads`、`bootstrap/cache` 等),导致宿主机当前用户对这些文件**没有写权限**,进而触发:
|
||||
|
||||
- `./cmd install` 报「目录【xxx】权限不足」/ 目录权限检测失败
|
||||
- `./cmd build`(vite)报 `EACCES: permission denied, copyfile`(复制 `public/uploads/...` 时)
|
||||
- Laravel 运行时写 `storage`/`bootstrap/cache` 失败
|
||||
|
||||
本技能**对齐 `./cmd install` 的目录赋权逻辑**:对四个可写目录做 `chmod 775`(目录)+ `chown` 回当前用户。
|
||||
|
||||
## 适用目录
|
||||
|
||||
与 install 一致的四个:
|
||||
|
||||
```
|
||||
bootstrap/cache
|
||||
docker
|
||||
public # 含 public/uploads(真实上传数据)
|
||||
storage
|
||||
```
|
||||
|
||||
## 核心原则:赋权,不删数据
|
||||
|
||||
`public/uploads` 含真实上传文件(头像、附件等)。**永远优先 `chown` 改属主,不要删数据。** 即便用户说"清理一下",也只允许清临时目录 `public/uploads/tmp`,**切勿**删 uploads 下其他内容。
|
||||
|
||||
## 前置检查
|
||||
|
||||
1. **工作目录**:在项目根(存在 `cmd` 且这四个目录在)
|
||||
2. **sudo**:改属主需 root(当前文件多为 root 属主)。本机一般可免密 sudo;不行则经 docker 以 root 改权限
|
||||
3. 确认要修的范围:默认四个目录全修;若用户只想解 build 报错,也可只针对 `public`(含 `public/uploads`)
|
||||
|
||||
检查通过后汇报将执行的命令,**向用户确认一次**再执行。
|
||||
|
||||
## 执行
|
||||
|
||||
确认后执行(属主修回当前用户,目录权限 775):
|
||||
|
||||
```shell
|
||||
# 1) 属主修回当前用户(递归)
|
||||
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
|
||||
|
||||
# 2) 目录权限 775(仅目录,对齐 install 的 `find -type d -exec chmod 775`)
|
||||
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
|
||||
```
|
||||
|
||||
> 只想解 build 的 uploads 报错时,可只对 `public`:
|
||||
> ```shell
|
||||
> sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||
> ```
|
||||
|
||||
执行后报告:改了哪些目录、属主/权限现状(可 `ls -ld` 抽查),并提示用户可重试之前失败的 install/build/update。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- `chown` 报权限不足 → 当前用户无 sudo 权限,提示用户用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过
|
||||
- 任何步骤失败立即停止报告,不自动重试
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| build 报 uploads EACCES 就 `rm` 删文件 | `chown` 修属主,保留数据 |
|
||||
| 删整个 `public/uploads` 清场 | 最多清 `public/uploads/tmp`,别碰真实上传数据 |
|
||||
| 对文件无差别 `chmod 777` | 目录 `chmod 775` + `chown` 回当前用户即可 |
|
||||
| 不加 sudo 直接 chown root 文件 | 改属主需 root |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "uploads 复制失败,删掉再 build" → 不,`chown` 赋权,不丢数据
|
||||
- "777 一把梭最省事" → 不,按 install 的 775(目录)+ chown
|
||||
- "权限不够就跳过这个目录" → 不,报告交用户处理 sudo
|
||||
74
.claude/skills/dootask-install/SKILL.md
Normal file
74
.claude/skills/dootask-install/SKILL.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: dootask-install
|
||||
description: 首次部署 DooTask:前置检查后执行 `sudo ./cmd install`(建库 + migrate --seed 的重操作),刚性流程、单次确认、失败即停。
|
||||
---
|
||||
|
||||
# DooTask 安装流程
|
||||
|
||||
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
|
||||
|
||||
## 核心原则
|
||||
|
||||
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并步骤,不要为"省事"绕过 sudo 或确认。
|
||||
|
||||
`./cmd install` 已把整套安装封装为单条命令(赋权→起容器→`composer install`→`key:generate`→`migrate --seed`→`up -d`)。本技能的职责是**安装前把关、选对参数、执行前确认、已知失败处理**,而不是把脚本逻辑拆开重做。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
执行前依次确认:
|
||||
|
||||
1. **工作目录**:必须在项目根(存在 `cmd`、`docker-compose.yml`、`.env.docker`)
|
||||
2. **Docker**:`docker` 与 `docker-compose`/`docker compose`(v2+) 可用且 daemon 在跑(脚本 `check_docker` 也会查,但提前确认能更早报错)
|
||||
3. **Node.js ≥ 20**(脚本 `check_node` 会查)
|
||||
4. **APP_ID 不冲突**:若 `.env` 已有 `APP_ID` 且被其他实例占用,脚本 `check_instance` 会报错——此时**停止**,提示用户先清空 `.env` 里的 `APP_ID` 和 `APP_IPPR` 再装
|
||||
5. **sudo**:`./cmd install` 需 root(`check_sudo`),用 `sudo ./cmd install` 执行
|
||||
|
||||
⚠️ **这是重操作**:会创建数据库并执行 `migrate --seed`(灌入种子数据)。在已有数据的环境上重装前务必和用户确认,避免覆盖。
|
||||
|
||||
检查通过后汇报结果,**向用户确认一次**再执行。
|
||||
|
||||
## 参数选择
|
||||
|
||||
| 参数 | 作用 | 何时用 |
|
||||
|------|------|--------|
|
||||
| `--port <端口>` | 指定 HTTP 端口(脚本会做端口占用检测) | 用户要自定义端口,或默认端口被占 |
|
||||
| `--relock` | 删除 `node_modules`/`package-lock.json`/`vendor`/`composer.lock` 后重装 | **谨慎**:仅在依赖锁损坏、用户明确要求重建锁时用,会拖慢安装 |
|
||||
|
||||
不确定时不要自作主张加参数,按需询问用户。
|
||||
|
||||
## 执行
|
||||
|
||||
确认后执行(按用户选择带上参数):
|
||||
|
||||
```shell
|
||||
sudo ./cmd install
|
||||
# 或: sudo ./cmd install --port 8080
|
||||
```
|
||||
|
||||
成功后脚本会输出访问地址并调用 `repassword.sh`。执行完向用户报告:访问地址(`http://127.0.0.1:<APP_PORT>`)、以及数据库密码提示。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,原样报告错误信息
|
||||
- **不要**自动重试,**不要**自动跳过
|
||||
- 常见失败与对应处理:
|
||||
- `APP_ID(xxx)已被其他实例使用` → 停止,让用户清空 `.env` 的 `APP_ID`/`APP_IPPR` 再装
|
||||
- `端口 xxx 已被占用` → 停止,让用户换 `--port`
|
||||
- `目录【xxx】权限不足` / 目录权限检测失败 → 这是目录属主/权限问题,引导用户用 **dootask-fix-permission** 技能修复后重装
|
||||
- `安装依赖失败`(composer)→ 报告,交用户决定(常因网络/镜像源)
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 不加 sudo 直接 `./cmd install` | 用 `sudo ./cmd install`(脚本强制 root) |
|
||||
| 失败后"我再试一次"或自动跳过 | 立即停止,交还用户 |
|
||||
| 在已有数据环境上不问就重装 | 先确认会 `migrate --seed`,可能影响现有数据 |
|
||||
| 遇权限报错自己乱 `chmod`/`chown` | 走 dootask-fix-permission 技能统一处理 |
|
||||
| 不问就加 `--relock` | 默认不加;仅用户明确要求或锁损坏时用 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "端口/权限报错了我顺手帮 TA 改一下别的" → 停下,只处理本次报的问题,按指引走对应技能
|
||||
- "种子数据应该没事,直接重装" → 不,先确认是否会覆盖现有数据
|
||||
- "sudo 麻烦,先试试不加" → 不,install 必须 root
|
||||
204
.claude/skills/dootask-release/SKILL.md
Normal file
204
.claude/skills/dootask-release/SKILL.md
Normal file
@@ -0,0 +1,204 @@
|
||||
---
|
||||
name: dootask-release
|
||||
description: 从 `pro` 分支发布 DooTask 前端新版本:翻译 → 版本号/更新日志 → 构建 → 提交推送,刚性顺序、每步确认、失败即停。
|
||||
---
|
||||
|
||||
# DooTask 发布流程
|
||||
|
||||
**刚性技能**——严格按顺序执行,每步向用户确认,任何一步失败立即停止。
|
||||
|
||||
## 核心原则
|
||||
|
||||
按固定顺序执行,不增删、合并或重排步骤。翻译(Step 1)和更新日志(Step 2)由你直接产出;脚本只做确定性机械工作(算版本号、检测差异、字节级生成语言文件)。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
执行任何发布步骤前,依次检查:
|
||||
|
||||
1. **分支**:必须是 `pro`,否则停止,提示用户切换
|
||||
2. **工作区**:`git status` 必须干净(无未提交变更、无未跟踪文件),否则**停止**并交由用户处理
|
||||
3. **Node.js**:`node --version` 必须 ≥ 20
|
||||
4. **PHP**:`php --version` 必须可用(Step 1 的脚本依赖本地 php,无需容器)。若 host 无 php,停止并提示用户
|
||||
|
||||
检查通过后汇报结果,用户确认后再开始执行。
|
||||
|
||||
## 发布步骤
|
||||
|
||||
**每步执行前**向用户确认;**每步执行后**报告结果。
|
||||
|
||||
开始前先把这份清单复制到你的回复里,逐项勾选、跟踪进度:
|
||||
|
||||
```
|
||||
发布进度:
|
||||
- [ ] 前置检查(分支 pro / 工作区干净 / node≥20 / php 可用)
|
||||
- [ ] Step 1 翻译(diff → 翻译 → apply → generate)
|
||||
- [ ] Step 2 版本号 + CHANGELOG
|
||||
- [ ] Step 3 构建(./cmd prod)
|
||||
- [ ] 汇总变更 → 用户确认 → commit + push
|
||||
- [ ] 确认 GitHub Actions Publish 工作流 success
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 1: 翻译
|
||||
|
||||
多语言数据流:`language/original-{web,api}.txt`(原文/简体中文)→ 经翻译写入 `language/translate.json`(含 9 种语言)→ 生成 `public/language/{web,api}/*`。
|
||||
|
||||
**1.1 检测差异**
|
||||
|
||||
```shell
|
||||
php .claude/skills/dootask-release/scripts/language.php diff
|
||||
```
|
||||
|
||||
输出 JSON:
|
||||
- `regexErrorCount > 0`:translate.json **已有条目**的占位符与某语言值不一致 → **停止**,报告 `regexErrors`,交用户修复(这是历史数据问题,不要自行猜测修改)
|
||||
- `redundantCount > 0`:translate.json 里有、但原文已删除的条目 → 仅作提示(apply 时会自动剔除,不致命)
|
||||
- `needsCount == 0`:无新文案 → **跳到 1.4 直接生成**
|
||||
- `needsCount > 0`:`needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`)→ 进入 1.2
|
||||
|
||||
**1.2 翻译**
|
||||
|
||||
对 `needs` 里的每个 `key`,翻成 8 种语言(`zh` 留空、`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`。
|
||||
|
||||
要求:贴合「项目任务管理系统」语境;占位符 `(%T1)`/`(%M1)` 等原样保留、不可增删改,位置可随目标语言语序调整:
|
||||
|
||||
| 原文 | 翻成英语 |
|
||||
|---|---|
|
||||
| (%T1)的周报[(%T2)][(%T3)月第(%T4)周] | Weekly report of (%T1) [(%T2)] [Week (%T4) of month (%T3)] |
|
||||
| (%T1)提交的「(%M2)」待你审批 | '(%M2)' submitted by (%T1) is waiting for your approval |
|
||||
|
||||
把结果写成一个 JSON 数组文件(建议放 `/tmp/dootask-release-translated.json`,避免污染工作区),每个元素含全部 10 个字段,顺序为:
|
||||
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru`(`zh` 写 `""`)。
|
||||
|
||||
```json
|
||||
[
|
||||
{"key":"...(%T1)...","zh":"","zh-CHT":"...","en":"...","ko":"...","ja":"...","de":"...","fr":"...","id":"...","ru":"..."}
|
||||
]
|
||||
```
|
||||
|
||||
**1.3 合并进 translate.json**
|
||||
|
||||
```shell
|
||||
php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-release-translated.json
|
||||
```
|
||||
|
||||
脚本会校验字段完整性与占位符完整性、追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。任一条不合格会报错停止,按提示修正翻译后重试。
|
||||
|
||||
**1.4 生成前端/后端语言文件**
|
||||
|
||||
```shell
|
||||
php .claude/skills/dootask-release/scripts/language.php generate
|
||||
```
|
||||
|
||||
由 `translate.json` 字节级重新生成 `public/language/web/*.js` 与 `public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。
|
||||
|
||||
**1.5 报告**:用 `git status --short language public/language` 汇总本步改动,向用户报告新增了多少条翻译。
|
||||
|
||||
---
|
||||
|
||||
### Step 2: 版本号 + 更新日志
|
||||
|
||||
**2.1 计算并写入版本号**
|
||||
|
||||
```shell
|
||||
node .claude/skills/dootask-release/scripts/version_bump.js
|
||||
```
|
||||
|
||||
脚本据 git 历史算出新 `version` 与 `codeVerson` 并写入 `package.json`,输出 JSON 含:`version`、`prevVersion`、`changelogRange`(如 `<上次release提交>..HEAD`,用于下一步圈定本次更新范围)。
|
||||
|
||||
**2.2 撰写 CHANGELOG**
|
||||
|
||||
读取本次区间的提交:
|
||||
|
||||
```shell
|
||||
git log <changelogRange> --stat
|
||||
```
|
||||
|
||||
`--stat` 会带上每个提交的完整描述正文 + 改动文件清单;光看标题不够时用 `git show <hash>` 看具体代码改动。
|
||||
|
||||
按 `CHANGELOG.md` 现有格式,在文件顶部 `# Changelog` 说明段之后、紧挨上一个 `## [...]` 之前,插入新版本区段:
|
||||
|
||||
```markdown
|
||||
## [<version>]
|
||||
|
||||
### Features
|
||||
|
||||
- ...
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- ...
|
||||
|
||||
### Performance
|
||||
|
||||
- ...
|
||||
```
|
||||
|
||||
撰写要求(对齐项目历史风格):
|
||||
- 小节标题用**英文 Title Case**:`Features` / `Bug Fixes` / `Performance` / `Documentation` / `Security` / `Miscellaneous`,**不要译成中文**;**没有内容的小节整段省略**。
|
||||
- 条目正文用**通俗友好的简体中文**,面向**普通用户**描述更新带来的直接好处,**避免技术术语**(如 refactor、merge branch、commit lint、bump deps 等)。
|
||||
- 过滤掉对用户无意义的提交(纯构建/依赖/CI/合并提交、本技能自身的脚手架改动等)。
|
||||
- 仅凭提交标题无法判断是否对用户有价值时,结合提交的完整描述正文和实际代码改动(`git show <hash>`)再决定,不要只看一行就下结论。
|
||||
- 合并相似项;每个小节内**按用户价值与影响范围排序,重要的在前**。
|
||||
|
||||
**2.3 报告**:展示新版本号与你写的 changelog 区段,请用户过目。
|
||||
|
||||
---
|
||||
|
||||
### Step 3: 构建前端
|
||||
|
||||
```shell
|
||||
./cmd prod
|
||||
```
|
||||
|
||||
构建前端生产版本。用 `./cmd prod`,不要换成裸跑 vite(它还负责 node 检查、清 `public/js/build`、debug 切换)。
|
||||
|
||||
> **已知失败**:build 报 `public/uploads/...` 的 `EACCES: permission denied, copyfile`,是 vite 复制 `public/` 时撞到 root 属主的运行时上传文件(不限于 `tmp`,`avatar` 等都可能)。补救是赋权、不是删数据——把 uploads 属主改回当前用户后重试:
|
||||
> ```shell
|
||||
> sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||
> ```
|
||||
> `public/uploads` 是真实上传数据,**不要删**;即便要清也只清 `public/uploads/tmp`。
|
||||
|
||||
---
|
||||
|
||||
## 最终:提交并推送
|
||||
|
||||
所有步骤完成后:
|
||||
|
||||
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 CHANGELOG.md language/translate.json public/language public/js`),不要用 `git add -A` / `git add .`,以免卷入未跟踪的本地实验文件
|
||||
- 不打 git tag(现行发布流程不使用 tag)
|
||||
- 确认前先核对:`/tmp/dootask-release-translated.json` 等临时文件不在仓库内,工作区不应残留发布无关的未跟踪文件
|
||||
|
||||
## push 之后:确认发布工作流(CI 才是真正出包)
|
||||
|
||||
push 到 `pro` 只是触发器,真正的构建/出包由 GitHub Actions 完成——**push 成功 ≠ 发布完成**:
|
||||
|
||||
- **Publish**(`.github/workflows/publish.yml`,push→pro 触发)跑完才算出包;成功后会自动触发 **Sync to Gitee**(镜像同步)。
|
||||
- push 完成后**主动确认** Publish 工作流 `conclusion=success`。优先用 `gh`(未装可临时装;公开仓库也可用 GitHub REST API 免鉴权读取 runs):
|
||||
```shell
|
||||
gh run list --workflow=publish.yml -R kuaifan/dootask -L 1
|
||||
gh run view <run-id> -R kuaifan/dootask --json status,conclusion,url
|
||||
```
|
||||
- 工作流仍在跑时,挂后台轮询、结束即通知用户,**不要在前台死等**。
|
||||
|
||||
### iOS 发布(询问后决定)
|
||||
|
||||
`ios-publish.yml` 是**独立的手动工作流**(`workflow_dispatch`),不随 push 触发。Publish 成功后,用 options 或 AskUserQuestion 形式提问是否同时发布 iOS(选项:发布 iOS / 不发布):
|
||||
|
||||
- 选「发布 iOS」才执行:
|
||||
```shell
|
||||
gh workflow run ios-publish.yml --ref pro -R kuaifan/dootask
|
||||
```
|
||||
需 `gh` 已登录且 token 含 `workflow` 权限;触发后可挂后台轮询结果。
|
||||
- 选「不发布」则结束。
|
||||
|
||||
## 失败处理
|
||||
|
||||
任何步骤失败立即停止、报告错误信息,交用户决定;不要自动重试或跳过。
|
||||
239
.claude/skills/dootask-release/scripts/language.php
Normal file
239
.claude/skills/dootask-release/scripts/language.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
// DooTask 发布——翻译流水线(纯本地 php,host 直接跑,不进容器、不调 OpenAI、不需 autoload)。
|
||||
// 逐行对齐 language/translate.php 的检测/保存/生成逻辑,唯独把"调用外部模型翻译"那一段抽走,
|
||||
// 翻译改在技能流程内完成。用 php 而非 node 的唯一原因:array_multisort + json_encode
|
||||
// 的逐字节产物必须与项目原生工具一致,否则每次发版都会产生大面积排序/转义噪声 diff(已验证 host php 可字节级复现)。
|
||||
//
|
||||
// 子命令:
|
||||
// language.php diff
|
||||
// —— 输出 JSON:needs(待翻译,key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示) / regexErrors(占位符错乱,致命)
|
||||
// language.php apply <translated.json>
|
||||
// —— 把新翻译合并进 translate.json(追加 + 剔除冗余),不生成 public 文件
|
||||
// language.php generate
|
||||
// —— 由 translate.json 重新生成 public/language/{web,api}/*
|
||||
//
|
||||
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
$ROOT = dirname(__DIR__, 4);
|
||||
$LANG_DIR = $ROOT . '/language';
|
||||
$LANG_FIELDS = ['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru'];
|
||||
|
||||
if (!is_dir($LANG_DIR)) {
|
||||
fwrite(STDERR, "未找到 language 目录($LANG_DIR)。\n");
|
||||
exit(1);
|
||||
}
|
||||
chdir($LANG_DIR);
|
||||
|
||||
$cmd = $argv[1] ?? '';
|
||||
|
||||
// ---- 公共:读取 original-*.txt ----
|
||||
function read_generateds(): array
|
||||
{
|
||||
$originals = [];
|
||||
$generateds = [];
|
||||
foreach (['web', 'api'] as $type) {
|
||||
$content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : "";
|
||||
$array = array_values(array_filter(array_unique(explode("\n", $content))));
|
||||
$generateds[$type] = $array;
|
||||
$originals = array_merge($originals, $array);
|
||||
}
|
||||
return [$originals, $generateds];
|
||||
}
|
||||
|
||||
// ---- 公共:构建 translations 映射(normalizedKey -> obj),并收集冗余/占位符错乱 ----
|
||||
function build_translations(array $originals): array
|
||||
{
|
||||
$translations = [];
|
||||
$redundants = [];
|
||||
$regrror = [];
|
||||
if (!file_exists("translate.json")) {
|
||||
fwrite(STDERR, "translate.json not exists\n");
|
||||
exit(1);
|
||||
}
|
||||
$tmps = json_decode(file_get_contents("translate.json"), true);
|
||||
foreach ($tmps as $obj) {
|
||||
if (!isset($obj['key'])) {
|
||||
continue;
|
||||
}
|
||||
$currentKey = $obj['key'];
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
|
||||
if (!in_array($originalKey, $originals)) {
|
||||
$redundants[$originalKey] = $obj;
|
||||
continue;
|
||||
}
|
||||
$translations[$originalKey] = $obj;
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
foreach ($obj as $k => $v) {
|
||||
if (empty($v)) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($v, $match)) {
|
||||
$regrror[$originalKey] = ['key' => $currentKey, 'field' => $k, 'value' => $v, 'match' => $match];
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [$translations, $redundants, $regrror];
|
||||
}
|
||||
|
||||
// ---- 公共:由 translate.json + originals 重新生成 public 文件 ----
|
||||
function generate(array $generateds, array $translations): void
|
||||
{
|
||||
foreach ($generateds as $type => $array) {
|
||||
$datas = [];
|
||||
foreach ($array as $text) {
|
||||
$text = trim($text);
|
||||
if (isset($translations[$text])) {
|
||||
$datas[] = $translations[$text];
|
||||
}
|
||||
}
|
||||
$inOrder = [];
|
||||
foreach ($datas as $index => $item) {
|
||||
if (preg_match('/\(%[TM]\d+\)/', $item['key'])) {
|
||||
$inOrder[$index] = strlen($item['key']);
|
||||
} else {
|
||||
$inOrder[$index] = strlen($item['key']) + 10000000000;
|
||||
}
|
||||
}
|
||||
array_multisort($inOrder, SORT_DESC, $datas);
|
||||
$results = [];
|
||||
foreach ($datas as $items) {
|
||||
foreach ($items as $kk => $item) {
|
||||
$results[$kk][] = $item;
|
||||
}
|
||||
}
|
||||
if ($type === 'api') {
|
||||
if (!is_dir("../public/language/api")) {
|
||||
mkdir("../public/language/api", 0777, true);
|
||||
}
|
||||
foreach ($results as $kk => $item) {
|
||||
file_put_contents("../public/language/api/$kk.json", json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
} elseif ($type === 'web') {
|
||||
if (!is_dir("../public/language/web")) {
|
||||
mkdir("../public/language/web", 0777, true);
|
||||
}
|
||||
foreach ($results as $kk => $item) {
|
||||
file_put_contents("../public/language/web/$kk.js", "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
echo "[$type] total: " . count($results['key']) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($cmd === 'diff') {
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations, $redundants, $regrror] = build_translations($originals);
|
||||
|
||||
// 需要翻译的数据(对齐 translate.php 150-169:占位符按单一计数器编号)
|
||||
$needs = [];
|
||||
foreach ($originals as $text) {
|
||||
$key = trim($text);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($translations[$key])) {
|
||||
$needs[$key] = $key;
|
||||
}
|
||||
}
|
||||
$needsOut = [];
|
||||
foreach ($needs as $key) {
|
||||
$c = 1;
|
||||
$converted = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) {
|
||||
$label = strlen($m[1]) > 1 ? "M" : "T";
|
||||
return "(%" . $label . $c++ . ")";
|
||||
}, $key);
|
||||
$needsOut[] = ['key' => $converted];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'needsCount' => count($needsOut),
|
||||
'redundantCount' => count($redundants),
|
||||
'regexErrorCount' => count($regrror),
|
||||
'needs' => $needsOut,
|
||||
'redundants' => array_keys($redundants),
|
||||
'regexErrors' => array_values($regrror),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
|
||||
if (count($regrror) > 0) {
|
||||
exit(2); // 已有数据占位符错乱,需先修复
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($cmd === 'apply') {
|
||||
$file = $argv[2] ?? '';
|
||||
if ($file === '' || !file_exists($file)) {
|
||||
fwrite(STDERR, "用法:apply <translated.json>(文件不存在)\n");
|
||||
exit(1);
|
||||
}
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations, $redundants, $regrror] = build_translations($originals);
|
||||
if (count($regrror) > 0) {
|
||||
fwrite(STDERR, "translate.json 已有条目占位符错乱,请先修复再发版。\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$incoming = json_decode(file_get_contents($file), true);
|
||||
if (!is_array($incoming)) {
|
||||
fwrite(STDERR, "translated.json 必须是数组\n");
|
||||
exit(1);
|
||||
}
|
||||
$added = 0;
|
||||
foreach ($incoming as $raw) {
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
if (!array_key_exists($f, $raw)) {
|
||||
fwrite(STDERR, "新翻译缺字段 \"$f\":" . json_encode($raw, JSON_UNESCAPED_UNICODE) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
// 占位符完整性:key 里每个 (%T1)/(%M1) 必须出现在每个非空语言值里
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $raw['key'], $m)) {
|
||||
foreach ($m[0] as $match) {
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
if ($f === 'key' || $f === 'zh') {
|
||||
continue;
|
||||
}
|
||||
if (empty($raw[$f])) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($raw[$f], $match)) {
|
||||
fwrite(STDERR, "占位符 $match 在字段 \"$f\" 缺失:{$raw['key']}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 规范化:固定字段顺序 + zh 置空
|
||||
$item = [];
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
$item[$f] = $f === 'zh' ? '' : $raw[$f];
|
||||
}
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $item['key']);
|
||||
$translations[$originalKey] = $item;
|
||||
$added++;
|
||||
}
|
||||
|
||||
// array_values:现有条目(去冗余)在前,新条目追加在后
|
||||
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
echo json_encode([
|
||||
'added' => $added,
|
||||
'total' => count($translations),
|
||||
'droppedRedundant' => count($redundants),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($cmd === 'generate') {
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations] = build_translations($originals);
|
||||
generate($generateds, $translations);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "未知子命令:'$cmd'。可用:diff | apply <file> | generate\n");
|
||||
exit(1);
|
||||
47
.claude/skills/dootask-release/scripts/version_bump.js
vendored
Normal file
47
.claude/skills/dootask-release/scripts/version_bump.js
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
// 计算并写入新版本号到 package.json(version + codeVerson),算法对齐 bin/version.js。
|
||||
// 不生成 CHANGELOG(在技能流程内撰写),只输出版本号与 changelog 的提交区间。
|
||||
//
|
||||
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../../..');
|
||||
const pkgFile = path.join(ROOT, 'package.json');
|
||||
const verOffset = 6394; // 版本号偏移量(与 bin/version.js 一致)
|
||||
const codeOffset = 35; // 代码版本号偏移量
|
||||
|
||||
function git(cmd) {
|
||||
return execSync(cmd, { cwd: ROOT, maxBuffer: 1024 * 1024 * 10 }).toString().trim();
|
||||
}
|
||||
|
||||
const verCount = parseInt(git('git rev-list --count HEAD'), 10);
|
||||
const codeCount = parseInt(git("git tag --merged pro -l 'v*' | wc -l"), 10);
|
||||
const num = verOffset + verCount;
|
||||
if (Number.isNaN(num)) {
|
||||
console.error(`版本计算失败:rev-list count=${verCount}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
|
||||
const codeVersion = codeOffset + codeCount;
|
||||
|
||||
let pkg = fs.readFileSync(pkgFile, 'utf8');
|
||||
const prevVersion = (pkg.match(/"version":\s*"(.*?)"/) || [])[1] || '';
|
||||
pkg = pkg.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
|
||||
pkg = pkg.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
|
||||
fs.writeFileSync(pkgFile, pkg, 'utf8');
|
||||
|
||||
// 上一个 release 提交作为 changelog 区间下界
|
||||
let prevReleaseCommit = '';
|
||||
try {
|
||||
prevReleaseCommit = git("git log --grep='^release: v' -n 1 --pretty=format:%H");
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
console.log(JSON.stringify({
|
||||
version,
|
||||
codeVersion,
|
||||
prevVersion,
|
||||
prevReleaseCommit,
|
||||
changelogRange: prevReleaseCommit ? `${prevReleaseCommit}..HEAD` : '(未找到上一个 release 提交,需人工确定区间)',
|
||||
}, null, 2));
|
||||
83
.claude/skills/dootask-update/SKILL.md
Normal file
83
.claude/skills/dootask-update/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: dootask-update
|
||||
description: 更新已部署的 DooTask:前置检查后执行 `sudo ./cmd update`(拉代码 + composer + 迁移 + 重启),本地有改动时停下交用户决定,不自动强制、失败即停。
|
||||
---
|
||||
|
||||
# DooTask 更新流程
|
||||
|
||||
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
|
||||
|
||||
## 核心原则
|
||||
|
||||
**违反字面规则 = 违反流程精神。** 不要擅自加步骤、绕过 sudo/确认,**尤其不要替用户决定强制更新**(会丢本地改动)。
|
||||
|
||||
`./cmd update` 已封装整套更新(检测本地改动→`git fetch`→必要时备份库→`git pull/reset`→`composer install`→`migrate`→重启 php+nginx→写 `UPDATE_TIME`)。本技能职责是**更新前把关、选对参数、处理本地改动这一关键岔路、执行前确认**。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
1. **已安装**:必须存在 `vendor/autoload.php`(脚本会查,没装则报"请先执行安装命令"——此时引导用户走 dootask-install)
|
||||
2. **工作目录**:在项目根
|
||||
3. **当前分支 / 目标分支**:默认更新当前分支;用户要切分支用 `--branch <分支>`。若用户没说,确认是否就更新当前分支
|
||||
4. **本地改动**(关键):`git status` 看是否有未提交改动
|
||||
5. **sudo**:`sudo ./cmd update` 需 root
|
||||
|
||||
检查通过后汇报结果,**向用户确认一次**再执行。
|
||||
|
||||
## 关键岔路:本地有改动
|
||||
|
||||
脚本检测到本地改动时会询问是否强制更新。**强制更新 = `git reset --hard origin/<分支>`,会丢弃所有本地改动。**
|
||||
|
||||
- 发现本地有改动 → **停下**,把改动清单报告用户,让**用户决定**:先提交/暂存改动,还是确认强制更新
|
||||
- **不要**替用户选 `--force`
|
||||
- 只有用户明确说"丢掉改动强制更新"时,才带 `--force`
|
||||
|
||||
## 参数选择
|
||||
|
||||
| 参数 | 作用 | 何时用 |
|
||||
|------|------|--------|
|
||||
| `--branch <分支>` | 切到指定分支再更新 | 用户要换分支(如切 `dev`/`pro`) |
|
||||
| `--force` | 强制更新:`git checkout -f` + `git reset --hard` | **危险**:仅用户明确接受"丢弃本地改动"后 |
|
||||
| `--local` | 本地更新模式:只备份库 + `migrate` + 重启,不拉远程代码 | 代码已就位(如手动改过/CI 拉过),只需迁移+重启 |
|
||||
|
||||
## 数据库
|
||||
|
||||
- 远程模式下,脚本检测到 `database/` 目录有迁移变动会**自动备份数据库**再继续——这是脚本内置的,无需手动。
|
||||
- 但若是大版本升级或用户在意数据,执行前提醒用户:本次可能含库迁移,已有自动备份兜底;如需可先 `./cmd mysql backup` 额外备份。
|
||||
|
||||
## 执行
|
||||
|
||||
确认(含本地改动决策)后执行:
|
||||
|
||||
```shell
|
||||
sudo ./cmd update
|
||||
# 切分支: sudo ./cmd update --branch pro
|
||||
# 强制(丢改动,用户确认后): sudo ./cmd update --force
|
||||
# 本地模式: sudo ./cmd update --local
|
||||
```
|
||||
|
||||
成功后报告:更新到的分支、是否做了库备份/迁移、服务是否重启完成。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,原样报告错误
|
||||
- **不要**自动重试、不要自动跳过、不要因为 `git pull` 失败就自己改成 `--force`
|
||||
- 常见失败:
|
||||
- `请先执行安装命令` → 走 dootask-install
|
||||
- `代码拉取失败,可能存在冲突` → 报告,让用户决定是否 `--force`(丢改动)或先处理冲突
|
||||
- 重启服务失败 → 脚本会尝试 `down` 后重起;若仍失败,报告交用户
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 检测到本地改动就自动 `--force` | 停下,报告改动,交用户决定 |
|
||||
| `git pull` 失败就自动改用 `--force` | 报告冲突,交用户 |
|
||||
| 不加 sudo | `sudo ./cmd update` |
|
||||
| 未装就更新 | 先走 dootask-install |
|
||||
| 失败后自动重试/跳过 | 立即停止 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "有点本地改动,强制更新一下就好了" → 不,`--force` 会丢改动,必须用户拍板
|
||||
- "拉取冲突了,我 reset 一下" → 不,交用户决定
|
||||
- "已经装过了吧,直接更新" → 先确认 `vendor/autoload.php` 在
|
||||
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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,6 +7,7 @@
|
||||
/public/hot
|
||||
/public/tmp
|
||||
/tmp
|
||||
/backup
|
||||
|
||||
# Uploads and user-generated content
|
||||
/public/summary
|
||||
@@ -61,3 +62,6 @@ laravels.pid
|
||||
|
||||
# Documentation
|
||||
README_LOCAL.md
|
||||
|
||||
# playwright
|
||||
.playwright-mcp/
|
||||
|
||||
162
CHANGELOG.md
162
CHANGELOG.md
@@ -2,6 +2,168 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.7.90]
|
||||
|
||||
### Features
|
||||
|
||||
- 系统设置新增「创建项目」权限开关,可指定由所有人、部门负责人或特定人员创建项目,未授权时自动隐藏新建入口,管理更清晰。
|
||||
- 会员卡片新增「项目与任务」入口,可直接查看该成员参与的项目、待办与已完成任务,团队协作一目了然。
|
||||
- 审批详情支持删除已结束的审批,由发起人或管理员清理无用记录更方便。
|
||||
- 管理员现在可以设置全员群的群名称,便于统一团队群组的展示。
|
||||
|
||||
## [1.7.81]
|
||||
|
||||
### Features
|
||||
|
||||
- 团队管理中可标记成员邮箱认证状态,成员信息更易管理。
|
||||
- 系统管理员可在任意群组中设置或取消他人的待办,协作管理更灵活。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复 AI 助手消息推送中发送者身份不完整的问题。
|
||||
|
||||
### Performance
|
||||
|
||||
- 优化大文件下载方式,下载更稳定、更高效。
|
||||
|
||||
## [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
|
||||
|
||||
131
CLAUDE.md
131
CLAUDE.md
@@ -1,116 +1,57 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
DooTask 是一套开源的任务/项目管理系统,支持看板、任务、子任务、评论、对话、文件、报表等协作能力。
|
||||
|
||||
- **后端**:Laravel 8,运行在 LaravelS/Swoole 常驻进程上
|
||||
- **前端**:Vue 2 + Vite
|
||||
- **桌面端**:Electron 壳,核心逻辑复用 Web 前端
|
||||
Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
|
||||
|
||||
## 开发命令
|
||||
|
||||
所有命令通过 `./cmd` 脚本执行,确保与 Docker/容器环境一致:
|
||||
所有命令通过 `./cmd` 脚本执行(不要直接运行 `php artisan` 等):
|
||||
|
||||
```bash
|
||||
# 服务管理
|
||||
./cmd up # 启动容器
|
||||
./cmd down # 停止容器
|
||||
./cmd restart # 重启容器
|
||||
./cmd reup # 重新构建并启动
|
||||
- `./cmd artisan ...` / `./cmd composer ...` / `./cmd php ...` — PHP 相关命令
|
||||
|
||||
# 开发构建
|
||||
./cmd dev # 启动前端开发服务器(需要 Node.js 20+)
|
||||
./cmd serve # dev 别名
|
||||
./cmd prod # 构建前端生产版本
|
||||
./cmd build # prod 别名
|
||||
### AI 不要主动执行的命令
|
||||
|
||||
# Laravel/PHP
|
||||
./cmd artisan ... # 运行 Laravel Artisan 命令
|
||||
./cmd composer ... # 运行 Composer 命令
|
||||
./cmd php ... # 运行 PHP 命令
|
||||
以下命令仅由用户人工触发,AI 不要主动跑——包括"任务完成后 sanity check"、"看下能不能编译"等场景:
|
||||
|
||||
# Electron
|
||||
./cmd electron # 构建桌面应用
|
||||
- `./cmd dev` — 用户已自行运行 dev server,改完会自己 reload;AI 再跑会争抢进程
|
||||
- `./cmd prod` / `./cmd build` — 发版才用,走 `/release` 流程
|
||||
|
||||
# 配置管理
|
||||
./cmd port <端口> # 修改服务端口
|
||||
./cmd url <地址> # 修改访问地址
|
||||
./cmd env <键> <值> # 设置环境变量
|
||||
./cmd debug [true|false] # 切换调试模式
|
||||
前端代码改动只做 Edit/Write,不要为了"验证"启动 dev server。用户明确说"跑一下 / 出包"时除外。
|
||||
|
||||
# 数据库
|
||||
./cmd mysql backup # 备份数据库
|
||||
./cmd mysql recovery # 恢复数据库
|
||||
## Gotchas
|
||||
|
||||
# 其他
|
||||
./cmd install # 一键安装
|
||||
./cmd update # 升级项目
|
||||
./cmd repassword # 重置管理员密码
|
||||
./cmd doc # 生成 API 文档
|
||||
./cmd https # 配置 HTTPS
|
||||
```
|
||||
### LaravelS/Swoole
|
||||
|
||||
## 代码架构
|
||||
|
||||
### 后端 (`app/`)
|
||||
|
||||
**Controller (`app/Http/Controllers/Api/`)**:API 控制器,负责路由入口、参数校验、编排调用模型/模块、组装响应。保持控制器「薄」,业务异常通过 `App\Exceptions\ApiException` 抛出。
|
||||
|
||||
**Model (`app/Models/`)**:Eloquent 模型,负责表结构映射、关系、访问器/修改器、查询 Scope。避免在模型中堆积复杂业务逻辑。
|
||||
|
||||
**Module (`app/Module/`)**:跨控制器/跨模型的业务逻辑与独立功能子域:
|
||||
- 外部集成:`AgoraIO/`、`Manticore/`
|
||||
- 通用工具:`Lock.php`、`TextExtractor.php`、`Image.php`、`AI.php`
|
||||
- 复杂业务逻辑:`Base.php`(核心业务)、`Doo.php`、`Timer.php`
|
||||
|
||||
**Tasks (`app/Tasks/`)**:Swoole 异步任务,用于后台处理:
|
||||
- WebSocket 消息推送:`WebSocketDialogMsgTask.php`、`PushTask.php`
|
||||
- 定时任务:`LoopTask.php`、`AutoArchivedTask.php`
|
||||
- 搜索同步:`ManticoreSyncTask.php`
|
||||
|
||||
**Observers (`app/Observers/`)**:Eloquent 观察者,监听模型事件(created/updated/deleted)自动触发相关逻辑。
|
||||
|
||||
**Services (`app/Services/`)**:服务类,如 `WebSocketService.php`、`RequestContext.php`。
|
||||
|
||||
### 前端 (`resources/assets/js/`)
|
||||
|
||||
```
|
||||
├── app.js, App.vue # 应用入口与根组件
|
||||
├── components/ # 通用与业务组件(看板、文件预览、聊天)
|
||||
├── pages/ # 页面级组件(登录、项目、任务、消息、报表)
|
||||
├── store/ # Vuex 状态管理
|
||||
│ ├── state.js # 状态定义
|
||||
│ ├── mutations.js # 同步修改
|
||||
│ ├── actions.js # 异步操作(含 API 调用封装)
|
||||
│ └── getters.js # 计算属性
|
||||
├── routes.js # 前端路由
|
||||
├── functions/ # 业务函数
|
||||
├── utils/ # 工具函数
|
||||
├── directives/ # Vue 自定义指令
|
||||
├── mixins/ # Vue 混入
|
||||
└── language/ # 国际化翻译
|
||||
```
|
||||
|
||||
API 调用应使用 `store/actions.js` 中已有的封装,避免在组件中散落 axios/fetch。
|
||||
|
||||
### 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,禁止直接改库
|
||||
- 使用 Eloquent 模型访问数据库
|
||||
|
||||
## 前端弹窗文案
|
||||
### 前端
|
||||
|
||||
调用 `$A.modalXXX`、`$A.messageXXX`、`$A.noticeXXX` 时,内部会自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当显式传入 `language: false` 时,才由调用方自行处理翻译。
|
||||
- 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/`,包含测试环境、测试用例、结果截图等信息
|
||||
|
||||
## 交互规范
|
||||
|
||||
@@ -118,8 +59,4 @@ API 调用应使用 `store/actions.js` 中已有的封装,避免在组件中
|
||||
|
||||
## 语言偏好
|
||||
|
||||
- 技术总结和关键结论优先使用简体中文,除非用户明确要求其他语言
|
||||
|
||||
## 扩展规则
|
||||
|
||||
详见 @.claude/rules/graphiti.md 了解 Graphiti 长期记忆集成。
|
||||
- 回复一律使用简体中文,除非用户明确要求其他语言
|
||||
|
||||
@@ -22,6 +22,7 @@ English | **[中文文档](./README_CN.md)**
|
||||
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
|
||||
- Supported Systems: `CentOS/Debian/Ubuntu/macOS` and other Linux/Unix systems
|
||||
- Hardware Recommendation: 2+ cores, 4GB+ memory
|
||||
- Database: MariaDB (provided by the default Docker Compose `mariadb` service)
|
||||
- Special Note: Windows users can install Linux environment using WSL2 before installing DooTask.
|
||||
|
||||
### Deploy Project
|
||||
@@ -115,13 +116,15 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
After installing the new project, follow these steps to complete migration:
|
||||
|
||||
1、Backup original database
|
||||
1、Backup the MariaDB database
|
||||
|
||||
```bash
|
||||
# Run command in the old project
|
||||
./cmd mysql backup
|
||||
```
|
||||
|
||||
> `./cmd mysql` is the CLI subcommand name; backups run against the MariaDB container.
|
||||
|
||||
2、Copy the following files and directories from old project to the same paths in new project
|
||||
|
||||
- `Database backup file`
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
- 必须安装:`Docker v20.10+` 和 `Docker Compose v2.0+`
|
||||
- 支持环境:`Centos/Debian/Ubuntu/macOS` 等 linux/unix 系统
|
||||
- 硬件建议:2核4G以上
|
||||
- 数据库:MariaDB(默认 Docker Compose 中的 `mariadb` 服务)
|
||||
- 特别说明:Windows 可以使用 WSL2 安装 Linux 环境后再安装 DooTask。
|
||||
|
||||
### 部署项目
|
||||
@@ -115,13 +116,15 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
在新项目安装好之后按照以下步骤完成项目迁移:
|
||||
|
||||
1、备份原数据库
|
||||
1、备份 MariaDB 数据库
|
||||
|
||||
```bash
|
||||
# 在旧的项目下执行指令
|
||||
./cmd mysql backup
|
||||
```
|
||||
|
||||
> `./cmd mysql` 为 CLI 子命令名称,实际操作的是 MariaDB 容器。
|
||||
|
||||
2、将旧项目以下文件和目录拷贝至新项目同路径位置
|
||||
|
||||
- `数据库备份文件`
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
## 发布版本
|
||||
|
||||
> 翻译、版本号、更新日志改由 `dootask-release` 技能完成(见 `.claude/skills/dootask-release/`)。
|
||||
|
||||
```shell
|
||||
npm run translate # 翻译(可选)
|
||||
npm run version # 生成版本
|
||||
npm run build # 编译前端
|
||||
```
|
||||
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,6 +348,37 @@ class ApproveController extends AbstractController
|
||||
return Base::retSuccess('已撤回', Base::arrayKeyToUnderline($task['data']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/delById 删除审批(流程实例)
|
||||
*
|
||||
* @apiDescription 需要token身份;仅可删除已结束的审批,且仅发起人或管理员可删
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup approve
|
||||
* @apiName process__delById
|
||||
*
|
||||
* @apiQuery {Number} proc_inst_id 流程实例ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function process__delById()
|
||||
{
|
||||
$user = User::auth();
|
||||
$data['userid'] = (string)$user->userid;
|
||||
$data['proc_inst_id'] = intval(Request::input('proc_inst_id'));
|
||||
$data['is_admin'] = $user->isAdmin();
|
||||
if ($data['proc_inst_id'] <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/delById', json_encode(Base::arrayKeyToCamel($data)));
|
||||
$task = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
|
||||
if (!$task || $task['status'] != 200) {
|
||||
return Base::retError($task['message'] ?? '删除失败');
|
||||
}
|
||||
return Base::retSuccess('已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/findTask 查询需要我审批的流程(审批中)
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\AiAssistantSession;
|
||||
use App\Models\User;
|
||||
use App\Module\AI;
|
||||
use App\Module\Apps;
|
||||
@@ -70,4 +71,237 @@ class AssistantController extends AbstractController
|
||||
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/assistant/match-elements 元素向量匹配
|
||||
*
|
||||
* @apiDescription 通过向量相似度匹配页面元素,用于智能查找与查询语义相关的元素
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup assistant
|
||||
* @apiName match_elements
|
||||
*
|
||||
* @apiParam {String} query 搜索关键词
|
||||
* @apiParam {Array} elements 元素列表,每个元素包含 ref 和 name 字段
|
||||
* @apiParam {Number} [top_k=10] 返回的匹配数量,最大50
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {Array} data.matches 匹配结果数组,按相似度降序排列
|
||||
*/
|
||||
public function match_elements()
|
||||
{
|
||||
User::auth();
|
||||
|
||||
$query = trim(Request::input('query', ''));
|
||||
$elements = Request::input('elements', []);
|
||||
$topK = min(intval(Request::input('top_k', 10)), 50);
|
||||
|
||||
if (empty($query) || empty($elements)) {
|
||||
return Base::retError('参数不能为空');
|
||||
}
|
||||
|
||||
// 获取查询向量
|
||||
$queryResult = AI::getEmbedding($query);
|
||||
if (Base::isError($queryResult)) {
|
||||
return $queryResult;
|
||||
}
|
||||
$queryVector = $queryResult['data'];
|
||||
|
||||
// 计算相似度并排序
|
||||
$scored = [];
|
||||
foreach ($elements as $el) {
|
||||
$name = $el['name'] ?? '';
|
||||
if (empty($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$elResult = AI::getEmbedding($name);
|
||||
if (Base::isError($elResult)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$similarity = $this->cosineSimilarity($queryVector, $elResult['data']);
|
||||
$scored[] = [
|
||||
'element' => $el,
|
||||
'similarity' => $similarity,
|
||||
];
|
||||
}
|
||||
|
||||
// 按相似度降序排序
|
||||
usort($scored, fn($a, $b) => $b['similarity'] <=> $a['similarity']);
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'matches' => array_slice($scored, 0, $topK),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个向量的余弦相似度
|
||||
*/
|
||||
private function cosineSimilarity(array $a, array $b): float
|
||||
{
|
||||
$dotProduct = 0;
|
||||
$normA = 0;
|
||||
$normB = 0;
|
||||
$count = count($a);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$dotProduct += $a[$i] * $b[$i];
|
||||
$normA += $a[$i] * $a[$i];
|
||||
$normB += $b[$i] * $b[$i];
|
||||
}
|
||||
$denominator = sqrt($normA) * sqrt($normB);
|
||||
if ($denominator == 0) {
|
||||
return 0;
|
||||
}
|
||||
return $dotProduct / $denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
public function session__list()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
|
||||
$sessions = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey)
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
|
||||
$list = [];
|
||||
foreach ($sessions as $session) {
|
||||
$data = Base::json2array($session->data);
|
||||
$images = Base::json2array($session->images);
|
||||
foreach ($images as $imageId => $path) {
|
||||
$images[$imageId] = Base::fillUrl($path);
|
||||
}
|
||||
$list[] = [
|
||||
'id' => $session->session_id,
|
||||
'title' => $session->title,
|
||||
'responses' => $data,
|
||||
'images' => $images,
|
||||
'sceneKey' => $session->scene_key,
|
||||
'createdAt' => $session->created_at ? $session->created_at->getTimestampMs() : 0,
|
||||
'updatedAt' => $session->updated_at ? $session->updated_at->getTimestampMs() : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话
|
||||
*/
|
||||
public function session__save()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
$sessionId = trim(Request::input('session_id', ''));
|
||||
$sceneKey = trim(Request::input('scene_key', ''));
|
||||
$title = trim(Request::input('title', ''));
|
||||
$data = Request::input('data', []);
|
||||
$newImages = Request::input('new_images', []);
|
||||
|
||||
if (empty($sessionId)) {
|
||||
return Base::retError('session_id 不能为空');
|
||||
}
|
||||
|
||||
$newImageUrls = [];
|
||||
if (is_array($newImages)) {
|
||||
$path = 'uploads/assistant/' . date('Ym') . '/' . $user->userid . '/';
|
||||
foreach ($newImages as $img) {
|
||||
$imageId = $img['imageId'] ?? '';
|
||||
$dataUrl = $img['dataUrl'] ?? '';
|
||||
if (empty($imageId) || empty($dataUrl)) {
|
||||
continue;
|
||||
}
|
||||
$result = Base::image64save([
|
||||
'image64' => $dataUrl,
|
||||
'path' => $path,
|
||||
'autoThumb' => false,
|
||||
]);
|
||||
if (Base::isSuccess($result)) {
|
||||
$newImageUrls[$imageId] = $result['data']['path'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$session = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey)
|
||||
->where('session_id', $sessionId)
|
||||
->first();
|
||||
|
||||
$imageMap = $newImageUrls;
|
||||
if ($session) {
|
||||
$existingImages = Base::json2array($session->images);
|
||||
$imageMap = array_merge($existingImages, $newImageUrls);
|
||||
}
|
||||
|
||||
$session = AiAssistantSession::createInstance([
|
||||
'userid' => $user->userid,
|
||||
'session_key' => $sessionKey,
|
||||
'session_id' => $sessionId,
|
||||
'scene_key' => $sceneKey,
|
||||
'title' => mb_substr($title, 0, 255),
|
||||
'data' => Base::array2json(is_array($data) ? $data : []),
|
||||
'images' => Base::array2json($imageMap),
|
||||
], $session?->id);
|
||||
$session->save();
|
||||
|
||||
// 仅返回本次新增的图片URL
|
||||
$urls = [];
|
||||
foreach ($newImageUrls as $imageId => $path) {
|
||||
$urls[$imageId] = Base::fillUrl($path);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'image_urls' => $urls,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
public function session__delete()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
$sessionId = trim(Request::input('session_id', ''));
|
||||
$clearAll = Request::input('clear_all', false);
|
||||
|
||||
$query = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey);
|
||||
|
||||
if ($clearAll) {
|
||||
$sessions = $query->get();
|
||||
foreach ($sessions as $session) {
|
||||
$this->deleteSessionImages($session);
|
||||
}
|
||||
$query->delete();
|
||||
} else {
|
||||
if (empty($sessionId)) {
|
||||
return Base::retError('session_id 不能为空');
|
||||
}
|
||||
$session = $query->where('session_id', $sessionId)->first();
|
||||
if ($session) {
|
||||
$this->deleteSessionImages($session);
|
||||
$session->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return Base::retSuccess('success');
|
||||
}
|
||||
|
||||
private function deleteSessionImages(AiAssistantSession $session)
|
||||
{
|
||||
$images = Base::json2array($session->images);
|
||||
foreach ($images as $path) {
|
||||
$fullPath = public_path($path);
|
||||
if (file_exists($fullPath)) {
|
||||
@unlink($fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ use App\Module\TimeRange;
|
||||
use App\Module\MsgTool;
|
||||
use App\Models\FileContent;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\AbstractModel;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
@@ -1667,6 +1670,7 @@ class DialogController extends AbstractController
|
||||
if (!in_array($botType, [
|
||||
'system-msg',
|
||||
'task-alert',
|
||||
'todo-alert',
|
||||
'check-in',
|
||||
'approval-alert',
|
||||
'meeting-alert',
|
||||
@@ -1696,6 +1700,116 @@ class DialogController extends AbstractController
|
||||
return WebSocketDialogMsg::sendMsg(null, $dialog->id, 'text', $msgData, $botUser->userid, false, false, $silence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/send_ai_assistant 以AI助手身份发送消息到对话
|
||||
*
|
||||
* @apiDescription 需要token身份,以AI助手身份(userid=-1)发送消息到对话。支持两种方式:
|
||||
* 1. 通过 dialog_id 直接发送到指定对话
|
||||
* 2. 通过 task_id 发送到任务对话(自动创建对话如不存在)
|
||||
* 两个参数至少提供一个,同时提供时优先使用 dialog_id
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__send_ai_assistant
|
||||
*
|
||||
* @apiParam {Number} [dialog_id] 对话ID(与task_id二选一)
|
||||
* @apiParam {Number} [task_id] 任务ID(与dialog_id二选一,自动创建对话)
|
||||
* @apiParam {String} text 消息内容
|
||||
* @apiParam {String} [text_type=md] 消息格式:md 或 html
|
||||
* @apiParam {String} [silence=no] 是否静默发送:yes/no
|
||||
* @apiParam {String} [nickname] 自定义发送者昵称(最多20字,留空则显示"AI 助手")
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__send_ai_assistant()
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$task_id = intval(Request::input('task_id'));
|
||||
$text = trim(Request::input('text'));
|
||||
$text_type = strtolower(trim(Request::input('text_type'))) ?: 'md';
|
||||
$silence = in_array(strtolower(trim(Request::input('silence'))), ['yes', 'true', '1']);
|
||||
$nickname = trim(Request::input('nickname'));
|
||||
$markdown = in_array($text_type, ['md', 'markdown']);
|
||||
//
|
||||
if (empty($dialog_id) && empty($task_id)) {
|
||||
return Base::retError('dialog_id 或 task_id 至少提供一个');
|
||||
}
|
||||
if (empty($text)) {
|
||||
return Base::retError('消息内容不能为空');
|
||||
}
|
||||
if (mb_strlen($text) > 200000) {
|
||||
return Base::retError('消息内容最大不能超过200000字');
|
||||
}
|
||||
if (mb_strlen($nickname) > 20) {
|
||||
return Base::retError('发送者昵称最多不能超过20字');
|
||||
}
|
||||
//
|
||||
if ($dialog_id) {
|
||||
// Direct dialog mode: verify user is a member
|
||||
WebSocketDialog::checkDialog($dialog_id);
|
||||
} else {
|
||||
// Task mode: resolve task -> dialog_id (auto-create if needed)
|
||||
$task = ProjectTask::find($task_id);
|
||||
if (!$task) {
|
||||
return Base::retError('任务不存在');
|
||||
}
|
||||
if (!ProjectUser::whereProjectId($task->project_id)->whereUserid($user->userid)->exists()) {
|
||||
return Base::retError('没有权限操作此任务');
|
||||
}
|
||||
// 任务可见性校验(与 task__one 一致)
|
||||
if ($task->visibility != 1) {
|
||||
$projectOwnerids = ProjectUser::whereProjectId($task->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->map(fn($v) => (int)$v)->toArray();
|
||||
if (!in_array($user->userid, $projectOwnerids)) {
|
||||
$visibleUserids = array_merge(
|
||||
ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(),
|
||||
ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(),
|
||||
ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid')->toArray()
|
||||
);
|
||||
if (!in_array($user->userid, $visibleUserids)) {
|
||||
return Base::retError('没有权限操作此任务');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$task->dialog_id) {
|
||||
$dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task');
|
||||
if ($dialog) {
|
||||
$task->dialog_id = $dialog->id;
|
||||
$task->save();
|
||||
$task->pushMsg('dialog');
|
||||
} else {
|
||||
return Base::retError('无法创建任务对话');
|
||||
}
|
||||
}
|
||||
$dialog_id = $task->dialog_id;
|
||||
}
|
||||
//
|
||||
$msgData = ['text' => $text];
|
||||
if ($markdown) {
|
||||
$msgData['type'] = 'md';
|
||||
}
|
||||
if ($nickname !== '') {
|
||||
$msgData['nickname'] = $nickname;
|
||||
}
|
||||
//
|
||||
$result = WebSocketDialogMsg::sendMsg(
|
||||
null,
|
||||
$dialog_id,
|
||||
'text',
|
||||
$msgData,
|
||||
\App\Module\AiTaskSuggestion::AI_ASSISTANT_USERID,
|
||||
true, // push_self
|
||||
false, // push_retry
|
||||
$silence
|
||||
);
|
||||
//
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/sendlocation 发送位置消息
|
||||
*
|
||||
@@ -2208,6 +2322,7 @@ class DialogController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$msg_ids = Request::input('msg_ids');
|
||||
$msg_id = intval(Request::input("msg_id"));
|
||||
$dialogids = Request::input('dialogids');
|
||||
$userids = Request::input('userids');
|
||||
@@ -2218,6 +2333,33 @@ class DialogController extends AbstractController
|
||||
return Base::retError("请选择对话或成员");
|
||||
}
|
||||
//
|
||||
// 支持批量逐条转发
|
||||
if (!empty($msg_ids) && is_array($msg_ids)) {
|
||||
if (count($msg_ids) > 100) {
|
||||
return Base::retError("最多转发100条消息");
|
||||
}
|
||||
$allMsgs = [];
|
||||
$msgs = WebSocketDialogMsg::whereIn('id', $msg_ids)->orderBy('created_at')->get();
|
||||
if ($msgs->isEmpty()) {
|
||||
return Base::retError("消息不存在或已被删除");
|
||||
}
|
||||
WebSocketDialog::checkDialog($msgs->first()->dialog_id);
|
||||
foreach ($msgs as $msg) {
|
||||
if (in_array($msg->type, WebSocketDialogMsg::$unforwardableTypes)) {
|
||||
continue;
|
||||
}
|
||||
$res = $msg->forwardMsg($dialogids, $userids, $user, $show_source, $leave_message);
|
||||
if (Base::isSuccess($res)) {
|
||||
$allMsgs = array_merge($allMsgs, $res['data']['msgs']);
|
||||
}
|
||||
// 留言只在第一条时发送,后续不再重复
|
||||
$leave_message = '';
|
||||
}
|
||||
return Base::retSuccess('转发成功', [
|
||||
'msgs' => $allMsgs
|
||||
]);
|
||||
}
|
||||
//
|
||||
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
|
||||
if (empty($msg)) {
|
||||
return Base::retError("消息不存在或已被删除");
|
||||
@@ -2227,6 +2369,98 @@ class DialogController extends AbstractController
|
||||
return $msg->forwardMsg($dialogids, $userids, $user, $show_source, $leave_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/mergeforward 合并转发消息
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__mergeforward
|
||||
*
|
||||
* @apiParam {Array} msg_ids 消息ID数组(最多100条)
|
||||
* @apiParam {Array} dialogids 转发给的对话ID
|
||||
* @apiParam {Array} userids 转发给的成员ID
|
||||
* @apiParam {Number} show_source 是否显示原发送者信息
|
||||
* @apiParam {String} leave_message 转发留言
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__mergeforward()
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$msg_ids = Request::input('msg_ids');
|
||||
$dialogids = Request::input('dialogids');
|
||||
$userids = Request::input('userids');
|
||||
$show_source = intval(Request::input("show_source"));
|
||||
$leave_message = Request::input('leave_message');
|
||||
//
|
||||
if (empty($dialogids) && empty($userids)) {
|
||||
return Base::retError("请选择对话或成员");
|
||||
}
|
||||
if (empty($msg_ids) || !is_array($msg_ids)) {
|
||||
return Base::retError("请选择要转发的消息");
|
||||
}
|
||||
if (count($msg_ids) > 100) {
|
||||
return Base::retError("最多转发100条消息");
|
||||
}
|
||||
//
|
||||
return WebSocketDialogMsg::mergeForwardMsg($msg_ids, $dialogids, $userids, $user, $show_source, $leave_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/mergedetail 合并转发消息详情
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__mergedetail
|
||||
*
|
||||
* @apiParam {Number} msg_id 合并转发消息ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__mergedetail()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
$msg_id = intval(Request::input('msg_id'));
|
||||
if ($msg_id <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
$dialogMsg = WebSocketDialogMsg::find($msg_id);
|
||||
if (!$dialogMsg || $dialogMsg->type !== 'merge-forward') {
|
||||
return Base::retError('消息不存在或已被删除');
|
||||
}
|
||||
WebSocketDialog::checkDialog($dialogMsg->dialog_id);
|
||||
//
|
||||
$msgData = Base::json2array($dialogMsg->getRawOriginal('msg'));
|
||||
$msgIds = $msgData['msg_ids'] ?? [];
|
||||
if (empty($msgIds)) {
|
||||
return Base::retError('消息不存在或已被删除');
|
||||
}
|
||||
$msgs = WebSocketDialogMsg::withTrashed()
|
||||
->whereIn('id', $msgIds)
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(function ($msg) {
|
||||
return [
|
||||
'id' => $msg->id,
|
||||
'userid' => $msg->userid,
|
||||
'type' => $msg->type,
|
||||
'msg' => $msg->msg,
|
||||
'created_at' => $msg->created_at->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
return Base::retSuccess('success', [
|
||||
'msgs' => $msgs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/emoji emoji回复
|
||||
*
|
||||
@@ -2346,7 +2580,8 @@ class DialogController extends AbstractController
|
||||
} else {
|
||||
$userids = is_array($userids) ? $userids : [];
|
||||
}
|
||||
return $msg->toggleTodoMsg($user->userid, $userids);
|
||||
$remindAt = Request::exists('remind_at') ? (trim(Request::input('remind_at', '')) ?: null) : false;
|
||||
return $msg->toggleTodoMsg($user->userid, $userids, $remindAt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2379,6 +2614,64 @@ class DialogController extends AbstractController
|
||||
return Base::retSuccess('success', $todo ?: []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/todoremind 设置/修改/取消待办提醒时间
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__todoremind
|
||||
*
|
||||
* @apiParam {Number} msg_id 消息ID
|
||||
* @apiParam {Array} userids 目标成员ID组
|
||||
* @apiParam {String} remind_at 提醒时间(空表示取消提醒)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__todoremind()
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$msg_id = intval(Request::input("msg_id"));
|
||||
$userids = Request::input('userids');
|
||||
$userids = is_array($userids) ? array_values(array_filter(array_map('intval', $userids))) : [];
|
||||
$remindAt = trim(Request::input('remind_at', '')) ?: null;
|
||||
//
|
||||
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
|
||||
if (empty($msg)) {
|
||||
return Base::retError("消息不存在或已被删除");
|
||||
}
|
||||
if (in_array($msg->type, ['tag', 'todo', 'notice'])) {
|
||||
return Base::retError('此消息不支持设待办');
|
||||
}
|
||||
$dialog = WebSocketDialog::checkDialog($msg->dialog_id);
|
||||
//
|
||||
if (empty($userids)) {
|
||||
return Base::retError("请选择成员");
|
||||
}
|
||||
// 权限管控(与设/取消待办同一开关与放行规则)
|
||||
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
|
||||
$others = array_diff($userids, [$user->userid]);
|
||||
if ($others && !$dialog->checkTodoOwnerPermission($user->userid)) {
|
||||
return Base::retError('仅群主、项目/任务负责人或系统管理员可设置或取消他人待办');
|
||||
}
|
||||
}
|
||||
//
|
||||
$msg->setTodoRemind($userids, $remindAt);
|
||||
//
|
||||
$upData = [
|
||||
'id' => $msg->id,
|
||||
'todo' => $msg->todo,
|
||||
'todo_done' => $msg->isTodoDone(true),
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
];
|
||||
$dialog->pushMsg('update', $upData);
|
||||
//
|
||||
return Base::retSuccess($remindAt ? '设置成功' : '取消成功', $upData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/done 完成待办
|
||||
*
|
||||
@@ -2609,7 +2902,10 @@ class DialogController extends AbstractController
|
||||
return Base::retError('对话不存在或已被删除', ['dialog_id' => $dialog_id], -4003);
|
||||
}
|
||||
} else {
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, true);
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
if (!$dialog->isOwner(User::userid())) {
|
||||
throw new \App\Exceptions\ApiException('仅群主或群管理员可操作');
|
||||
}
|
||||
}
|
||||
//
|
||||
$data = ['id' => $dialog->id];
|
||||
@@ -2620,7 +2916,9 @@ class DialogController extends AbstractController
|
||||
$data['avatar'] = Base::fillUrl($array['avatar'] = $avatar);
|
||||
}
|
||||
$existName = Request::exists('chat_name') || Request::exists('name');
|
||||
if ($existName && $dialog->group_type === 'user') {
|
||||
// 个人群组群主可改名;全员群仅系统管理员可改名
|
||||
$canEditName = $dialog->group_type === 'user' || ($dialog->group_type === 'all' && $admin === 1);
|
||||
if ($existName && $canEditName) {
|
||||
$chatName = trim(Request::input('chat_name') ?: Request::input('name'));
|
||||
if (mb_strlen($chatName) < 2) {
|
||||
return Base::retError('群名称至少2个字');
|
||||
@@ -2668,7 +2966,11 @@ class DialogController extends AbstractController
|
||||
return Base::retError('请选择群成员');
|
||||
}
|
||||
//
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, "auto");
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
// 有群主时,仅群主/群管理员可邀请;无群主时,任意成员可邀请
|
||||
if ($dialog->owner_id > 0 && !$dialog->isOwner($user->userid)) {
|
||||
throw new \App\Exceptions\ApiException('仅限群主或群管理员操作');
|
||||
}
|
||||
//
|
||||
$dialog->checkGroup();
|
||||
$dialog->joinGroup($userids, $user->userid);
|
||||
@@ -2758,17 +3060,107 @@ class DialogController extends AbstractController
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, $check_owner);
|
||||
//
|
||||
$dialog->checkGroup($check_owner ? 'user' : null);
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->owner_id = $userid;
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($userid, 0);
|
||||
// 同步 role:原主 role=0、新主 role=1(覆盖即可)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'deputy_ids' => $dialog->deputy_ids,
|
||||
]);
|
||||
}
|
||||
return Base::retSuccess('转让成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 任命群管理员(仅群主可操作)
|
||||
*
|
||||
* @apiParam {Number} dialog_id 群对话ID
|
||||
* @apiParam {Number} userid 要任命的群成员 userid
|
||||
*/
|
||||
public function group__adddeputy()
|
||||
{
|
||||
$user = User::auth();
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('请选择有效的成员');
|
||||
}
|
||||
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, true); // checkOwner=true:仅群主
|
||||
$dialog->checkGroup('user'); // 仅普通群
|
||||
|
||||
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->first();
|
||||
if (empty($member)) {
|
||||
return Base::retError('该用户不是群成员');
|
||||
}
|
||||
|
||||
if ((int)$member->role === 1) {
|
||||
return Base::retError('不能将群主任命为群管理员');
|
||||
}
|
||||
if ((int)$member->role !== 2) {
|
||||
$member->role = 2;
|
||||
$member->save();
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
|
||||
return Base::retSuccess('任命成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 罢免群管理员(仅群主可操作)
|
||||
*
|
||||
* @apiParam {Number} dialog_id 群对话ID
|
||||
* @apiParam {Number} userid 要罢免的群管理员 userid
|
||||
*/
|
||||
public function group__deldeputy()
|
||||
{
|
||||
$user = User::auth();
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('请选择有效的成员');
|
||||
}
|
||||
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, true);
|
||||
$dialog->checkGroup('user');
|
||||
|
||||
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->first();
|
||||
if (empty($member)) {
|
||||
return Base::retSuccess('罢免成功'); // 幂等:本来就不是成员
|
||||
}
|
||||
|
||||
if ((int)$member->role === 2) {
|
||||
$member->role = 0;
|
||||
$member->save();
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
|
||||
return Base::retSuccess('罢免成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/group/disband 解散群组
|
||||
*
|
||||
|
||||
@@ -17,6 +17,7 @@ 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;
|
||||
@@ -68,6 +69,11 @@ class FileController extends AbstractController
|
||||
* @apiParam {String} [with_url] 是否返回文件访问URL
|
||||
* - no: 不返回(默认)
|
||||
* - yes: 返回content_url字段
|
||||
* @apiParam {String} [with_text] 是否提取文件文本内容(用于AI阅读,支持分页)
|
||||
* - no: 不提取(默认)
|
||||
* - yes: 提取文本内容,支持 docx/xlsx/pptx/pdf/txt 等格式
|
||||
* @apiParam {Number} [text_offset] with_text=yes时有效,文本起始位置(字符数),默认0
|
||||
* @apiParam {Number} [text_limit] with_text=yes时有效,文本获取长度(字符数),默认50000,最大200000
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -77,6 +83,9 @@ class FileController extends AbstractController
|
||||
{
|
||||
$id = Request::input('id');
|
||||
$with_url = Request::input('with_url', 'no');
|
||||
$with_text = Request::input('with_text', 'no');
|
||||
$text_offset = intval(Request::input('text_offset', 0));
|
||||
$text_limit = intval(Request::input('text_limit', 50000));
|
||||
//
|
||||
$permission = 0;
|
||||
if (Base::isNumber($id)) {
|
||||
@@ -112,9 +121,57 @@ class FileController extends AbstractController
|
||||
$array['content_url'] = FileContent::getFileUrl($file->id);
|
||||
}
|
||||
|
||||
// 如果请求提取文本内容
|
||||
if ($with_text === 'yes') {
|
||||
$array['text_content'] = ManticoreFile::extractFileContentPaginated($file, $text_offset, $text_limit);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $array);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/fetch 通过路径获取文件文本内容
|
||||
*
|
||||
* @apiDescription 用于 MCP/AI 工具通过文件路径获取内容,支持分页获取大文件
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName fetch
|
||||
*
|
||||
* @apiParam {String} path 文件路径(相对于系统根目录,如 uploads/file/...)
|
||||
* @apiParam {Number} [offset] 起始位置(字符数),默认0
|
||||
* @apiParam {Number} [limit] 获取长度(字符数),默认50000,最大200000
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* - content: 文本内容
|
||||
* - total_length: 完整内容总长度
|
||||
* - offset: 当前起始位置
|
||||
* - limit: 本次获取长度
|
||||
* - has_more: 是否还有更多内容
|
||||
*/
|
||||
public function fetch()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
$path = trim(Request::input('path'));
|
||||
$offset = intval(Request::input('offset', 0));
|
||||
$limit = intval(Request::input('limit', 50000));
|
||||
|
||||
if (empty($path)) {
|
||||
return Base::retError('参数错误:path 不能为空');
|
||||
}
|
||||
|
||||
// 直接传入路径,ManticoreFile 内部处理 URL 解析
|
||||
$result = ManticoreFile::extractFileContentPaginated($path, $offset, $limit);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return Base::retError($result['error']);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/search 搜索文件列表
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,8 @@ class SystemController extends AbstractController
|
||||
'login_code',
|
||||
'password_policy',
|
||||
'project_invite',
|
||||
'project_add_permission',
|
||||
'project_add_userids',
|
||||
'chat_information',
|
||||
'anon_message',
|
||||
'convert_video',
|
||||
@@ -93,6 +95,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]);
|
||||
}
|
||||
@@ -140,14 +145,21 @@ 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();
|
||||
// 指定人员名单仅管理员可见
|
||||
if ($type != 'all' && $type != 'save') {
|
||||
unset($setting['project_add_userids']);
|
||||
}
|
||||
//
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
@@ -610,6 +622,7 @@ class SystemController extends AbstractController
|
||||
'ldap_password',
|
||||
'ldap_user_dn',
|
||||
'ldap_base_dn',
|
||||
'ldap_login_attr',
|
||||
'ldap_sync_local'
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
@@ -623,6 +636,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('{}'));
|
||||
|
||||
@@ -41,6 +41,9 @@ 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
|
||||
@@ -300,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 返回数据
|
||||
@@ -307,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;
|
||||
@@ -320,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);
|
||||
}
|
||||
|
||||
@@ -377,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);
|
||||
@@ -389,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 获取我的部门列表
|
||||
*
|
||||
@@ -853,7 +884,8 @@ class UsersController extends AbstractController
|
||||
*/
|
||||
public function extra()
|
||||
{
|
||||
$user = User::auth();
|
||||
$viewer = User::auth();
|
||||
$user = $viewer;
|
||||
//
|
||||
$userid = intval(Request::input('userid'));
|
||||
if ($userid <= 0) {
|
||||
@@ -888,6 +920,8 @@ class UsersController extends AbstractController
|
||||
|
||||
$tagMeta = UserTag::listWithMeta($userid, $user);
|
||||
|
||||
$worksContext = UserDepartment::userWorksContext($viewer, $userid);
|
||||
|
||||
$data = [
|
||||
'userid' => $userid,
|
||||
'birthday' => $birthday,
|
||||
@@ -895,6 +929,7 @@ class UsersController extends AbstractController
|
||||
'introduction' => $introduction,
|
||||
'personal_tags' => $tagMeta['top'],
|
||||
'personal_tags_total' => $tagMeta['total'],
|
||||
'works_visible' => $worksContext['allowed'],
|
||||
];
|
||||
|
||||
return Base::retSuccess('success', $data);
|
||||
@@ -1063,6 +1098,8 @@ class UsersController extends AbstractController
|
||||
* - clearadmin 取消管理员
|
||||
* - settemp 设为临时帐号
|
||||
* - cleartemp 取消临时身份(取消临时帐号)
|
||||
* - setverity 标记邮箱为已认证
|
||||
* - clearverity 标记邮箱为未认证
|
||||
* - checkin_macs 修改自动签到mac地址(需要参数 checkin_macs)
|
||||
* - checkin_face 修改签到人脸图片(需要参数 checkin_face)
|
||||
* - department 修改部门(需要参数 department)
|
||||
@@ -1126,6 +1163,16 @@ class UsersController extends AbstractController
|
||||
$upArray['identity'] = array_diff($userInfo->identity, ['temp']);
|
||||
break;
|
||||
|
||||
case 'setverity':
|
||||
$msg = '设置成功';
|
||||
$upArray['email_verity'] = 1;
|
||||
break;
|
||||
|
||||
case 'clearverity':
|
||||
$msg = '取消成功';
|
||||
$upArray['email_verity'] = 0;
|
||||
break;
|
||||
|
||||
case 'checkin_macs':
|
||||
$list = is_array($data['checkin_macs']) ? $data['checkin_macs'] : [];
|
||||
$array = [];
|
||||
@@ -1235,7 +1282,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;
|
||||
}
|
||||
// 昵称
|
||||
@@ -1312,6 +1359,101 @@ 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 {Number} [email_verity] 是否标记邮箱为已认证(1是、0否,默认1)
|
||||
* @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;
|
||||
$emailVerity = intval(Request::input('email_verity', 1)) === 1;
|
||||
$profession = trim((string)Request::input('profession', ''));
|
||||
$department = Request::input('department', []);
|
||||
$user = User::createByAdmin($email, $password, $nickname, [
|
||||
'changePass' => $changePass,
|
||||
'emailVerity' => $emailVerity,
|
||||
'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[]、email_verity(1已认证/0未认证,默认0))进行创建
|
||||
* @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 邮箱验证
|
||||
*
|
||||
@@ -2130,6 +2272,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 删除部门(限管理员)
|
||||
*
|
||||
@@ -3198,7 +3399,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);
|
||||
//
|
||||
|
||||
@@ -23,6 +23,8 @@ use App\Tasks\CheckinRemindTask;
|
||||
use App\Tasks\CloseMeetingRoomTask;
|
||||
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());
|
||||
// Manticore Search 同步
|
||||
Task::deliver(new ManticoreSyncTask());
|
||||
// AI 任务建议
|
||||
Task::deliver(new AiTaskLoopTask());
|
||||
|
||||
return "success";
|
||||
}
|
||||
|
||||
@@ -10,14 +10,19 @@ class TrustProxies extends Middleware
|
||||
/**
|
||||
* The trusted proxies for this application.
|
||||
*
|
||||
* PHP(Swoole)只在内网被 nginx 访问,外部无法直连,故信任内网代理。
|
||||
*
|
||||
* @var array|string|null
|
||||
*/
|
||||
protected $proxies;
|
||||
protected $proxies = '*';
|
||||
|
||||
/**
|
||||
* The headers that should be used to detect proxies.
|
||||
*
|
||||
* 只采信 X-Forwarded-Proto:nginx 已用 $the_scheme 覆盖该头(值由 nginx 控制),
|
||||
* 据此让 url() 实时跟随 https;host/for 一律不信,避免 Host 注入与 IP 伪造。
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
|
||||
protected $headers = Request::HEADER_X_FORWARDED_PROTO;
|
||||
}
|
||||
|
||||
@@ -56,12 +56,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';
|
||||
}
|
||||
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 归档时间
|
||||
@@ -77,6 +80,7 @@ class Project extends AbstractModel
|
||||
|
||||
protected $appends = [
|
||||
'owner_userid',
|
||||
'deputy_userids',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -92,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
|
||||
*/
|
||||
@@ -227,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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -378,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 = [
|
||||
@@ -530,6 +605,38 @@ class Project extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否有权限创建项目(依据系统设置「项目创建权限」)
|
||||
* @param int $userid
|
||||
* @return bool
|
||||
*/
|
||||
public static function userCanCreate($userid)
|
||||
{
|
||||
// 范围已在 Setting::getSettingAttribute() 归一化(默认 ['all'])
|
||||
$modes = Base::settingFind('system', 'project_add_permission', ['all']);
|
||||
// 「所有人」:放行(与具体用户无关,避免未携带身份时被误判为无权)
|
||||
if (in_array('all', $modes)) {
|
||||
return true;
|
||||
}
|
||||
$user = User::find(intval($userid));
|
||||
if (empty($user)) {
|
||||
return false;
|
||||
}
|
||||
// 系统管理员始终可创建项目(不受开关限制)
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
// 部门负责人/部门管理员
|
||||
if (in_array('departmentOwner', $modes) && UserDepartment::getManagedDepartments($user->userid)->isNotEmpty()) {
|
||||
return true;
|
||||
}
|
||||
// 指定人员
|
||||
if (in_array('appoint', $modes)) {
|
||||
return in_array($user->userid, Base::settingFind('system', 'project_add_userids', []));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
* @param $params
|
||||
@@ -546,6 +653,10 @@ class Project extends AbstractModel
|
||||
$desc = trim(Arr::get($params, 'desc', ''));
|
||||
$flow = trim(Arr::get($params, 'flow', 'close'));
|
||||
$isPersonal = intval(Arr::get($params, 'personal'));
|
||||
// 个人项目为系统自动创建,不受创建权限限制
|
||||
if (!$isPersonal && !self::userCanCreate($userid)) {
|
||||
return Base::retError('当前仅指定人员可以创建项目');
|
||||
}
|
||||
if (mb_strlen($name) < 2) {
|
||||
return Base::retError('项目名称不可以少于2个字');
|
||||
} elseif (mb_strlen($name) > 32) {
|
||||
@@ -599,7 +710,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('创建项目聊天室失败');
|
||||
}
|
||||
@@ -621,7 +732,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)
|
||||
@@ -639,9 +752,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,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
|
||||
@@ -718,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;
|
||||
@@ -1507,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 完成时间
|
||||
@@ -1906,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)) {
|
||||
@@ -2171,6 +2258,40 @@ 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
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -59,6 +59,14 @@ class Setting extends AbstractModel
|
||||
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'];
|
||||
}
|
||||
// 项目创建权限:范围(all/departmentOwner/appoint,默认 all)+ 指定人员
|
||||
$value['project_add_permission'] = array_values(array_intersect(
|
||||
is_array($value['project_add_permission'] ?? null) ? $value['project_add_permission'] : [],
|
||||
['all', 'departmentOwner', 'appoint']
|
||||
)) ?: ['all'];
|
||||
$value['project_add_userids'] = is_array($value['project_add_userids'] ?? null)
|
||||
? array_values(array_unique(array_filter(array_map('intval', $value['project_add_userids']))))
|
||||
: [];
|
||||
break;
|
||||
|
||||
// 文件设置
|
||||
@@ -199,7 +207,6 @@ class Setting extends AbstractModel
|
||||
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
|
||||
return match ($vendor) {
|
||||
'ollama' => $key !== '' || !empty($setting['ollama_base_url']),
|
||||
'wenxin' => $key !== '' && !empty($setting['wenxin_secret']),
|
||||
default => $key !== '',
|
||||
};
|
||||
}
|
||||
@@ -501,7 +508,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ use Carbon\Carbon;
|
||||
*/
|
||||
class User extends AbstractModel
|
||||
{
|
||||
const IMPORT_MAX = 500;
|
||||
|
||||
protected $primaryKey = 'userid';
|
||||
|
||||
protected $hidden = [
|
||||
@@ -425,6 +427,287 @@ class User extends AbstractModel
|
||||
return $createdUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
|
||||
* @param string $email
|
||||
* @param string $password
|
||||
* @param string $nickname
|
||||
* @param array $options changePass(bool,默认true) / emailVerity(bool,默认false,标记邮箱已认证) / 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;
|
||||
$emailVerity = ($options['emailVerity'] ?? false) ? 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; // 复用现有首登强制改密机制
|
||||
$user->email_verity = $emailVerity; // 管理员可在创建时直接标记邮箱认证状态
|
||||
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,
|
||||
'emailVerity' => !empty($row['email_verity']),
|
||||
'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'] ?? '',
|
||||
'email_verity' => 1, // 默认标记为已认证,前端可在预览中按行调整
|
||||
'status' => $ok ? 'ok' : 'error',
|
||||
'reason' => $reason ?? '',
|
||||
];
|
||||
}
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'valid' => $valid,
|
||||
'invalid' => count($rows) - $valid,
|
||||
'rows' => $list,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的ID
|
||||
* @return int
|
||||
|
||||
@@ -151,6 +151,7 @@ class UserBot extends AbstractModel
|
||||
$name = match ($name) {
|
||||
'system-msg' => '系统消息',
|
||||
'task-alert' => '任务提醒',
|
||||
'todo-alert' => '待办提醒',
|
||||
'check-in' => '签到打卡',
|
||||
'anon-msg' => '匿名消息',
|
||||
'approval-alert' => '审批',
|
||||
|
||||
@@ -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,142 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员卡片「查看该会员项目/任务」的权限上下文。
|
||||
* 允许条件:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
|
||||
* @param User $viewer 当前登录用户
|
||||
* @param int $targetUserid 目标会员
|
||||
* @return array ['allowed'=>bool, 'is_self'=>bool, 'is_admin'=>bool, 'project_ids'=>int[]]
|
||||
* project_ids 仅在部门负责人视角下有意义(限定可见项目集合);本人/管理员为空数组表示不限制
|
||||
*/
|
||||
public static function userWorksContext(User $viewer, int $targetUserid): array
|
||||
{
|
||||
$result = [
|
||||
'allowed' => false,
|
||||
'is_self' => false,
|
||||
'is_admin' => false,
|
||||
'project_ids' => [],
|
||||
];
|
||||
if ($targetUserid <= 0) {
|
||||
return $result;
|
||||
}
|
||||
// 机器人/系统账号(或不存在)不展示项目与任务
|
||||
$target = User::select(['userid', 'bot'])->whereUserid($targetUserid)->first();
|
||||
if (empty($target) || $target->bot) {
|
||||
return $result;
|
||||
}
|
||||
// 本人
|
||||
if ($viewer->userid === $targetUserid) {
|
||||
$result['allowed'] = true;
|
||||
$result['is_self'] = true;
|
||||
return $result;
|
||||
}
|
||||
// 系统管理员
|
||||
if ($viewer->isAdmin()) {
|
||||
$result['allowed'] = true;
|
||||
$result['is_admin'] = true;
|
||||
return $result;
|
||||
}
|
||||
// 部门负责人只读视角
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
return $result;
|
||||
}
|
||||
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');
|
||||
if (!in_array($targetUserid, $memberUserids, true)) {
|
||||
return $result;
|
||||
}
|
||||
// 目标会员参与、且未关闭「部门负责人视角可见」的项目
|
||||
$projectIds = ProjectUser::where('project_users.userid', $targetUserid)
|
||||
->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();
|
||||
if (empty($projectIds)) {
|
||||
return $result;
|
||||
}
|
||||
$result['allowed'] = true;
|
||||
$result['project_ids'] = $projectIds;
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ class WebSocketDialog extends AbstractModel
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
// 全员群初始化默认名称(双语字面量),用于识别"管理员尚未自定义"的状态
|
||||
const ALL_GROUP_DEFAULT_NAME = '全体成员 All members';
|
||||
|
||||
protected $appends = ['deputy_ids'];
|
||||
|
||||
/**
|
||||
* 头像地址
|
||||
* @param $value
|
||||
@@ -260,6 +265,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']);
|
||||
|
||||
// 会员必要字段
|
||||
@@ -355,7 +369,9 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
break;
|
||||
case 'all':
|
||||
$data['name'] = Doo::translate('全体成员');
|
||||
$data['name'] = ($data['name'] && $data['name'] !== self::ALL_GROUP_DEFAULT_NAME)
|
||||
? $data['name']
|
||||
: Doo::translate('全体成员');
|
||||
$data['dialog_mute'] = Base::settingFind('system', 'all_group_mute');
|
||||
break;
|
||||
}
|
||||
@@ -457,11 +473,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 +496,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 +504,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 +534,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) {
|
||||
@@ -547,9 +595,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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -635,6 +685,93 @@ 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 (User::find($userid)?->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
// 群主 / 群管理员
|
||||
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
|
||||
@@ -692,7 +829,9 @@ class WebSocketDialog extends AbstractModel
|
||||
$name = \DB::table('project_tasks')->where('dialog_id', $this->id)->value('name');
|
||||
break;
|
||||
case 'all':
|
||||
$name = Doo::translate('全体成员');
|
||||
$name = ($name && $name !== self::ALL_GROUP_DEFAULT_NAME)
|
||||
? $name
|
||||
: Doo::translate('全体成员');
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -820,6 +959,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':
|
||||
@@ -857,6 +1003,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,
|
||||
|
||||
@@ -414,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('此消息不支持设待办');
|
||||
@@ -423,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();
|
||||
@@ -477,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
|
||||
@@ -492,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;
|
||||
@@ -513,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)) {
|
||||
@@ -564,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
|
||||
@@ -695,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'];
|
||||
|
||||
@@ -1262,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"]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,14 +166,29 @@ class AI
|
||||
continue;
|
||||
}
|
||||
$role = trim((string)($item[0] ?? ''));
|
||||
$message = trim((string)($item[1] ?? ''));
|
||||
if ($role === '' || $message === '') {
|
||||
$message = $item[1] ?? '';
|
||||
|
||||
// 跳过空消息
|
||||
if (empty($message)) {
|
||||
continue;
|
||||
}
|
||||
// 替换系统条件性提示块占位符
|
||||
if (str_contains($message, '{{SYSTEM_OPTIONAL_PROMPTS}}')) {
|
||||
$optionalPrompts = PromptPlaceholder::buildOptionalPrompts(User::userid());
|
||||
$message = str_replace('{{SYSTEM_OPTIONAL_PROMPTS}}', $optionalPrompts, $message);
|
||||
|
||||
// 处理纯文本(字符串)
|
||||
if (!is_array($message)) {
|
||||
// 纯文本
|
||||
$message = trim((string)$message);
|
||||
if ($role === '' || $message === '') {
|
||||
continue;
|
||||
}
|
||||
// 替换系统条件性提示块占位符
|
||||
if (str_contains($message, '{{SYSTEM_OPTIONAL_PROMPTS}}')) {
|
||||
$optionalPrompts = PromptPlaceholder::buildOptionalPrompts(User::userid());
|
||||
$message = str_replace('{{SYSTEM_OPTIONAL_PROMPTS}}', $optionalPrompts, $message);
|
||||
}
|
||||
}
|
||||
|
||||
if ($role === '') {
|
||||
continue;
|
||||
}
|
||||
$context[] = [$role, $message];
|
||||
}
|
||||
@@ -189,12 +204,6 @@ class AI
|
||||
}
|
||||
|
||||
$apiKey = Base::val($setting, $modelType . '_key');
|
||||
if ($modelType === 'wenxin') {
|
||||
$wenxinSecret = Base::val($setting, 'wenxin_secret');
|
||||
if ($wenxinSecret) {
|
||||
$apiKey = trim(($apiKey ?: '') . ':' . $wenxinSecret);
|
||||
}
|
||||
}
|
||||
if ($modelType === 'ollama' && empty($apiKey)) {
|
||||
$apiKey = Base::strRandom(6);
|
||||
}
|
||||
@@ -224,6 +233,10 @@ class AI
|
||||
$authParams['agency'] = $agency;
|
||||
}
|
||||
|
||||
// 从模型名末尾剥离思考标记,支持以下写法:
|
||||
// 模型名 think / 模型名-thinking / 模型名_reasoning (空格、- 、_ 作分隔)
|
||||
// 模型名(think) / 模型名 ( reasoning ) (括号包裹)
|
||||
// 关键词三选一:think | thinking | reasoning
|
||||
$thinkPatterns = [
|
||||
"/^(.+?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/",
|
||||
"/^(.+?)\s*\(\s*(think|thinking|reasoning)\s*\)\s*$/"
|
||||
@@ -234,6 +247,7 @@ class AI
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 命中后把关键词剥掉,只保留前面的真实模型名
|
||||
if ($thinkMatch && !empty($thinkMatch[1])) {
|
||||
$authParams['model_name'] = $thinkMatch[1];
|
||||
}
|
||||
@@ -261,6 +275,101 @@ class AI
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 AI 调用接口
|
||||
* 适用于自定义对话场景
|
||||
*
|
||||
* @param array $messages 消息数组,格式:[['role', 'content'], ...]
|
||||
* role: system | user | assistant
|
||||
* @param int $timeout 超时时间(秒)
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
* @return array 返回结果,成功时 data 包含 content 字段
|
||||
*/
|
||||
public static function invoke(array $messages, int $timeout = 60, bool $noCache = true): array
|
||||
{
|
||||
if (!Apps::isInstalled('ai')) {
|
||||
return Base::retError('应用「AI Assistant」未安装');
|
||||
}
|
||||
|
||||
if (empty($messages)) {
|
||||
return Base::retError('消息内容不能为空');
|
||||
}
|
||||
|
||||
$provider = self::resolveTextProvider();
|
||||
if (!$provider) {
|
||||
return Base::retError("请先配置 AI 助手");
|
||||
}
|
||||
|
||||
// 转换消息格式
|
||||
$formattedMessages = [];
|
||||
foreach ($messages as $msg) {
|
||||
if (!is_array($msg) || count($msg) < 2) {
|
||||
continue;
|
||||
}
|
||||
$role = trim((string)($msg[0] ?? ''));
|
||||
$content = trim((string)($msg[1] ?? ''));
|
||||
if ($role === '' || $content === '') {
|
||||
continue;
|
||||
}
|
||||
// 标准化 role
|
||||
$role = match ($role) {
|
||||
'system' => 'system',
|
||||
'assistant' => 'assistant',
|
||||
default => 'user',
|
||||
};
|
||||
$formattedMessages[] = [
|
||||
'role' => $role,
|
||||
'content' => $content,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($formattedMessages)) {
|
||||
return Base::retError('消息内容格式错误');
|
||||
}
|
||||
|
||||
// 构建缓存 key
|
||||
$cacheKey = "AIInvoke::" . md5(json_encode($formattedMessages));
|
||||
if ($noCache) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addHours(1), function () use ($formattedMessages, $provider, $timeout) {
|
||||
$payload = [
|
||||
"model" => $provider['model'],
|
||||
"messages" => $formattedMessages,
|
||||
];
|
||||
$reasoningEffort = self::getReasoningEffort($provider);
|
||||
if ($reasoningEffort !== null) {
|
||||
$payload['reasoning_effort'] = $reasoningEffort;
|
||||
}
|
||||
$post = json_encode($payload);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setProvider($provider);
|
||||
$ai->setTimeout($timeout);
|
||||
|
||||
$res = $ai->request();
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("AI 调用失败", $res);
|
||||
}
|
||||
|
||||
$content = $res['data'];
|
||||
if (empty($content)) {
|
||||
return Base::retError("AI 返回内容为空");
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'content' => $content,
|
||||
]);
|
||||
});
|
||||
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** ******************************************************************************************** */
|
||||
/** ******************************************************************************************** */
|
||||
/** ******************************************************************************************** */
|
||||
@@ -658,14 +767,6 @@ class AI
|
||||
}
|
||||
$model = trim((string)($setting[$vendor . '_model'] ?? ''));
|
||||
break;
|
||||
case 'wenxin':
|
||||
$secret = trim((string)($setting['wenxin_secret'] ?? ''));
|
||||
if ($key === '' || $secret === '' || $baseUrl === '') {
|
||||
return null;
|
||||
}
|
||||
$key = $key . ':' . $secret;
|
||||
$model = trim((string)($setting[$vendor . '_model'] ?? ''));
|
||||
break;
|
||||
default:
|
||||
if ($key === '' || $baseUrl === '') {
|
||||
return null;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use Overtrue\Pinyin\Pinyin;
|
||||
use Redirect;
|
||||
use Request;
|
||||
use Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Validator;
|
||||
@@ -848,6 +848,13 @@ class Base
|
||||
*/
|
||||
public static function getSchemeAndHost()
|
||||
{
|
||||
// 优先用当前请求的协议+主机:getScheme() 会经 TrustProxies 采信 X-Forwarded-Proto,
|
||||
// 从而正确识别 https;host 取自 Host 头(不信 X-Forwarded-Host,避免 Host 注入)
|
||||
$request = request();
|
||||
if ($request && $request->getHttpHost()) {
|
||||
return $request->getSchemeAndHttpHost();
|
||||
}
|
||||
// 非请求上下文(Task/命令行等)的兜底
|
||||
$scheme = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == '443' ? 'https://' : 'http://';
|
||||
return $scheme.($_SERVER['HTTP_HOST'] ?? '');
|
||||
}
|
||||
@@ -2818,14 +2825,17 @@ class Base
|
||||
|
||||
/**
|
||||
* 字节转格式
|
||||
* @param $bytes
|
||||
* @param int|float $bytes
|
||||
* @return string
|
||||
*/
|
||||
public static function readableBytes($bytes)
|
||||
public static function readableBytes(int|float $bytes): string
|
||||
{
|
||||
$i = floor(log($bytes) / log(1024));
|
||||
if ($bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
$i = (int) floor(log($bytes) / log(1024));
|
||||
$sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
return sprintf('%.02F', $bytes / pow(1024, $i)) * 1 . ' ' . $sizes[$i];
|
||||
return (string) ((float) sprintf('%.02F', $bytes / pow(1024, $i))) . ' ' . $sizes[$i];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2868,9 +2878,15 @@ class Base
|
||||
|
||||
/**
|
||||
* DownloadFileResponse 下载文件
|
||||
*
|
||||
* 返回 Symfony BinaryFileResponse,在 LaravelS/Swoole 环境下由 StaticResponse 走原生
|
||||
* sendfile() 发送——OS 级零拷贝、不占用 PHP 内存,可支持任意大小文件(如几百 MB 的大文件)。
|
||||
* 切勿改回 StreamedResponse:它会被 LaravelS 用 ob_start()/ob_get_clean() 把整个响应体
|
||||
* 缓冲进 PHP 内存,大文件会撞 memory_limit 导致下载失败。
|
||||
*
|
||||
* @param File|\SplFileInfo|string $file 文件对象或路径
|
||||
* @param string|null $name 下载文件名
|
||||
* @return StreamedResponse
|
||||
* @return BinaryFileResponse
|
||||
*/
|
||||
public static function DownloadFileResponse($file, $name = null)
|
||||
{
|
||||
@@ -2889,12 +2905,6 @@ class Base
|
||||
throw new FileException('File must be readable and exist.');
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
$size = $file->getSize();
|
||||
if ($size === false || $size < 0) {
|
||||
throw new FileException('Unable to determine file size.');
|
||||
}
|
||||
|
||||
// 处理文件名
|
||||
if (empty($name)) {
|
||||
$name = basename($file->getPathname());
|
||||
@@ -2912,83 +2922,27 @@ class Base
|
||||
$mimeType = 'application/octet-stream';
|
||||
}
|
||||
|
||||
// 处理 Range 请求
|
||||
$start = 0;
|
||||
$end = $size - 1;
|
||||
$length = $size;
|
||||
$isRangeRequest = false;
|
||||
// BinaryFileResponse:autoEtag=false 避免对大文件做 md5/sha1 全文件哈希,autoLastModified=true 取 mtime(开销极小)
|
||||
$response = new BinaryFileResponse($file, 200, [], true, null, false, true);
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
$response->headers->set('Cache-Control', 'private, no-transform, no-store, must-revalidate, max-age=0');
|
||||
// filename 兜底为纯 ASCII,filename* 用 UTF-8 编码,兼容含中文/特殊字符的文件名
|
||||
$asciiName = preg_replace('/[^\x20-\x7e]/', '_', $name);
|
||||
$response->headers->set('Content-Disposition', sprintf(
|
||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||
$asciiName,
|
||||
rawurlencode($name)
|
||||
));
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||
$range = str_replace('bytes=', '', $_SERVER['HTTP_RANGE']);
|
||||
if (preg_match('/^(\d+)-(\d*)$/', $range, $matches)) {
|
||||
$start = intval($matches[1]);
|
||||
$end = !empty($matches[2]) ? intval($matches[2]) : $size - 1;
|
||||
|
||||
// 验证范围的有效性
|
||||
if ($start >= 0 && $end < $size && $start <= $end) {
|
||||
$length = $end - $start + 1;
|
||||
$isRangeRequest = true;
|
||||
} else {
|
||||
$start = 0;
|
||||
$end = $size - 1;
|
||||
}
|
||||
}
|
||||
// LaravelS/Swoole 下 StaticResponse 用 sendfile() 整文件发送,不支持分段;
|
||||
// 若放任 Symfony 处理 Range 会返回 206 头却仍发送完整文件,导致内容错位/损坏。
|
||||
// 故在 Swoole 环境下移除 Range 请求头,始终以 200 返回完整文件。
|
||||
if (app()->bound('swoole')) {
|
||||
Request::instance()->headers->remove('Range');
|
||||
$response->headers->set('Accept-Ranges', 'none');
|
||||
}
|
||||
|
||||
// 设置基本响应头
|
||||
$headers = [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Disposition' => sprintf(
|
||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||
$name,
|
||||
rawurlencode($name)
|
||||
),
|
||||
'Accept-Ranges' => 'bytes',
|
||||
'Cache-Control' => 'private, no-transform, no-store, must-revalidate, max-age=0',
|
||||
'Content-Length' => $length,
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $file->getMTime()) . ' GMT',
|
||||
'ETag' => sprintf('"%s"', md5_file($file->getPathname()))
|
||||
];
|
||||
|
||||
if ($isRangeRequest) {
|
||||
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
|
||||
$statusCode = 206;
|
||||
} else {
|
||||
$statusCode = 200;
|
||||
}
|
||||
|
||||
// 创建流式响应
|
||||
return new StreamedResponse(
|
||||
function () use ($file, $start, $length) {
|
||||
$handle = fopen($file->getPathname(), 'rb');
|
||||
if ($handle === false) {
|
||||
throw new FileException('Cannot open file for reading');
|
||||
}
|
||||
|
||||
if (fseek($handle, $start) === -1) {
|
||||
fclose($handle);
|
||||
throw new FileException('Cannot seek to position ' . $start);
|
||||
}
|
||||
|
||||
$remaining = $length;
|
||||
$bufferSize = 8192; // 8KB chunks
|
||||
|
||||
while ($remaining > 0 && !feof($handle)) {
|
||||
$readSize = min($bufferSize, $remaining);
|
||||
$buffer = fread($handle, $readSize);
|
||||
if ($buffer === false) {
|
||||
break;
|
||||
}
|
||||
echo $buffer;
|
||||
flush();
|
||||
$remaining -= strlen($buffer);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
},
|
||||
$statusCode,
|
||||
$headers
|
||||
);
|
||||
return $response;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('File download failed', [
|
||||
'error' => $e->getMessage(),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\ManticoreSyncFailure;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
@@ -173,6 +174,71 @@ class ManticoreBase
|
||||
self::$initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为连接断开错误
|
||||
* 参考 Laravel Illuminate\Database\DetectsLostConnections
|
||||
*/
|
||||
private function isConnectionLostError(PDOException $e): bool
|
||||
{
|
||||
$message = $e->getMessage();
|
||||
return stripos($message, 'server has gone away') !== false
|
||||
|| stripos($message, 'no connection to the server') !== false
|
||||
|| stripos($message, 'Lost connection') !== false
|
||||
|| stripos($message, 'is dead or not enabled') !== false
|
||||
|| stripos($message, 'Error while sending') !== false
|
||||
|| stripos($message, 'decryption failed or bad record mac') !== false
|
||||
|| stripos($message, 'server closed the connection unexpectedly') !== false
|
||||
|| stripos($message, 'SSL connection has been closed unexpectedly') !== false
|
||||
|| stripos($message, 'Error writing data to the connection') !== false
|
||||
|| stripos($message, 'Resource deadlock avoided') !== false
|
||||
|| stripos($message, 'Transaction() on null') !== false
|
||||
|| stripos($message, 'child connection forced to terminate') !== false
|
||||
|| stripos($message, 'query_wait_timeout') !== false
|
||||
|| stripos($message, 'reset by peer') !== false
|
||||
|| stripos($message, 'Physical connection is not usable') !== false
|
||||
|| stripos($message, 'Packets out of order') !== false
|
||||
|| stripos($message, 'Adaptive Server connection failed') !== false
|
||||
|| stripos($message, 'Connection was killed') !== false
|
||||
|| stripos($message, 'Broken pipe') !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的执行包装器
|
||||
* 正常情况零开销,仅在连接断开时重试一次
|
||||
*
|
||||
* @param callable $callback 执行回调,接收 PDO 参数
|
||||
* @param mixed $failureReturn 失败时的返回值
|
||||
* @param array $logContext 日志上下文
|
||||
* @return mixed
|
||||
*/
|
||||
private function runWithRetry(callable $callback, $failureReturn = false, array $logContext = [])
|
||||
{
|
||||
$pdo = $this->getConnection();
|
||||
if (!$pdo) {
|
||||
return $failureReturn;
|
||||
}
|
||||
|
||||
try {
|
||||
return $callback($pdo);
|
||||
} catch (PDOException $e) {
|
||||
// 如果是连接断开错误,重置连接并重试一次
|
||||
if ($this->isConnectionLostError($e)) {
|
||||
self::resetConnection();
|
||||
$pdo = $this->getConnection();
|
||||
if ($pdo) {
|
||||
try {
|
||||
return $callback($pdo);
|
||||
} catch (PDOException $retryException) {
|
||||
Log::error('Manticore retry failed: ' . $retryException->getMessage(), $logContext);
|
||||
return $failureReturn;
|
||||
}
|
||||
}
|
||||
}
|
||||
Log::error('Manticore error: ' . $e->getMessage(), $logContext);
|
||||
return $failureReturn;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已安装
|
||||
*/
|
||||
@@ -181,6 +247,57 @@ class ManticoreBase
|
||||
return Apps::isInstalled("search");
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接执行 SQL(不使用参数绑定)
|
||||
* 用于包含 MVA 或向量字段的 INSERT 语句,因为 Manticore 的 prepared statement 不支持括号表达式
|
||||
*
|
||||
* @param string $sql 完整的 SQL 语句(所有值已内联)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public function executeRaw(string $sql): bool
|
||||
{
|
||||
return $this->runWithRetry(
|
||||
function (PDO $pdo) use ($sql) {
|
||||
$pdo->exec($sql);
|
||||
return true;
|
||||
},
|
||||
false,
|
||||
['sql' => $sql]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 SQL 字符串值(用于不使用参数绑定的场景)
|
||||
*
|
||||
* @param mixed $value 要转义的值
|
||||
* @return string 转义后的值(包含引号)
|
||||
*/
|
||||
public function quoteValue($value): string
|
||||
{
|
||||
$pdo = $this->getConnection();
|
||||
if (!$pdo) {
|
||||
// Fallback: 手动转义
|
||||
if (is_null($value)) {
|
||||
return 'NULL';
|
||||
}
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string)$value;
|
||||
}
|
||||
return "'" . addslashes((string)$value) . "'";
|
||||
}
|
||||
|
||||
if (is_null($value)) {
|
||||
return 'NULL';
|
||||
}
|
||||
if (is_int($value)) {
|
||||
return (string)$value;
|
||||
}
|
||||
if (is_float($value)) {
|
||||
return (string)$value;
|
||||
}
|
||||
return $pdo->quote((string)$value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SQL(不返回结果)
|
||||
*
|
||||
@@ -190,22 +307,15 @@ class ManticoreBase
|
||||
*/
|
||||
public function execute(string $sql, array $params = []): bool
|
||||
{
|
||||
$pdo = $this->getConnection();
|
||||
if (!$pdo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
return $stmt->execute();
|
||||
} catch (PDOException $e) {
|
||||
Log::error('Manticore execute error: ' . $e->getMessage(), [
|
||||
'sql' => $sql,
|
||||
'params' => $params
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
return $this->runWithRetry(
|
||||
function (PDO $pdo) use ($sql, $params) {
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
return $stmt->execute();
|
||||
},
|
||||
false,
|
||||
['sql' => $sql, 'params' => $params]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,23 +327,16 @@ class ManticoreBase
|
||||
*/
|
||||
public function executeWithRowCount(string $sql, array $params = []): int
|
||||
{
|
||||
$pdo = $this->getConnection();
|
||||
if (!$pdo) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
return $stmt->rowCount();
|
||||
} catch (PDOException $e) {
|
||||
Log::error('Manticore execute error: ' . $e->getMessage(), [
|
||||
'sql' => $sql,
|
||||
'params' => $params
|
||||
]);
|
||||
return -1;
|
||||
}
|
||||
return $this->runWithRetry(
|
||||
function (PDO $pdo) use ($sql, $params) {
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
return $stmt->rowCount();
|
||||
},
|
||||
-1,
|
||||
['sql' => $sql, 'params' => $params]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,23 +348,16 @@ class ManticoreBase
|
||||
*/
|
||||
public function query(string $sql, array $params = []): array
|
||||
{
|
||||
$pdo = $this->getConnection();
|
||||
if (!$pdo) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
return $this->convertNumericTypes($stmt->fetchAll());
|
||||
} catch (PDOException $e) {
|
||||
Log::error('Manticore query error: ' . $e->getMessage(), [
|
||||
'sql' => $sql,
|
||||
'params' => $params
|
||||
]);
|
||||
return [];
|
||||
}
|
||||
return $this->runWithRetry(
|
||||
function (PDO $pdo) use ($sql, $params) {
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
return $this->convertNumericTypes($stmt->fetchAll());
|
||||
},
|
||||
[],
|
||||
['sql' => $sql, 'params' => $params]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,24 +369,17 @@ class ManticoreBase
|
||||
*/
|
||||
public function queryOne(string $sql, array $params = []): ?array
|
||||
{
|
||||
$pdo = $this->getConnection();
|
||||
if (!$pdo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetch();
|
||||
return $result ? $this->convertNumericTypesRow($result) : null;
|
||||
} catch (PDOException $e) {
|
||||
Log::error('Manticore queryOne error: ' . $e->getMessage(), [
|
||||
'sql' => $sql,
|
||||
'params' => $params
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
return $this->runWithRetry(
|
||||
function (PDO $pdo) use ($sql, $params) {
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetch();
|
||||
return $result ? $this->convertNumericTypesRow($result) : null;
|
||||
},
|
||||
null,
|
||||
['sql' => $sql, 'params' => $params]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -578,45 +667,9 @@ class ManticoreBase
|
||||
*/
|
||||
public static function upsertFileVector(array $data): bool
|
||||
{
|
||||
$instance = new self();
|
||||
|
||||
$fileId = $data['file_id'] ?? 0;
|
||||
if ($fileId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 先尝试删除已存在的记录
|
||||
$instance->execute("DELETE FROM file_vectors WHERE file_id = ?", [$fileId]);
|
||||
|
||||
// 构建 allowed_users MVA 值
|
||||
$allowedUsers = $data['allowed_users'] ?? [];
|
||||
$allowedUsersStr = !empty($allowedUsers) ? '(' . implode(',', array_map('intval', $allowedUsers)) . ')' : '()';
|
||||
|
||||
// 插入新记录
|
||||
$vectorValue = $data['content_vector'] ?? null;
|
||||
if ($vectorValue) {
|
||||
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
|
||||
$sql = "INSERT INTO file_vectors
|
||||
(id, file_id, userid, pshare, file_name, file_type, file_ext, content, allowed_users, content_vector)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, {$allowedUsersStr}, {$vectorValue})";
|
||||
} else {
|
||||
$sql = "INSERT INTO file_vectors
|
||||
(id, file_id, userid, pshare, file_name, file_type, file_ext, content, allowed_users)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, {$allowedUsersStr})";
|
||||
}
|
||||
|
||||
$params = [
|
||||
$fileId,
|
||||
$fileId,
|
||||
$data['userid'] ?? 0,
|
||||
$data['pshare'] ?? 0,
|
||||
$data['file_name'] ?? '',
|
||||
$data['file_type'] ?? '',
|
||||
$data['file_ext'] ?? '',
|
||||
$data['content'] ?? ''
|
||||
];
|
||||
|
||||
return $instance->execute($sql, $params);
|
||||
// 确保 id 字段与 file_id 一致
|
||||
$data['id'] = $data['file_id'] ?? 0;
|
||||
return self::upsertVector('file', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -649,12 +702,7 @@ class ManticoreBase
|
||||
*/
|
||||
public static function deleteFileVector(int $fileId): bool
|
||||
{
|
||||
if ($fileId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new self();
|
||||
return $instance->execute("DELETE FROM file_vectors WHERE file_id = ?", [$fileId]);
|
||||
return self::deleteVector('file', $fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -875,50 +923,9 @@ class ManticoreBase
|
||||
*/
|
||||
public static function upsertUserVector(array $data): bool
|
||||
{
|
||||
$instance = new self();
|
||||
|
||||
$userid = $data['userid'] ?? 0;
|
||||
if ($userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 先删除已存在的记录
|
||||
$instance->execute("DELETE FROM user_vectors WHERE userid = ?", [$userid]);
|
||||
|
||||
// 插入新记录
|
||||
$vectorValue = $data['content_vector'] ?? null;
|
||||
if ($vectorValue) {
|
||||
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
|
||||
$sql = "INSERT INTO user_vectors
|
||||
(id, userid, nickname, email, profession, tags, introduction, content_vector)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, {$vectorValue})";
|
||||
|
||||
$params = [
|
||||
$userid,
|
||||
$userid,
|
||||
$data['nickname'] ?? '',
|
||||
$data['email'] ?? '',
|
||||
$data['profession'] ?? '',
|
||||
$data['tags'] ?? '',
|
||||
$data['introduction'] ?? ''
|
||||
];
|
||||
} else {
|
||||
$sql = "INSERT INTO user_vectors
|
||||
(id, userid, nickname, email, profession, tags, introduction)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$params = [
|
||||
$userid,
|
||||
$userid,
|
||||
$data['nickname'] ?? '',
|
||||
$data['email'] ?? '',
|
||||
$data['profession'] ?? '',
|
||||
$data['tags'] ?? '',
|
||||
$data['introduction'] ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
return $instance->execute($sql, $params);
|
||||
// 确保 id 字段与 userid 一致
|
||||
$data['id'] = $data['userid'] ?? 0;
|
||||
return self::upsertVector('user', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -929,12 +936,7 @@ class ManticoreBase
|
||||
*/
|
||||
public static function deleteUserVector(int $userid): bool
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new self();
|
||||
return $instance->execute("DELETE FROM user_vectors WHERE userid = ?", [$userid]);
|
||||
return self::deleteVector('user', $userid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1136,43 +1138,9 @@ class ManticoreBase
|
||||
*/
|
||||
public static function upsertProjectVector(array $data): bool
|
||||
{
|
||||
$instance = new self();
|
||||
|
||||
$projectId = $data['project_id'] ?? 0;
|
||||
if ($projectId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 先删除已存在的记录
|
||||
$instance->execute("DELETE FROM project_vectors WHERE project_id = ?", [$projectId]);
|
||||
|
||||
// 构建 allowed_users MVA 值
|
||||
$allowedUsers = $data['allowed_users'] ?? [];
|
||||
$allowedUsersStr = !empty($allowedUsers) ? '(' . implode(',', array_map('intval', $allowedUsers)) . ')' : '()';
|
||||
|
||||
// 插入新记录
|
||||
$vectorValue = $data['content_vector'] ?? null;
|
||||
if ($vectorValue) {
|
||||
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
|
||||
$sql = "INSERT INTO project_vectors
|
||||
(id, project_id, userid, personal, project_name, project_desc, allowed_users, content_vector)
|
||||
VALUES (?, ?, ?, ?, ?, ?, {$allowedUsersStr}, {$vectorValue})";
|
||||
} else {
|
||||
$sql = "INSERT INTO project_vectors
|
||||
(id, project_id, userid, personal, project_name, project_desc, allowed_users)
|
||||
VALUES (?, ?, ?, ?, ?, ?, {$allowedUsersStr})";
|
||||
}
|
||||
|
||||
$params = [
|
||||
$projectId,
|
||||
$projectId,
|
||||
$data['userid'] ?? 0,
|
||||
$data['personal'] ?? 0,
|
||||
$data['project_name'] ?? '',
|
||||
$data['project_desc'] ?? ''
|
||||
];
|
||||
|
||||
return $instance->execute($sql, $params);
|
||||
// 确保 id 字段与 project_id 一致
|
||||
$data['id'] = $data['project_id'] ?? 0;
|
||||
return self::upsertVector('project', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1205,12 +1173,7 @@ class ManticoreBase
|
||||
*/
|
||||
public static function deleteProjectVector(int $projectId): bool
|
||||
{
|
||||
if ($projectId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new self();
|
||||
return $instance->execute("DELETE FROM project_vectors WHERE project_id = ?", [$projectId]);
|
||||
return self::deleteVector('project', $projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1420,45 +1383,9 @@ class ManticoreBase
|
||||
*/
|
||||
public static function upsertTaskVector(array $data): bool
|
||||
{
|
||||
$instance = new self();
|
||||
|
||||
$taskId = $data['task_id'] ?? 0;
|
||||
if ($taskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 先删除已存在的记录
|
||||
$instance->execute("DELETE FROM task_vectors WHERE task_id = ?", [$taskId]);
|
||||
|
||||
// 构建 allowed_users MVA 值
|
||||
$allowedUsers = $data['allowed_users'] ?? [];
|
||||
$allowedUsersStr = !empty($allowedUsers) ? '(' . implode(',', array_map('intval', $allowedUsers)) . ')' : '()';
|
||||
|
||||
// 插入新记录
|
||||
$vectorValue = $data['content_vector'] ?? null;
|
||||
if ($vectorValue) {
|
||||
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
|
||||
$sql = "INSERT INTO task_vectors
|
||||
(id, task_id, project_id, userid, visibility, task_name, task_desc, task_content, allowed_users, content_vector)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, {$allowedUsersStr}, {$vectorValue})";
|
||||
} else {
|
||||
$sql = "INSERT INTO task_vectors
|
||||
(id, task_id, project_id, userid, visibility, task_name, task_desc, task_content, allowed_users)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, {$allowedUsersStr})";
|
||||
}
|
||||
|
||||
$params = [
|
||||
$taskId,
|
||||
$taskId,
|
||||
$data['project_id'] ?? 0,
|
||||
$data['userid'] ?? 0,
|
||||
$data['visibility'] ?? 1,
|
||||
$data['task_name'] ?? '',
|
||||
$data['task_desc'] ?? '',
|
||||
$data['task_content'] ?? ''
|
||||
];
|
||||
|
||||
return $instance->execute($sql, $params);
|
||||
// 确保 id 字段与 task_id 一致
|
||||
$data['id'] = $data['task_id'] ?? 0;
|
||||
return self::upsertVector('task', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1511,12 +1438,7 @@ class ManticoreBase
|
||||
*/
|
||||
public static function deleteTaskVector(int $taskId): bool
|
||||
{
|
||||
if ($taskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new self();
|
||||
return $instance->execute("DELETE FROM task_vectors WHERE task_id = ?", [$taskId]);
|
||||
return self::deleteVector('task', $taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1725,44 +1647,9 @@ class ManticoreBase
|
||||
*/
|
||||
public static function upsertMsgVector(array $data): bool
|
||||
{
|
||||
$instance = new self();
|
||||
|
||||
$msgId = $data['msg_id'] ?? 0;
|
||||
if ($msgId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 先删除已存在的记录
|
||||
$instance->execute("DELETE FROM msg_vectors WHERE msg_id = ?", [$msgId]);
|
||||
|
||||
// 构建 allowed_users MVA 值
|
||||
$allowedUsers = $data['allowed_users'] ?? [];
|
||||
$allowedUsersStr = !empty($allowedUsers) ? '(' . implode(',', array_map('intval', $allowedUsers)) . ')' : '()';
|
||||
|
||||
// 插入新记录
|
||||
$vectorValue = $data['content_vector'] ?? null;
|
||||
if ($vectorValue) {
|
||||
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
|
||||
$sql = "INSERT INTO msg_vectors
|
||||
(id, msg_id, dialog_id, userid, msg_type, content, allowed_users, created_at, content_vector)
|
||||
VALUES (?, ?, ?, ?, ?, ?, {$allowedUsersStr}, ?, {$vectorValue})";
|
||||
} else {
|
||||
$sql = "INSERT INTO msg_vectors
|
||||
(id, msg_id, dialog_id, userid, msg_type, content, allowed_users, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, {$allowedUsersStr}, ?)";
|
||||
}
|
||||
|
||||
$params = [
|
||||
$msgId,
|
||||
$msgId,
|
||||
$data['dialog_id'] ?? 0,
|
||||
$data['userid'] ?? 0,
|
||||
$data['msg_type'] ?? 'text',
|
||||
$data['content'] ?? '',
|
||||
$data['created_at'] ?? time()
|
||||
];
|
||||
|
||||
return $instance->execute($sql, $params);
|
||||
// 确保 id 字段与 msg_id 一致
|
||||
$data['id'] = $data['msg_id'] ?? 0;
|
||||
return self::upsertVector('msg', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1796,12 +1683,7 @@ class ManticoreBase
|
||||
*/
|
||||
public static function deleteMsgVector(int $msgId): bool
|
||||
{
|
||||
if ($msgId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new self();
|
||||
return $instance->execute("DELETE FROM msg_vectors WHERE msg_id = ?", [$msgId]);
|
||||
return self::deleteVector('msg', $msgId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1882,6 +1764,14 @@ class ManticoreBase
|
||||
// 向量更新方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 数值类型字段列表(用于 SQL 值构建时判断是否需要引号)
|
||||
*/
|
||||
private const NUMERIC_FIELDS = [
|
||||
'id', 'userid', 'pshare', 'visibility', 'personal',
|
||||
'msg_id', 'file_id', 'task_id', 'project_id', 'dialog_id', 'created_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* 向量表配置
|
||||
* 定义各类型的表名、主键字段、普通字段、MVA字段
|
||||
@@ -1919,6 +1809,120 @@ class ManticoreBase
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* 通用向量插入方法
|
||||
*
|
||||
* 使用 executeRaw 直接执行 SQL,避免 Manticore prepared statement
|
||||
* 无法解析 MVA 和向量字段括号语法的问题。
|
||||
*
|
||||
* @param string $type 类型: msg/file/task/project/user
|
||||
* @param array $data 数据,键名对应字段名
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function upsertVector(string $type, array $data): bool
|
||||
{
|
||||
if (!isset(self::VECTOR_TABLE_CONFIG[$type])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$config = self::VECTOR_TABLE_CONFIG[$type];
|
||||
$table = $config['table'];
|
||||
$pk = $config['pk'];
|
||||
$fields = $config['fields'];
|
||||
$mvaFields = $config['mva_fields'];
|
||||
|
||||
// 检查主键
|
||||
$pkValue = $data[$pk] ?? 0;
|
||||
if ($pkValue <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new self();
|
||||
|
||||
// 先删除已存在的记录
|
||||
$instance->execute("DELETE FROM {$table} WHERE {$pk} = ?", [$pkValue]);
|
||||
|
||||
// 构建字段列表和值
|
||||
$fieldList = [];
|
||||
$valueList = [];
|
||||
|
||||
// 处理普通字段
|
||||
foreach ($fields as $field) {
|
||||
$fieldList[] = $field;
|
||||
$value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : ''));
|
||||
|
||||
if (in_array($field, self::NUMERIC_FIELDS)) {
|
||||
$valueList[] = (int)$value;
|
||||
} else {
|
||||
$valueList[] = $instance->quoteValue((string)$value);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 MVA 字段
|
||||
foreach ($mvaFields as $mvaField) {
|
||||
$fieldList[] = $mvaField;
|
||||
$mvaData = $data[$mvaField] ?? [];
|
||||
$valueList[] = !empty($mvaData)
|
||||
? '(' . implode(',', array_map('intval', $mvaData)) . ')'
|
||||
: '()';
|
||||
}
|
||||
|
||||
// 处理向量字段
|
||||
$vectorValue = $data['content_vector'] ?? null;
|
||||
if ($vectorValue) {
|
||||
$fieldList[] = 'content_vector';
|
||||
$valueList[] = str_replace(['[', ']'], ['(', ')'], $vectorValue);
|
||||
}
|
||||
|
||||
// 构建并执行 SQL
|
||||
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")";
|
||||
|
||||
$result = $instance->executeRaw($sql);
|
||||
|
||||
// 记录同步结果
|
||||
if ($result) {
|
||||
// 成功则删除失败记录(如果有)
|
||||
ManticoreSyncFailure::removeSuccess($type, $pkValue, 'sync');
|
||||
} else {
|
||||
// 失败则记录
|
||||
ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "INSERT failed for {$table}");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用向量删除方法
|
||||
*
|
||||
* @param string $type 类型: msg/file/task/project/user
|
||||
* @param int $id 数据ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function deleteVector(string $type, int $id): bool
|
||||
{
|
||||
if (!isset(self::VECTOR_TABLE_CONFIG[$type]) || $id <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$config = self::VECTOR_TABLE_CONFIG[$type];
|
||||
$table = $config['table'];
|
||||
$pk = $config['pk'];
|
||||
|
||||
$instance = new self();
|
||||
$result = $instance->execute("DELETE FROM {$table} WHERE {$pk} = ?", [$id]);
|
||||
|
||||
// 记录删除结果
|
||||
if ($result) {
|
||||
// 成功则删除失败记录(如果有)
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'delete');
|
||||
} else {
|
||||
// 失败则记录
|
||||
ManticoreSyncFailure::recordFailure($type, $id, 'delete', "DELETE failed for {$table}");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用批量更新向量方法(高性能版本)
|
||||
*
|
||||
@@ -1982,29 +1986,34 @@ class ManticoreBase
|
||||
// Manticore 向量使用 () 格式
|
||||
$vectorStr = str_replace(['[', ']'], ['(', ')'], $vectorStr);
|
||||
|
||||
// 构建字段列表和值
|
||||
// 构建字段列表和值(直接内联值,不使用参数绑定)
|
||||
$fieldList = $fields;
|
||||
$values = [];
|
||||
$quotedValues = [];
|
||||
foreach ($fields as $field) {
|
||||
$value = $existing[$field] ?? null;
|
||||
// 处理默认值:数值字段用 0,时间戳字段用当前时间,其他用空字符串
|
||||
if ($value === null) {
|
||||
if ($field === 'created_at') {
|
||||
$value = time();
|
||||
} elseif (in_array($field, ['id', 'userid', 'pshare', 'visibility', 'personal', 'msg_id', 'file_id', 'task_id', 'project_id', 'dialog_id'])) {
|
||||
} elseif (in_array($field, self::NUMERIC_FIELDS)) {
|
||||
$value = 0;
|
||||
} else {
|
||||
$value = '';
|
||||
}
|
||||
}
|
||||
$values[] = $value;
|
||||
// 根据字段类型处理值
|
||||
if (in_array($field, self::NUMERIC_FIELDS)) {
|
||||
$quotedValues[] = (int)$value;
|
||||
} else {
|
||||
$quotedValues[] = $instance->quoteValue((string)$value);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 MVA 字段
|
||||
$mvaValues = [];
|
||||
$mvaValuesStr = [];
|
||||
foreach ($mvaFields as $mvaField) {
|
||||
$fieldList[] = $mvaField;
|
||||
$mvaValues[] = !empty($existing[$mvaField])
|
||||
$mvaValuesStr[] = !empty($existing[$mvaField])
|
||||
? '(' . $existing[$mvaField] . ')'
|
||||
: '()';
|
||||
}
|
||||
@@ -2012,12 +2021,11 @@ class ManticoreBase
|
||||
// 添加向量字段
|
||||
$fieldList[] = 'content_vector';
|
||||
|
||||
// 构建 SQL
|
||||
$valuePlaceholders = array_fill(0, count($fields), '?');
|
||||
$allValues = implode(', ', array_merge($valuePlaceholders, $mvaValues, [$vectorStr]));
|
||||
// 构建 SQL(所有值直接内联,使用 executeRaw 避免 prepared statement 解析问题)
|
||||
$allValues = implode(', ', array_merge($quotedValues, $mvaValuesStr, [$vectorStr]));
|
||||
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES ({$allValues})";
|
||||
|
||||
$insertStatements[] = ['sql' => $sql, 'values' => $values, 'pk' => $pkValue];
|
||||
$insertStatements[] = ['sql' => $sql, 'pk' => $pkValue];
|
||||
}
|
||||
|
||||
// 如果没有有效的插入语句,直接返回
|
||||
@@ -2033,13 +2041,16 @@ class ManticoreBase
|
||||
$validPks
|
||||
);
|
||||
|
||||
// 4. 逐条插入新记录
|
||||
// 4. 逐条插入新记录(使用 executeRaw 避免 prepared statement 解析问题)
|
||||
$successCount = 0;
|
||||
foreach ($insertStatements as $stmt) {
|
||||
if ($instance->execute($stmt['sql'], $stmt['values'])) {
|
||||
if ($instance->executeRaw($stmt['sql'])) {
|
||||
$successCount++;
|
||||
// 成功则删除失败记录(如果有)
|
||||
ManticoreSyncFailure::removeSuccess($type, $stmt['pk'], 'sync');
|
||||
} else {
|
||||
// 插入失败,数据已被删除,需要重新同步
|
||||
// 失败则记录
|
||||
ManticoreSyncFailure::recordFailure($type, $stmt['pk'], 'sync', "Batch INSERT failed for {$table}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Module\Base;
|
||||
use App\Module\TextExtractor;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Manticore Search 文件搜索类
|
||||
@@ -355,7 +354,85 @@ class ManticoreFile
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文件内容
|
||||
* 提取文件内容(支持分页)
|
||||
*
|
||||
* @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 文件内容文本
|
||||
@@ -364,37 +441,28 @@ class ManticoreFile
|
||||
{
|
||||
// 1. 先尝试从 FileContent 的 text 字段获取(已提取的文本内容)
|
||||
$fileContent = FileContent::where('fid', $file->id)->orderByDesc('id')->first();
|
||||
if ($fileContent && !empty($fileContent->text)) {
|
||||
if (!$fileContent) {
|
||||
return '';
|
||||
}
|
||||
if (!empty($fileContent->text)) {
|
||||
return $fileContent->text;
|
||||
}
|
||||
|
||||
// 2. 尝试从 FileContent 的 content 字段获取
|
||||
if ($fileContent && !empty($fileContent->content)) {
|
||||
if (!empty($fileContent->content)) {
|
||||
$contentData = Base::json2array($fileContent->content);
|
||||
|
||||
// 2.1 某些文件类型直接存储内容
|
||||
if (!empty($contentData['content'])) {
|
||||
return is_string($contentData['content']) ? $contentData['content'] : '';
|
||||
if (!empty($contentData['content']) && is_string($contentData['content'])) {
|
||||
return $contentData['content'];
|
||||
}
|
||||
|
||||
// 2.2 尝试使用 TextExtractor 提取文件内容
|
||||
// 2.2 通过路径提取
|
||||
$filePath = $contentData['url'] ?? null;
|
||||
if ($filePath && str_starts_with($filePath, 'uploads/')) {
|
||||
$fullPath = public_path($filePath);
|
||||
if (file_exists($fullPath)) {
|
||||
// 根据文件类型设置不同的大小限制
|
||||
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||
$maxFileSize = self::getMaxFileSizeByExt($ext);
|
||||
$maxContentSize = self::MAX_CONTENT_LENGTH;
|
||||
|
||||
$result = TextExtractor::extractFile(
|
||||
$fullPath,
|
||||
(int) ($maxFileSize / 1024), // 转换为 KB
|
||||
(int) ($maxContentSize / 1024) // 转换为 KB
|
||||
);
|
||||
if (Base::isSuccess($result)) {
|
||||
return $result['data'] ?? '';
|
||||
}
|
||||
$result = self::extractFromPath($filePath);
|
||||
if (is_string($result)) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,6 +470,33 @@ class ManticoreFile
|
||||
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'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用于生成向量的内容
|
||||
* 包含文件名和文件内容,确保语义搜索能匹配文件名
|
||||
|
||||
@@ -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字)'];
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,9 @@ class ProjectTaskObserver extends AbstractObserver
|
||||
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)) {
|
||||
|
||||
@@ -99,8 +99,8 @@ class UserObserver extends AbstractObserver
|
||||
|
||||
if (!empty($changedFields)) {
|
||||
// 判断是用户自己修改还是管理员修改
|
||||
$currentUser = User::authInfo();
|
||||
$eventType = ($currentUser && $currentUser->userid === $user->userid)
|
||||
$currentUserid = User::userid();
|
||||
$eventType = ($currentUserid > 0 && $currentUserid === $user->userid)
|
||||
? 'profile_update'
|
||||
: 'admin_update';
|
||||
Apps::dispatchUserHook($user, 'user_update', $eventType, $changedFields);
|
||||
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -490,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;
|
||||
|
||||
@@ -256,6 +256,9 @@ class ManticoreSyncTask extends AbstractTask
|
||||
@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 &");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,7 +38,7 @@ class PushUmengMsg extends AbstractTask
|
||||
}
|
||||
|
||||
// 消息ID
|
||||
$msgId = isset($this->array['id']) ? intval($this->array['id']) : 0;
|
||||
$msgId = isset($this->array['extra']['msg_id']) ? intval($this->array['extra']['msg_id']) : 0;
|
||||
|
||||
// 处理用户列表
|
||||
$userids = is_array($this->userid) ? $this->userid : [$this->userid];
|
||||
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,12 @@ class WebSocketDialogMsgTask extends AbstractTask
|
||||
if ($umengUserid) {
|
||||
$setting = Base::setting('appPushSetting');
|
||||
if ($setting['push'] === 'open') {
|
||||
$umengTitle = User::userid2nickname($msg->userid);
|
||||
if ($msg->userid == -1) {
|
||||
// AI 助手虚拟用户没有会员记录,取自定义昵称或默认名称
|
||||
$umengTitle = ($msg->msg['nickname'] ?? '') ?: Doo::translate('AI 助手');
|
||||
} else {
|
||||
$umengTitle = User::userid2nickname($msg->userid);
|
||||
}
|
||||
$umengBody = WebSocketDialogMsg::previewMsg($msg);
|
||||
if ($dialog->type == 'group') {
|
||||
$umengBody = $umengTitle . ': ' . $umengBody;
|
||||
|
||||
347
bin/version.js
vendored
347
bin/version.js
vendored
@@ -1,347 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require("path");
|
||||
const exec = require('child_process').exec;
|
||||
let ProxyAgent = null;
|
||||
try {
|
||||
ProxyAgent = require("undici").ProxyAgent;
|
||||
} catch (error) {
|
||||
ProxyAgent = null;
|
||||
}
|
||||
const packageFile = path.resolve(process.cwd(), "package.json");
|
||||
const changeFile = path.resolve(process.cwd(), "CHANGELOG.md");
|
||||
|
||||
const verOffset = 6394; // 版本号偏移量
|
||||
const codeOffset = 34; // 代码版本号偏移量
|
||||
|
||||
const envFilePath = path.resolve(process.cwd(), ".env");
|
||||
const defaultAiSystemPrompt = "你是一位软件发布日志编辑专家。请产出 Markdown 更新日志,面向普通用户,以通俗友好的简体中文描述更新带来的直接好处,避免技术术语。所有章节标题必须以 `### ` 开头并保持英文 Title Case(例如 `### Features`、`### Bug Fixes`、`### Performance`、`### Documentation` 等)。每个章节内的条目按用户价值和影响范围排序,将更重要、影响更广的更新放在前面。";
|
||||
const defaultOpenAiEndpoint = "https://api.openai.com/v1/chat/completions";
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
content.split(/\r?\n/).forEach(rawLine => {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
return;
|
||||
}
|
||||
const equalsIndex = line.indexOf("=");
|
||||
if (equalsIndex === -1) {
|
||||
return;
|
||||
}
|
||||
let key = line.slice(0, equalsIndex).trim();
|
||||
if (key.startsWith("export ")) {
|
||||
key = key.slice(7).trim();
|
||||
}
|
||||
let value = line.slice(equalsIndex + 1).trim();
|
||||
if (!value) {
|
||||
value = "";
|
||||
}
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
} else {
|
||||
const commentIndex = value.indexOf(" #");
|
||||
if (commentIndex !== -1) {
|
||||
value = value.slice(0, commentIndex).trim();
|
||||
}
|
||||
}
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
loadEnvFile(envFilePath);
|
||||
|
||||
function resolveApiEndpoint(candidate) {
|
||||
const source = (candidate || "").trim();
|
||||
if (!source) {
|
||||
return defaultOpenAiEndpoint;
|
||||
}
|
||||
if (/\/chat\/completions(\?|$)/.test(source)) {
|
||||
return source;
|
||||
}
|
||||
const normalized = source.replace(/\/+$/, "");
|
||||
if (/\/v\d+$/i.test(normalized)) {
|
||||
return `${normalized}/chat/completions`;
|
||||
}
|
||||
return `${normalized}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
function loadSocksProxyAgent(proxyUrl) {
|
||||
try {
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
return new SocksProxyAgent(proxyUrl);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'MODULE_NOT_FOUND') {
|
||||
console.warn("检测到 SOCKS 代理,但未安装 socks-proxy-agent,请运行 `npm install --save-dev socks-proxy-agent` 后重试。");
|
||||
} else {
|
||||
console.warn(`无法初始化 SOCKS 代理: ${error?.message || error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createProxyDispatcher(proxyUrl) {
|
||||
if (!proxyUrl) {
|
||||
return null;
|
||||
}
|
||||
let parsedProtocol = '';
|
||||
try {
|
||||
parsedProtocol = new URL(proxyUrl).protocol.replace(':', '').toLowerCase();
|
||||
} catch (error) {
|
||||
console.warn(`代理地址无效 (${proxyUrl}): ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
if (parsedProtocol.startsWith('socks')) {
|
||||
return loadSocksProxyAgent(proxyUrl);
|
||||
}
|
||||
if (!ProxyAgent) {
|
||||
console.warn('未找到 undici.ProxyAgent,无法启用 HTTP 代理。');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new ProxyAgent(proxyUrl);
|
||||
} catch (error) {
|
||||
console.warn(`无法初始化代理 (${proxyUrl}): ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultUserPrompt(version, changelogSection) {
|
||||
return [
|
||||
"你是一位软件发布日志编辑专家。",
|
||||
"下面是一段通过 git 提交记录自动生成的更新日志文本。",
|
||||
"",
|
||||
"请将其整理为一份「面向普通用户、简洁概览风格」的 changelog,保持 Markdown 格式,包含以下结构:",
|
||||
"",
|
||||
`## [${version}]`,
|
||||
"",
|
||||
"### Features",
|
||||
"",
|
||||
"- ...",
|
||||
"",
|
||||
"### Bug Fixes",
|
||||
"",
|
||||
"- ...",
|
||||
"",
|
||||
"### Performance",
|
||||
"",
|
||||
"- ...",
|
||||
"",
|
||||
"**要求:**",
|
||||
"1. 删除技术性或重复的细节,合并相似项。",
|
||||
"2. 语句自然简洁,用简体中文描述。",
|
||||
"3. 使用贴近日常的词汇,突出更新对普通用户的直接价值,避免开发或管理术语(如\"refactor\"、\"merge branch\"、\"commit lint\")。",
|
||||
"4. 小节标题必须以 `### ` 开头并保持英文 Title Case(例如 `### Features`、`### Bug Fixes`、`### Performance`、`### Documentation`、`### Security`、`### Miscellaneous` 等),不得翻译成中文。",
|
||||
"5. 每个小节内的条目按用户价值和影响范围排序,将更重要、影响更广的更新放在前面。",
|
||||
"6. 若某个小节没有内容,请省略整段小节(包括标题)。",
|
||||
"7. 输出仅为 Markdown changelog 内容,不加其他解释。",
|
||||
"",
|
||||
"以下是原始日志:",
|
||||
"```markdown",
|
||||
changelogSection,
|
||||
"```"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function runExec(command) {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(command, { maxBuffer: 1024 * 1024 * 10 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stdout.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeDuplicateLines(log) {
|
||||
const logs = log.split(/(\n## \[.*?\])/);
|
||||
return logs.map(str => {
|
||||
const array = [];
|
||||
const items = str.split("\n");
|
||||
items.forEach(item => {
|
||||
if (/^-/.test(item)) {
|
||||
if (array.indexOf(item) === -1) {
|
||||
array.push(item);
|
||||
}
|
||||
} else {
|
||||
array.push(item);
|
||||
}
|
||||
});
|
||||
return array.join("\n");
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findSectionBounds(content, version) {
|
||||
const heading = `## [${version}]`;
|
||||
const start = content.indexOf(heading);
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
const nextHeadingIndex = content.indexOf("\n## [", start + heading.length);
|
||||
const end = nextHeadingIndex === -1 ? content.length : nextHeadingIndex;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function trimCliffOutput(rawOutput, version) {
|
||||
const markerIndex = rawOutput.indexOf("## [");
|
||||
if (markerIndex === -1) {
|
||||
return "";
|
||||
}
|
||||
return rawOutput
|
||||
.slice(markerIndex)
|
||||
.replace("## [Unreleased]", `## [${version}]`)
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildAiHeaders(apiUrl, apiKey) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const customHeader = process.env.CHANGELOG_AI_AUTH_HEADER;
|
||||
if (customHeader) {
|
||||
const separatorIndex = customHeader.indexOf(":");
|
||||
if (separatorIndex !== -1) {
|
||||
const headerName = customHeader.slice(0, separatorIndex).trim();
|
||||
const headerValue = customHeader.slice(separatorIndex + 1).trim();
|
||||
if (headerName && headerValue) {
|
||||
headers[headerName] = headerValue;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
if (apiUrl.includes("openai.azure.com")) {
|
||||
headers["api-key"] = apiKey;
|
||||
} else {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function enhanceWithAI(version, changelogSection) {
|
||||
const apiKey = (process.env.OPENAI_API_KEY || "").trim();
|
||||
if (!apiKey) {
|
||||
console.warn("未设置 OPENAI_API_KEY,跳过 AI 发布日志整理。");
|
||||
return changelogSection;
|
||||
}
|
||||
const proxyUrl = (process.env.OPENAI_PROXY_URL || "").trim();
|
||||
const explicitApiUrl = process.env.CHANGELOG_AI_URL || process.env.OPENAI_API_URL;
|
||||
const apiUrl = resolveApiEndpoint(explicitApiUrl);
|
||||
const dispatcher = createProxyDispatcher(proxyUrl);
|
||||
const model = process.env.CHANGELOG_AI_MODEL || process.env.OPENAI_API_MODEL || "gpt-4o-mini";
|
||||
const systemPrompt = process.env.CHANGELOG_AI_SYSTEM_PROMPT || defaultAiSystemPrompt;
|
||||
const userPrompt = process.env.CHANGELOG_AI_PROMPT || buildDefaultUserPrompt(version, changelogSection);
|
||||
|
||||
try {
|
||||
const requestInit = {
|
||||
method: "POST",
|
||||
headers: buildAiHeaders(apiUrl, apiKey),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt }
|
||||
],
|
||||
})
|
||||
};
|
||||
if (dispatcher) {
|
||||
requestInit.dispatcher = dispatcher;
|
||||
}
|
||||
const response = await fetch(apiUrl, requestInit);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`AI request failed: ${errorText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const aiText = data?.choices?.[0]?.message?.content?.trim();
|
||||
if (!aiText) {
|
||||
throw new Error("AI response did not contain content.");
|
||||
}
|
||||
return aiText
|
||||
.replace(/^\s*```markdown\s*/i, "")
|
||||
.replace(/\s*```\s*$/i, "")
|
||||
.trim();
|
||||
} catch (error) {
|
||||
console.warn("AI summarization failed, falling back to original section:", error.message);
|
||||
return changelogSection;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateLatestSection(version) {
|
||||
const rawOutput = await runExec('docker run -t --rm -v "$(pwd)":/app/ orhunp/git-cliff:1.3.0 --unreleased');
|
||||
const section = trimCliffOutput(rawOutput, version);
|
||||
if (!section.trim() || section.trim() === `## [${version}]`) {
|
||||
return "";
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function insertChangelogSection(existing, section, version) {
|
||||
const trimmedSection = section.trim();
|
||||
if (!trimmedSection) {
|
||||
return existing;
|
||||
}
|
||||
const bounds = findSectionBounds(existing, version);
|
||||
if (bounds) {
|
||||
return `${existing.slice(0, bounds.start)}${trimmedSection}\n\n${existing.slice(bounds.end).replace(/^(\n)+/, "")}`;
|
||||
}
|
||||
const insertIndex = existing.indexOf("\n## [");
|
||||
if (insertIndex === -1) {
|
||||
return `${existing.trimEnd()}\n\n${trimmedSection}\n`;
|
||||
}
|
||||
const head = existing.slice(0, insertIndex).trimEnd();
|
||||
const tail = existing.slice(insertIndex).replace(/^(\n)+/, "");
|
||||
return `${head}\n\n${trimmedSection}\n\n${tail}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const verCountRaw = await runExec("git rev-list --count HEAD");
|
||||
const codeCountRaw = await runExec("git tag --merged pro -l 'v*' | wc -l");
|
||||
const verCount = verCountRaw.trim();
|
||||
const codeCount = codeCountRaw.trim();
|
||||
|
||||
const num = verOffset + parseInt(verCount, 10);
|
||||
if (Number.isNaN(num) || Math.floor(num % 100) < 0) {
|
||||
throw new Error(`get version error ${verCount}`);
|
||||
}
|
||||
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
|
||||
const codeVersion = codeOffset + parseInt(codeCount, 10);
|
||||
|
||||
let packageContent = fs.readFileSync(packageFile, "utf8");
|
||||
packageContent = packageContent.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
|
||||
packageContent = packageContent.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
|
||||
fs.writeFileSync(packageFile, packageContent, "utf8");
|
||||
|
||||
console.log("New version: " + version);
|
||||
console.log("New code verson: " + codeVersion);
|
||||
|
||||
if (!fs.existsSync(changeFile)) {
|
||||
throw new Error("Change file does not exist");
|
||||
}
|
||||
|
||||
const latestSection = await generateLatestSection(version);
|
||||
if (!latestSection) {
|
||||
console.log("No new changelog entries detected.");
|
||||
return;
|
||||
}
|
||||
|
||||
const aiSection = await enhanceWithAI(version, latestSection);
|
||||
|
||||
const changelogContent = fs.readFileSync(changeFile, "utf8");
|
||||
const mergedContent = insertChangelogSection(changelogContent, aiSection, version);
|
||||
const dedupedContent = removeDuplicateLines(mergedContent);
|
||||
|
||||
fs.writeFileSync(changeFile, dedupedContent.trimEnd() + "\n", "utf8");
|
||||
console.log("Log file updated: CHANGELOG.md");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
60
cliff.toml
60
cliff.toml
@@ -1,60 +0,0 @@
|
||||
# configuration file for git-cliff (0.1.0)
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog\n
|
||||
All notable changes to this project will be documented in this file.\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://tera.netlify.app/docs/#introduction
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}]
|
||||
{% else %}\
|
||||
## [Unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
# remove the leading and trailing whitespace from the template
|
||||
trim = true
|
||||
# changelog footer
|
||||
footer = """
|
||||
"""
|
||||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = true
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features"},
|
||||
{ message = "^fix", group = "Bug Fixes"},
|
||||
{ message = "^doc", group = "Documentation"},
|
||||
{ message = "^perf", group = "Performance"},
|
||||
{ message = "^pref", group = "Performance"},
|
||||
{ message = "^refactor", group = "Refactor"},
|
||||
{ message = "^style", group = "Styling"},
|
||||
{ message = "^test", group = "Testing"},
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true},
|
||||
{ message = "^chore", group = "Miscellaneous Tasks"},
|
||||
{ body = ".*security", group = "Security"},
|
||||
]
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = true
|
||||
# glob pattern for matching git tags
|
||||
tag_pattern = "v[0-9]*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
# regex for ignoring tags
|
||||
ignore_tags = ""
|
||||
# sort the tags chronologically
|
||||
date_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "newest"
|
||||
49
cmd
49
cmd
@@ -391,8 +391,13 @@ env_set() {
|
||||
local val=$2
|
||||
local exist=`cat ${WORK_DIR}/.env | grep "^$key="`
|
||||
if [ -z "$exist" ]; then
|
||||
echo "" >> $WORK_DIR/.env
|
||||
echo "$key=$val" >> $WORK_DIR/.env
|
||||
else
|
||||
# 值未变化则直接返回,避免无谓重写 .env(重写会改 mtime,触发 vite 全量重启/前端刷新)
|
||||
if [[ "$(env_get "$key")" == "$val" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ `uname` == 'Linux' ]]; then
|
||||
sed -i "/^${key}=/c\\${key}=${val}" ${WORK_DIR}/.env
|
||||
else
|
||||
@@ -501,10 +506,36 @@ DooTask 管理脚本
|
||||
EOF
|
||||
}
|
||||
|
||||
# 检测APP_ID是否与其他实例冲突
|
||||
check_instance() {
|
||||
local app_id=$(env_get APP_ID)
|
||||
local container_name="dootask-php-${app_id}"
|
||||
local mount_path=$(docker inspect "$container_name" --format '{{range .Mounts}}{{if eq .Destination "/var/www"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
|
||||
if [[ -n "$mount_path" ]] && [[ "$mount_path" != "$WORK_DIR" ]]; then
|
||||
error "APP_ID(${app_id})已被其他实例使用:${mount_path}"
|
||||
error "请先清空 .env 中的 APP_ID 和 APP_IPPR 再重新安装"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检测端口是否被占用
|
||||
# 参数1: 端口号, 参数2: 当前端口号(可选,相同则跳过检测)
|
||||
check_port() {
|
||||
local port=$1
|
||||
local current_port=$2
|
||||
if [[ "$port" -gt 0 ]] && [[ "$port" != "$current_port" ]]; then
|
||||
if ! docker run --rm -p "${port}:80" --entrypoint true nginx:alpine 2>/dev/null; then
|
||||
error "端口 ${port} 已被占用,请指定其他端口"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 安装函数
|
||||
handle_install() {
|
||||
check_sudo
|
||||
|
||||
check_instance
|
||||
|
||||
local relock=$(arg_get relock)
|
||||
local port=$(arg_get port)
|
||||
|
||||
@@ -561,8 +592,17 @@ handle_install() {
|
||||
done
|
||||
|
||||
# 设置端口
|
||||
local old_port=$(env_get APP_PORT)
|
||||
[[ "$port" -gt 0 ]] && env_set APP_PORT "$port"
|
||||
|
||||
# 检测端口占用(首次安装或端口变更时)
|
||||
local new_port=$(env_get APP_PORT)
|
||||
if [ -z "$(docker_name nginx)" ] || [[ "$new_port" != "$old_port" ]]; then
|
||||
check_port "$new_port"
|
||||
local ssl_port=$(env_get APP_SSL_PORT)
|
||||
check_port "$ssl_port"
|
||||
fi
|
||||
|
||||
# 启动PHP容器
|
||||
$COMPOSE up php -d
|
||||
|
||||
@@ -763,6 +803,7 @@ case "$1" in
|
||||
;;
|
||||
"port")
|
||||
shift 1
|
||||
check_port "$1" "$(env_get APP_PORT)"
|
||||
env_set APP_PORT "$1"
|
||||
$COMPOSE up -d
|
||||
success "修改成功"
|
||||
@@ -841,7 +882,7 @@ case "$1" in
|
||||
else
|
||||
https_auto
|
||||
fi
|
||||
restart_php
|
||||
$COMPOSE up -d
|
||||
;;
|
||||
"artisan")
|
||||
shift 1
|
||||
@@ -890,10 +931,6 @@ case "$1" in
|
||||
container_exec php "php app/Models/clearHelper.php"
|
||||
container_exec php "php artisan ide-helper:models -W"
|
||||
;;
|
||||
"translate")
|
||||
shift 1
|
||||
container_exec php "cd /var/www/language && php translate.php"
|
||||
;;
|
||||
"restart")
|
||||
shift 1
|
||||
$COMPOSE stop "$@"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateManticoreSyncFailuresTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('manticore_sync_failures')) {
|
||||
return;
|
||||
}
|
||||
Schema::create('manticore_sync_failures', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('data_type', 20)->comment('数据类型: msg/file/task/project/user');
|
||||
$table->bigInteger('data_id')->comment('数据ID');
|
||||
$table->string('action', 20)->comment('操作类型: sync/delete');
|
||||
$table->string('error_message', 500)->nullable()->comment('错误信息');
|
||||
$table->integer('retry_count')->default(0)->comment('重试次数');
|
||||
$table->timestamp('last_retry_at')->nullable()->comment('最后重试时间');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['data_type', 'data_id', 'action'], 'uk_type_id_action');
|
||||
$table->index(['last_retry_at', 'retry_count'], 'idx_retry');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('manticore_sync_failures');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateProjectTaskAiEventsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('project_task_ai_events')) {
|
||||
return;
|
||||
}
|
||||
Schema::create('project_task_ai_events', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('task_id')->comment('任务ID');
|
||||
$table->string('event_type', 50)->comment('事件类型: description/subtasks/assignee/similar');
|
||||
$table->string('status', 20)->default('pending')->comment('状态: pending/processing/completed/failed/skipped');
|
||||
$table->tinyInteger('retry_count')->unsigned()->default(0)->comment('重试次数');
|
||||
$table->json('result')->nullable()->comment('执行结果');
|
||||
$table->text('error')->nullable()->comment('错误信息');
|
||||
$table->bigInteger('msg_id')->nullable()->default(0)->comment('消息ID');
|
||||
$table->timestamp('executed_at')->nullable()->comment('执行时间');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['task_id', 'event_type'], 'uk_task_event');
|
||||
$table->index('status', 'idx_status');
|
||||
$table->index('created_at', 'idx_created');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('project_task_ai_events');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddAiAutoAnalyzeToProjectsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->string('ai_auto_analyze', 20)->default('open')->after('archive_days');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropColumn('ai_auto_analyze');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateAiAssistantSessionsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('ai_assistant_sessions')) {
|
||||
return;
|
||||
}
|
||||
Schema::create('ai_assistant_sessions', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('userid')->default(0)->comment('用户ID');
|
||||
$table->string('session_key', 100)->default('')->comment('场景分类key');
|
||||
$table->string('session_id', 100)->default('')->comment('前端生成的会话ID');
|
||||
$table->string('scene_key', 200)->default('')->comment('具体场景标识');
|
||||
$table->string('title', 255)->default('')->comment('会话标题');
|
||||
$table->longText('data')->nullable()->comment('responses JSON');
|
||||
$table->longText('images')->nullable()->comment('图片映射 {imageId: relativePath}');
|
||||
$table->timestamps();
|
||||
$table->index('userid', 'idx_userid');
|
||||
$table->unique(['userid', 'session_key', 'session_id'], 'uk_user_session');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('ai_assistant_sessions');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddRoleToWebSocketDialogUsers extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('web_socket_dialog_users', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('web_socket_dialog_users', 'role')) {
|
||||
$table->tinyInteger('role')->default(0)->after('userid')
|
||||
->comment('0=普通成员 1=群主 2=群管理员');
|
||||
$table->index(['dialog_id', 'role'], 'idx_dialog_role');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('web_socket_dialog_users', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('web_socket_dialog_users', 'role')) {
|
||||
$table->dropIndex('idx_dialog_role');
|
||||
$table->dropColumn('role');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillDialogOwnerRole extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
// 把每个群里 userid = web_socket_dialogs.owner_id 的成员记录设为 role=1(主群主)
|
||||
// 幂等:仅当 role=0 时才更新
|
||||
DB::statement("
|
||||
UPDATE {$prefix}web_socket_dialog_users du
|
||||
INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id
|
||||
SET du.role = 1
|
||||
WHERE d.owner_id > 0
|
||||
AND du.userid = d.owner_id
|
||||
AND du.role = 0
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
// 回滚:把 role=1 的记录全部回到 role=0
|
||||
DB::statement("UPDATE {$prefix}web_socket_dialog_users SET role = 0 WHERE role = 1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateUserDepartmentOwnersTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (!Schema::hasTable('user_department_owners')) {
|
||||
Schema::create('user_department_owners', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedBigInteger('department_id')->comment('部门ID');
|
||||
$table->unsignedBigInteger('userid')->comment('部门管理员 userid');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->unique(['department_id', 'userid'], 'uniq_dept_user');
|
||||
$table->index('userid', 'idx_userid');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
if (Schema::hasTable('user_department_owners')) {
|
||||
Schema::dropIfExists('user_department_owners');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillProjectDialogPrimaryOwner extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 修复历史项目群聊未登记群主的问题:
|
||||
* - 早期 Project::addProject 调 createGroup 时未传 owner_id 第 4 参
|
||||
* (在 commit 3a9001e09 才补上),导致老项目群 dialogs.owner_id = 0
|
||||
* - 这些群也因此被 2026_04_30_000002_backfill_dialog_owner_role 跳过
|
||||
* (那条迁移要求 owner_id > 0),主负责人那条 dialog_users.role 仍为 0
|
||||
*
|
||||
* 本迁移仅处理 group_type = 'project' 且未软删的项目:
|
||||
* (a) dialogs.owner_id = 0 → 按 project_users.owner=1 回填
|
||||
* (b) 同一批群里主负责人那条 dialog_users.role = 0 → 设为 1
|
||||
*
|
||||
* 全部带幂等条件,可重跑。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
// (a) 回填 dialogs.owner_id
|
||||
DB::statement("
|
||||
UPDATE {$prefix}web_socket_dialogs d
|
||||
INNER JOIN {$prefix}projects p ON p.dialog_id = d.id
|
||||
INNER JOIN {$prefix}project_users pu ON pu.project_id = p.id AND pu.owner = 1
|
||||
SET d.owner_id = pu.userid
|
||||
WHERE d.owner_id = 0
|
||||
AND d.group_type = 'project'
|
||||
AND p.deleted_at IS NULL
|
||||
");
|
||||
|
||||
// (b) 把这些项目群里主负责人那条 dialog_users.role 设为 1
|
||||
// 不依赖 (a) 的结果,直接按 project_users.owner=1 反查,幂等条件 du.role=0
|
||||
DB::statement("
|
||||
UPDATE {$prefix}web_socket_dialog_users du
|
||||
INNER JOIN {$prefix}projects p ON p.dialog_id = du.dialog_id
|
||||
INNER JOIN {$prefix}project_users pu
|
||||
ON pu.project_id = p.id
|
||||
AND pu.userid = du.userid
|
||||
AND pu.owner = 1
|
||||
SET du.role = 1
|
||||
WHERE du.role = 0
|
||||
AND p.deleted_at IS NULL
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* 数据回填类迁移不提供精确回滚——回滚会丢失原本就正确的数据。
|
||||
* 如需重置,请手动操作。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillDialogRoleConsistency extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 兜底修复 role/owner_id 一致性:
|
||||
* - 部门群 owner_id 与 user_departments.owner_userid 对齐
|
||||
* - owner_id > 0 的群确保 owner 成员存在且 role=1
|
||||
* - 同一群中非 owner 的 role=1 降为普通成员(不影响 role=2 管理员)
|
||||
* - 历史 owner_id=0 的普通用户群按最早的非机器人成员回填群主
|
||||
* - 清理部门负责人残留的 user_department_owners 记录
|
||||
*
|
||||
* 全部语句带幂等条件,可重复运行。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
// 1) 部门群 owner_id 以 user_departments.owner_userid 为准
|
||||
DB::statement("\n UPDATE {$prefix}web_socket_dialogs d\n INNER JOIN {$prefix}user_departments ud ON ud.dialog_id = d.id\n SET d.owner_id = ud.owner_userid\n WHERE d.type = 'group'\n AND d.group_type = 'department'\n AND d.deleted_at IS NULL\n AND ud.owner_userid > 0\n AND d.owner_id != ud.owner_userid\n ");
|
||||
|
||||
// 2) 历史普通用户群 owner_id=0:按最早加入的非机器人成员回填群主
|
||||
DB::statement("\n UPDATE {$prefix}web_socket_dialogs d\n INNER JOIN (\n SELECT du.dialog_id, MIN(du.id) AS min_id\n FROM {$prefix}web_socket_dialog_users du\n WHERE du.userid > 0 AND du.bot = 0\n GROUP BY du.dialog_id\n ) first_du ON first_du.dialog_id = d.id\n INNER JOIN {$prefix}web_socket_dialog_users owner_du ON owner_du.id = first_du.min_id\n SET d.owner_id = owner_du.userid\n WHERE d.type = 'group'\n AND d.group_type = 'user'\n AND d.deleted_at IS NULL\n AND d.owner_id = 0\n ");
|
||||
|
||||
// 3) owner_id > 0 但 owner 不在群成员表时,补一条成员记录(仅补真实存在的用户)
|
||||
DB::statement("\n INSERT INTO {$prefix}web_socket_dialog_users\n (dialog_id, userid, role, bot, important, last_at, created_at, updated_at)\n SELECT\n d.id,\n d.owner_id,\n 1,\n COALESCE(u.bot, 0),\n CASE WHEN d.group_type IN ('user', 'all') THEN 0 ELSE 1 END,\n CASE WHEN d.group_type IN ('user', 'department', 'all') THEN NOW(3) ELSE NULL END,\n NOW(3),\n NOW(3)\n FROM {$prefix}web_socket_dialogs d\n INNER JOIN {$prefix}users u ON u.userid = d.owner_id\n LEFT JOIN {$prefix}web_socket_dialog_users du\n ON du.dialog_id = d.id AND du.userid = d.owner_id\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.id IS NULL\n ");
|
||||
|
||||
// 4) owner 成员设为 role=1;业务群 owner 同时保持 important=1
|
||||
DB::statement("\n UPDATE {$prefix}web_socket_dialog_users du\n INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id\n SET du.role = 1,\n du.important = CASE WHEN d.group_type IN ('user', 'all') THEN du.important ELSE 1 END\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.userid = d.owner_id\n AND (du.role != 1 OR (d.group_type NOT IN ('user', 'all') AND du.important != 1))\n ");
|
||||
|
||||
// 5) 同一群里非 owner 的 role=1 降为普通成员,避免多个主群主
|
||||
DB::statement("\n UPDATE {$prefix}web_socket_dialog_users du\n INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id\n SET du.role = 0\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.role = 1\n AND du.userid != d.owner_id\n ");
|
||||
|
||||
// 6) 部门负责人不能同时残留在部门管理员表
|
||||
DB::statement("\n DELETE udo FROM {$prefix}user_department_owners udo\n INNER JOIN {$prefix}user_departments ud ON ud.id = udo.department_id\n WHERE ud.owner_userid = udo.userid\n ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* 数据修复类迁移不提供精确回滚,避免破坏已校准的数据。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddUseCountToProjectTaskTemplates extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('project_task_templates', function (Blueprint $table) {
|
||||
$table->unsignedInteger('use_count')->default(0)->after('is_default')->comment('累计使用次数');
|
||||
$table->timestamp('last_used_at')->nullable()->after('use_count')->comment('最近一次使用时间');
|
||||
$table->index(['use_count', 'last_used_at'], 'idx_template_usage');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('project_task_templates', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_template_usage');
|
||||
$table->dropColumn(['use_count', 'last_used_at']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddTaskTemplateShareToProjectsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('projects', 'task_template_share')) {
|
||||
$table->string('task_template_share', 20)->default('open')->after('ai_auto_analyze')->comment('共享模板开关');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('projects', 'task_template_share')) {
|
||||
$table->dropColumn('task_template_share');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddDepartmentOwnerViewToProjectsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('projects', 'department_owner_view')) {
|
||||
$table->string('department_owner_view', 20)->default('open')->after('task_template_share')->comment('部门负责人视角可见开关');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('projects', 'department_owner_view')) {
|
||||
$table->dropColumn('department_owner_view');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddRemindToWebSocketDialogMsgTodos extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('web_socket_dialog_msg_todos', function (Blueprint $table) {
|
||||
$table->timestamp('remind_at')->nullable()->comment('提醒时间')->after('done_at');
|
||||
$table->timestamp('reminded_at')->nullable()->comment('已提醒时间')->after('remind_at');
|
||||
$table->index(['remind_at', 'reminded_at', 'done_at'], 'idx_todo_remind');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('web_socket_dialog_msg_todos', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_todo_remind');
|
||||
$table->dropColumn(['remind_at', 'reminded_at']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,6 @@ class WebSocketDialogsTableSeeder extends Seeder
|
||||
User::botGetOrCreate('ai-claude');
|
||||
|
||||
$userids = User::whereBot(0)->whereNull('disable_at')->pluck('userid')->toArray();
|
||||
WebSocketDialog::createGroup("全体成员 All members", $userids, 'all');
|
||||
WebSocketDialog::createGroup(WebSocketDialog::ALL_GROUP_DEFAULT_NAME, $userids, 'all');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,10 @@ services:
|
||||
ports:
|
||||
- "${APP_PORT}:80"
|
||||
- "${APP_SSL_PORT:-0}:443"
|
||||
environment:
|
||||
APP_SCHEME: "${APP_SCHEME:-auto}"
|
||||
volumes:
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./docker/nginx/default.conf:/etc/nginx/templates/default.conf.template
|
||||
- ./:/var/www
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/health"]
|
||||
@@ -96,7 +98,7 @@ services:
|
||||
appstore:
|
||||
container_name: "dootask-appstore-${APP_ID}"
|
||||
privileged: true
|
||||
image: "dootask/appstore:0.3.9"
|
||||
image: "dootask/appstore:0.4.3"
|
||||
volumes:
|
||||
- shared_data:/usr/share/dootask
|
||||
- ${HOST_DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
|
||||
@@ -1,23 +1,47 @@
|
||||
map $host $app_scheme_raw {
|
||||
default "${APP_SCHEME}";
|
||||
}
|
||||
|
||||
map $app_scheme_raw $force_https {
|
||||
"https" 1;
|
||||
"on" 1;
|
||||
"ssl" 1;
|
||||
"1" 1;
|
||||
"true" 1;
|
||||
"yes" 1;
|
||||
default 0;
|
||||
}
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
"" close;
|
||||
}
|
||||
|
||||
map $http_host $this_host {
|
||||
"" $host;
|
||||
"" $host;
|
||||
default $http_host;
|
||||
}
|
||||
map $http_x_forwarded_proto $the_scheme {
|
||||
default $http_x_forwarded_proto;
|
||||
"" $scheme;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_host $the_host {
|
||||
"" $this_host;
|
||||
default $http_x_forwarded_host;
|
||||
"" $this_host;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_proto $auto_scheme {
|
||||
"" $scheme;
|
||||
default $http_x_forwarded_proto;
|
||||
}
|
||||
|
||||
map $force_https $the_scheme {
|
||||
1 https;
|
||||
default $auto_scheme;
|
||||
}
|
||||
|
||||
upstream service {
|
||||
server php:20000 weight=5 max_fails=3 fail_timeout=30s;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
|
||||
395
electron/build.js
vendored
395
electron/build.js
vendored
@@ -6,13 +6,14 @@ const child_process = require('child_process');
|
||||
const ora = require('ora');
|
||||
const yauzl = require('yauzl');
|
||||
const axios = require('axios');
|
||||
const FormData =require('form-data');
|
||||
const tar = require('tar');
|
||||
const utils = require('./lib/utils');
|
||||
const r2 = require('./lib/r2');
|
||||
const { buildReleaseIndex } = require('./lib/release-index');
|
||||
const config = require('../package.json')
|
||||
const env = require('dotenv').config({ path: './.env' })
|
||||
const argv = process.argv;
|
||||
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY, PUBLISH_KEY} = process.env;
|
||||
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY} = process.env;
|
||||
|
||||
const electronDir = path.resolve(__dirname, "public");
|
||||
const nativeCachePath = path.resolve(__dirname, ".native");
|
||||
@@ -25,6 +26,9 @@ const architectures = ["arm64", "x64"];
|
||||
let buildChecked = false,
|
||||
updaterChecked = false;
|
||||
|
||||
const shellQuote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
const elapsedSeconds = (startTime) => `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
|
||||
|
||||
/**
|
||||
* 检测并下载更新器
|
||||
*/
|
||||
@@ -308,234 +312,23 @@ function changeLog() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装 axios 自动重试
|
||||
* @param data // {axios: object{}, onRetry: function, retryNumber: number}
|
||||
* @returns {Promise<unknown>}
|
||||
* 上传单个文件到 R2 的 draft/<version>/ 目录(带进度/spinner)
|
||||
*/
|
||||
function axiosAutoTry(data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios(data.axios).then(result => {
|
||||
resolve(result)
|
||||
}).catch(error => {
|
||||
if (typeof data.retryNumber == 'number' && data.retryNumber > 0) {
|
||||
data.retryNumber--;
|
||||
if (typeof data.onRetry === "function") {
|
||||
data.onRetry()
|
||||
}
|
||||
if (error.code == 'ECONNABORTED' || error.code == 'ECONNRESET') {
|
||||
// 中止,超时
|
||||
return resolve(axiosAutoTry(data))
|
||||
} else {
|
||||
if (error.response && error.response.status == 407) {
|
||||
// 代理407
|
||||
return setTimeout(v => {
|
||||
resolve(axiosAutoTry(data))
|
||||
}, 500 + Math.random() * 500)
|
||||
} else if (error.response && error.response.status == 503) {
|
||||
// 服务器异常
|
||||
return setTimeout(v => {
|
||||
resolve(axiosAutoTry(data))
|
||||
}, 1000 + Math.random() * 500)
|
||||
} else if (error.response && error.response.status == 429) {
|
||||
// 并发超过限制
|
||||
return setTimeout(v => {
|
||||
resolve(axiosAutoTry(data))
|
||||
}, 1000 + Math.random() * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传app应用
|
||||
* @param url
|
||||
*/
|
||||
function androidUpload(url) {
|
||||
if (!PUBLISH_KEY) {
|
||||
console.error("缺少 PUBLISH_KEY 环境变量");
|
||||
process.exit()
|
||||
async function uploadDraftFile(client, localFile, version) {
|
||||
const filename = path.basename(localFile);
|
||||
const key = `draft/${version}/${filename}`;
|
||||
const startTime = Date.now();
|
||||
const spinner = ora(`Upload [0%] ${filename}`).start();
|
||||
try {
|
||||
await r2.uploadFile(client, localFile, key, (loaded, total) => {
|
||||
const pct = Math.min(99, Math.round((loaded / total) * 100)) + '%';
|
||||
spinner.text = `Upload [${pct}] ${filename}`;
|
||||
});
|
||||
} catch (error) {
|
||||
spinner.fail(`Upload [fail] ${filename} (${elapsedSeconds(startTime)}): ${error.message || error}`);
|
||||
throw error;
|
||||
}
|
||||
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
|
||||
if (!fs.existsSync(releaseDir)) {
|
||||
console.error("发布文件未找到");
|
||||
process.exit()
|
||||
}
|
||||
fs.readdir(releaseDir, async (err, files) => {
|
||||
if (err) {
|
||||
console.warn(err)
|
||||
} else {
|
||||
const uploadOras = {}
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(releaseDir, filename)
|
||||
if (/\.apk$/.test(filename) && fs.existsSync(localFile)) {
|
||||
const fileStat = fs.statSync(localFile)
|
||||
if (fileStat.isFile()) {
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
const formData = new FormData()
|
||||
formData.append("file", fs.createReadStream(localFile));
|
||||
formData.append("action", "draft");
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: url,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Publish-Version': config.version,
|
||||
'Publish-Key': PUBLISH_KEY,
|
||||
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
|
||||
},
|
||||
onUploadProgress: progress => {
|
||||
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
|
||||
uploadOras[filename].text = `Upload [${complete}] ${filename}`
|
||||
},
|
||||
},
|
||||
onRetry: _ => {
|
||||
uploadOras[filename].warn(`Upload [retry] ${filename}`)
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200) {
|
||||
uploadOras[filename].fail(`Upload [fail:${status}] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (!utils.isJson(data)) {
|
||||
uploadOras[filename].fail(`Upload [fail:not json] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (data.ret !== 1) {
|
||||
uploadOras[filename].fail(`Upload [fail:ret ${data.ret}] ${filename}`)
|
||||
return
|
||||
}
|
||||
uploadOras[filename].succeed(`Upload [100%] ${filename}`)
|
||||
}).catch(_ => {
|
||||
uploadOras[filename].fail(`Upload [fail] ${filename}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知发布完成
|
||||
* @param url
|
||||
*/
|
||||
async function published(url) {
|
||||
if (!PUBLISH_KEY) {
|
||||
console.error("缺少 PUBLISH_KEY 环境变量");
|
||||
process.exit()
|
||||
}
|
||||
const spinner = ora('完成发布...').start();
|
||||
const formData = new FormData()
|
||||
formData.append("action", "release");
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: url,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Publish-Version': config.version,
|
||||
'Publish-Key': PUBLISH_KEY,
|
||||
},
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200) {
|
||||
spinner.fail('发布失败, status: ' + status)
|
||||
return
|
||||
}
|
||||
if (!utils.isJson(data)) {
|
||||
spinner.fail('发布失败, not json')
|
||||
return
|
||||
}
|
||||
if (data.ret !== 1) {
|
||||
spinner.fail(`发布失败, ${JSON.stringify(data)}`)
|
||||
return
|
||||
}
|
||||
spinner.succeed('发布完成')
|
||||
}).catch(_ => {
|
||||
spinner.fail('发布失败')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用发布
|
||||
* @param url
|
||||
* @param key
|
||||
* @param version
|
||||
* @param output
|
||||
*/
|
||||
function genericPublish({url, key, version, output}) {
|
||||
if (!/https?:\/\//i.test(url)) {
|
||||
console.warn("发布地址无效: " + url)
|
||||
return
|
||||
}
|
||||
const filePath = path.resolve(__dirname, output)
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn("发布文件未找到: " + filePath)
|
||||
return
|
||||
}
|
||||
fs.readdir(filePath, async (err, files) => {
|
||||
if (err) {
|
||||
console.warn(err)
|
||||
} else {
|
||||
const uploadOras = {}
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(filePath, filename)
|
||||
if (fs.existsSync(localFile)) {
|
||||
const fileStat = fs.statSync(localFile)
|
||||
if (fileStat.isFile()) {
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
const formData = new FormData()
|
||||
formData.append("file", fs.createReadStream(localFile));
|
||||
formData.append("action", "draft");
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: url,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Publish-Version': version,
|
||||
'Publish-Key': key,
|
||||
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
|
||||
},
|
||||
onUploadProgress: progress => {
|
||||
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
|
||||
uploadOras[filename].text = `Upload [${complete}] ${filename}`
|
||||
},
|
||||
},
|
||||
onRetry: _ => {
|
||||
uploadOras[filename].warn(`Upload [retry] ${filename}`)
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200) {
|
||||
uploadOras[filename].fail(`Upload [fail:${status}] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (!utils.isJson(data)) {
|
||||
uploadOras[filename].fail(`Upload [fail:not json] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (data.ret !== 1) {
|
||||
uploadOras[filename].fail(`Upload [fail:ret ${data.ret}] ${filename}`)
|
||||
return
|
||||
}
|
||||
uploadOras[filename].succeed(`Upload [100%] ${filename}`)
|
||||
}).catch(_ => {
|
||||
uploadOras[filename].fail(`Upload [fail] ${filename}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
spinner.succeed(`Upload [100%] ${filename} (${elapsedSeconds(startTime)})`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,8 +402,8 @@ async function startBuild(data) {
|
||||
//
|
||||
if (data.id === 'app') {
|
||||
const eeuiDir = path.resolve(__dirname, "../resources/mobile");
|
||||
const eeuiRun = `docker run --rm -v ${eeuiDir}:/work -w /work kuaifan/eeui-cli:0.0.1`
|
||||
const publicDir = path.resolve(__dirname, "../resources/mobile/src/public");
|
||||
const containerName = `dootask-eeui-${Date.now()}-${process.pid}`;
|
||||
fse.removeSync(publicDir)
|
||||
fse.copySync(electronDir, publicDir)
|
||||
if (argv[3] === "publish") {
|
||||
@@ -628,10 +421,19 @@ async function startBuild(data) {
|
||||
fs.writeFileSync(xcconfigFile, xcconfigResult, 'utf8')
|
||||
}
|
||||
if (['build', 'publish'].includes(argv[3])) {
|
||||
if (!fs.existsSync(path.resolve(eeuiDir, "node_modules"))) {
|
||||
child_process.execSync(`${eeuiRun} npm install`, {stdio: "inherit", cwd: "resources/mobile"});
|
||||
child_process.execSync(
|
||||
`docker run -d --name ${containerName} -v ${shellQuote(eeuiDir)}:/work -w /work kuaifan/eeui-cli:0.0.1 sleep infinity`,
|
||||
{stdio: "ignore", cwd: "resources/mobile"}
|
||||
);
|
||||
try {
|
||||
if (!fs.existsSync(path.resolve(eeuiDir, "node_modules"))) {
|
||||
child_process.execSync(`docker exec ${containerName} npm install`, {stdio: "inherit", cwd: "resources/mobile"});
|
||||
}
|
||||
child_process.execSync(`docker exec ${containerName} node /work/scripts/patch-eeui-build.js`, {stdio: "inherit", cwd: "resources/mobile"});
|
||||
child_process.execSync(`docker exec ${containerName} eeui build --simple`, {stdio: "inherit", cwd: "resources/mobile"});
|
||||
} finally {
|
||||
child_process.execSync(`docker rm -f ${containerName}`, {stdio: "ignore", cwd: "resources/mobile"});
|
||||
}
|
||||
child_process.execSync(`${eeuiRun} eeui build --simple`, {stdio: "inherit", cwd: "resources/mobile"});
|
||||
} else {
|
||||
[
|
||||
path.resolve(publicDir, "../../platforms/ios/eeuiApp/bundlejs/eeui/public"),
|
||||
@@ -698,18 +500,24 @@ async function startBuild(data) {
|
||||
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
|
||||
child_process.execSync(`npm run ${platform}-publish`, {stdio: "inherit", cwd: "electron"});
|
||||
}
|
||||
// generic (build or publish)
|
||||
appConfig.build.publish = data.publish
|
||||
// generic (build or publish) —— 有 R2_PUBLIC_URL 时自动更新源指向 R2 release/
|
||||
appConfig.build.publish = r2.R2_PUBLIC_URL
|
||||
? { provider: 'generic', url: `${r2.R2_PUBLIC_URL.replace(/\/+$/, '')}/release` }
|
||||
: data.publish
|
||||
appConfig.build.directories.output = `${output}-generic`;
|
||||
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
|
||||
child_process.execSync(`npm run ${platform}`, {stdio: "inherit", cwd: "electron"});
|
||||
if (publish === true && PUBLISH_KEY) {
|
||||
genericPublish({
|
||||
url: appConfig.build.publish.url,
|
||||
key: PUBLISH_KEY,
|
||||
version: config.version,
|
||||
output: appConfig.build.directories.output
|
||||
})
|
||||
if (publish === true && r2.r2Configured()) {
|
||||
const client = r2.createR2Client()
|
||||
const outputDir = path.resolve(__dirname, appConfig.build.directories.output)
|
||||
if (fs.existsSync(outputDir)) {
|
||||
const files = fs.readdirSync(outputDir)
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(outputDir, filename)
|
||||
if (!fs.statSync(localFile).isFile()) continue
|
||||
await uploadDraftFile(client, localFile, config.version)
|
||||
}
|
||||
}
|
||||
}
|
||||
// package.json Recovery
|
||||
recoveryPackage(true)
|
||||
@@ -745,18 +553,101 @@ if (["dev"].includes(argv[2])) {
|
||||
}
|
||||
})
|
||||
} else if (["android-upload"].includes(argv[2])) {
|
||||
// 上传安卓文件(GitHub Actions)
|
||||
config.app.forEach(({publish}) => {
|
||||
if (publish.provider === 'generic') {
|
||||
androidUpload(publish.url)
|
||||
// 上传安卓文件到 R2 draft(GitHub Actions)
|
||||
(async () => {
|
||||
if (!r2.r2Configured()) {
|
||||
console.error("缺少 R2_* 环境变量(R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/R2_ENDPOINT/R2_BUCKET)")
|
||||
process.exit(1)
|
||||
}
|
||||
const client = r2.createR2Client()
|
||||
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
|
||||
if (!fs.existsSync(releaseDir)) {
|
||||
console.error("发布文件未找到")
|
||||
process.exit(1)
|
||||
}
|
||||
const files = fs.readdirSync(releaseDir)
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(releaseDir, filename)
|
||||
if (/\.apk$/.test(filename) && fs.existsSync(localFile) && fs.statSync(localFile).isFile()) {
|
||||
await uploadDraftFile(client, localFile, config.version)
|
||||
}
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error(err.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
} else if (["published"].includes(argv[2])) {
|
||||
// 发布完成(GitHub Actions)
|
||||
config.app.forEach(async ({publish}) => {
|
||||
if (publish.provider === 'generic') {
|
||||
await published(publish.url)
|
||||
} else if (["release"].includes(argv[2])) {
|
||||
// R2 内提升:draft/<version> → release/(当前版扁平,旧版归档 release/<prev>/)
|
||||
(async () => {
|
||||
if (!r2.r2Configured()) {
|
||||
console.error("缺少 R2_* 环境变量")
|
||||
process.exit(1)
|
||||
}
|
||||
const client = r2.createR2Client()
|
||||
const version = config.version
|
||||
const draftPrefix = `draft/${version}/`
|
||||
const draftKeys = await r2.listKeys(client, draftPrefix)
|
||||
if (!draftKeys.length) {
|
||||
console.error(`draft/${version}/ 为空,无法发布`)
|
||||
process.exit(1)
|
||||
}
|
||||
const names = draftKeys.map(k => k.slice(draftPrefix.length))
|
||||
|
||||
// 读 manifest 取上一发布版
|
||||
const manifest = JSON.parse(await r2.getText(client, 'manifest.json') || '{"draft":null,"release":null}')
|
||||
const prev = manifest.release
|
||||
|
||||
// 1. 归档上一版扁平文件 → release/<prev>/
|
||||
if (prev && prev !== version) {
|
||||
const prevRootKeys = await r2.listKeys(client, 'release/', '/')
|
||||
for (const key of prevRootKeys) {
|
||||
const name = key.slice('release/'.length)
|
||||
await r2.copyObject(client, key, `release/${prev}/${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 清空扁平根层(仅根层对象,版本归档子目录不动)
|
||||
const rootKeys = await r2.listKeys(client, 'release/', '/')
|
||||
await r2.deleteKeys(client, rootKeys)
|
||||
|
||||
// 3. 铺新扁平:安装包/blockmap/zip 先,latest*.yml 最后
|
||||
const ymls = names.filter(n => /\.ya?ml$/i.test(n))
|
||||
const others = names.filter(n => !/\.ya?ml$/i.test(n))
|
||||
for (const name of others) await r2.copyObject(client, `${draftPrefix}${name}`, `release/${name}`)
|
||||
for (const name of ymls) await r2.copyObject(client, `${draftPrefix}${name}`, `release/${name}`)
|
||||
|
||||
// 4. 下载索引
|
||||
const index = buildReleaseIndex(names)
|
||||
await r2.putText(client, 'release/index.json', JSON.stringify({ version, files: index }, null, 2))
|
||||
|
||||
// 5. 更新 manifest,清理 draft
|
||||
await r2.putText(client, 'manifest.json', JSON.stringify({ draft: null, release: version }, null, 2))
|
||||
await r2.deleteKeys(client, draftKeys)
|
||||
|
||||
console.log(`Release published: v${version}`)
|
||||
})().catch(err => {
|
||||
console.error(err.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
} else if (["upload-changelog"].includes(argv[2])) {
|
||||
// 上传 changelog 到 R2(GitHub Actions)
|
||||
(async () => {
|
||||
if (!r2.r2Configured()) {
|
||||
console.error("缺少 R2_* 环境变量")
|
||||
process.exit(1)
|
||||
}
|
||||
const changelogPath = path.resolve(__dirname, "../CHANGELOG.md")
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
console.error("CHANGELOG.md 未找到")
|
||||
process.exit(1)
|
||||
}
|
||||
const client = r2.createR2Client()
|
||||
const content = fs.readFileSync(changelogPath, 'utf8')
|
||||
await r2.putText(client, 'changelog.md', content)
|
||||
console.log('Changelog uploaded')
|
||||
})().catch(err => {
|
||||
console.error(err.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
} else if (["all", "win", "mac"].includes(argv[2])) {
|
||||
// 自动编译(GitHub Actions)
|
||||
@@ -907,8 +798,8 @@ if (["dev"].includes(argv[2])) {
|
||||
|
||||
// 发布判断环境变量
|
||||
if (answers.publish) {
|
||||
if (!PUBLISH_KEY && (!GITHUB_TOKEN || !utils.strExists(GITHUB_REPOSITORY, "/"))) {
|
||||
console.error("发布需要 PUBLISH_KEY 或 GitHub Token 和 Repository, 请检查环境变量!");
|
||||
if (!r2.r2Configured() && !(GITHUB_TOKEN && utils.strExists(GITHUB_REPOSITORY, "/"))) {
|
||||
console.error("发布需要 R2_* 或 GITHUB_TOKEN + GITHUB_REPOSITORY, 请检查环境变量!");
|
||||
process.exit()
|
||||
}
|
||||
}
|
||||
|
||||
8
electron/electron-down.js
vendored
8
electron/electron-down.js
vendored
@@ -2,7 +2,7 @@ const {BrowserWindow, screen, shell, ipcMain} = require('electron')
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const loger = require("electron-log");
|
||||
const {default: electronDl, download, CancelError} = require("@dootask/electron-dl");
|
||||
const {default: electronDl, download, CancelError, InterruptedError} = require("@dootask/electron-dl");
|
||||
const utils = require("./lib/utils");
|
||||
const {DownloadManager, DownloadStore} = require("./lib/download-manager");
|
||||
|
||||
@@ -116,10 +116,12 @@ async function createDownload(window_, url, options = {}) {
|
||||
try {
|
||||
return await download(window_, url, options);
|
||||
} catch (error) {
|
||||
// electron-dl rejects with CancelError when a download is cancelled; treat it as expected.
|
||||
// electron-dl rejects with CancelError/InterruptedError; treat them as expected.
|
||||
const isCancelError = (typeof CancelError === 'function' && error instanceof CancelError)
|
||||
|| error?.name === 'CancelError';
|
||||
if (!isCancelError) {
|
||||
const isInterruptedError = (typeof InterruptedError === 'function' && error instanceof InterruptedError)
|
||||
|| error?.name === 'InterruptedError';
|
||||
if (!isCancelError && !isInterruptedError) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
|
||||
2
electron/electron.js
vendored
2
electron/electron.js
vendored
@@ -1060,4 +1060,4 @@ ipcMain.on('updateQuitAndInstall', (event, args) => {
|
||||
//================================================================
|
||||
|
||||
onExport()
|
||||
onRenderer(mainWindow)
|
||||
onRenderer(() => mainWindow)
|
||||
|
||||
219
electron/lib/mcp.js
vendored
219
electron/lib/mcp.js
vendored
@@ -4,7 +4,7 @@
|
||||
* DooTask 的 Electron 客户端集成了 Model Context Protocol (MCP) 服务,
|
||||
* 允许 AI 助手(如 Claude)直接与 DooTask 任务进行交互。
|
||||
*
|
||||
* 提供的工具(共 27 个):
|
||||
* 提供的工具(共 29 个):
|
||||
*
|
||||
* === 用户管理 ===
|
||||
* - get_users_basic - 批量获取用户基础信息(1-50个),便于匹配负责人/协助人
|
||||
@@ -27,9 +27,10 @@
|
||||
* - update_project - 修改项目信息(名称、描述等)
|
||||
*
|
||||
* === 文件管理(个人文件系统) ===
|
||||
* - list_files - 浏览个人文件系统,获取指定文件夹下的文件和子文件夹列表
|
||||
* - search_files - 搜索用户文件系统中的文件(自己创建的和共享给自己的)
|
||||
* - get_file_detail - 获取文件详情,支持通过文件ID或分享码访问,返回 content_url 可配合 WebFetch 读取
|
||||
* - list_files - 浏览个人文件系统,获取指定文件夹下的文件和子文件夹列表
|
||||
* - search_files - 搜索用户文件系统中的文件(自己创建的和共享给自己的)
|
||||
* - get_file_detail - 获取文件详情,可选提取文本内容
|
||||
* - fetch_file_content - 通过文件路径获取文本内容
|
||||
*
|
||||
* === 工作报告 ===
|
||||
* - list_received_reports - 获取我接收的汇报列表,支持按类型/状态/部门/时间筛选
|
||||
@@ -42,6 +43,7 @@
|
||||
* === 消息通知 ===
|
||||
* - search_dialogs - 按名称搜索群聊或联系人,返回 dialog_id/userid
|
||||
* - send_message - 发送消息到对话(支持 dialog_id 或 userid)
|
||||
* - send_task_ai_message - 以AI助手身份发送消息到任务对话,支持自定义发送者昵称
|
||||
* - get_message_list - 获取对话消息记录(支持 dialog_id 或 userid)
|
||||
*
|
||||
* === 智能搜索 ===
|
||||
@@ -74,7 +76,8 @@
|
||||
* - "查看我的文件列表"
|
||||
* - "搜索包含'设计稿'的文件"
|
||||
* - "显示文件123的详细信息"
|
||||
* - "帮我分析这个文档的内容"
|
||||
* - "帮我阅读这个Word文档的内容"
|
||||
* - "分析这份PDF报告的要点"
|
||||
*
|
||||
* 工作报告:
|
||||
* - "查看未读的工作汇报"
|
||||
@@ -226,7 +229,7 @@ class DooTaskMCP {
|
||||
return { error: 'Result contains non-serializable data' };
|
||||
}
|
||||
} catch (error) {
|
||||
return { error: error.msg || error.message || String(error) || 'API request failed' };
|
||||
return { error: error.msg || error.message || String(error) || 'API request failed', ret: error.ret, data: error.data };
|
||||
}
|
||||
})()
|
||||
`);
|
||||
@@ -240,6 +243,10 @@ class DooTaskMCP {
|
||||
const result = await Promise.race([executePromise, timeoutPromise]);
|
||||
|
||||
if (result && result.error) {
|
||||
// 多结束/开始状态(-4005/-4006):保留 ret 与 flow_items 交给工具处理,不直接抛错
|
||||
if (result.ret === -4005 || result.ret === -4006) {
|
||||
return result;
|
||||
}
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
@@ -589,14 +596,38 @@ class DooTaskMCP {
|
||||
task_id: z.number()
|
||||
.min(1)
|
||||
.describe('要标记完成的任务ID'),
|
||||
flow_item_id: z.number()
|
||||
.optional()
|
||||
.describe('工作流状态ID'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
const result = await this.request('POST', 'project/task/update', {
|
||||
const requestData = {
|
||||
task_id: params.task_id,
|
||||
complete_at: now,
|
||||
});
|
||||
};
|
||||
if (params.flow_item_id) {
|
||||
requestData.flow_item_id = params.flow_item_id;
|
||||
}
|
||||
|
||||
const result = await this.request('POST', 'project/task/update', requestData);
|
||||
|
||||
// 处理多结束状态的情况
|
||||
if (result.ret === -4005) {
|
||||
const flowItems = result.data?.flow_items || [];
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: '存在多个结束状态,请选择要使用的状态后重新调用此工具,并指定flow_item_id参数',
|
||||
task_id: params.task_id,
|
||||
flow_items: flowItems,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
@@ -718,6 +749,9 @@ class DooTaskMCP {
|
||||
complete_at: z.union([z.string(), z.boolean()])
|
||||
.optional()
|
||||
.describe('完成时间。传时间字符串标记完成,传false标记未完成'),
|
||||
flow_item_id: z.number()
|
||||
.optional()
|
||||
.describe('工作流状态ID'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const requestData = {
|
||||
@@ -732,9 +766,42 @@ class DooTaskMCP {
|
||||
if (params.start_at !== undefined) requestData.start_at = params.start_at;
|
||||
if (params.end_at !== undefined) requestData.end_at = params.end_at;
|
||||
if (params.complete_at !== undefined) requestData.complete_at = params.complete_at;
|
||||
if (params.flow_item_id !== undefined) requestData.flow_item_id = params.flow_item_id;
|
||||
|
||||
const result = await this.request('POST', 'project/task/update', requestData);
|
||||
|
||||
// 处理多结束状态的情况(标记完成时)
|
||||
if (result.ret === -4005) {
|
||||
const flowItems = result.data?.flow_items || [];
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: '存在多个结束状态,请选择要使用的状态后重新调用此工具,并指定flow_item_id参数',
|
||||
task_id: params.task_id,
|
||||
flow_items: flowItems,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// 处理多开始状态的情况(取消完成时)
|
||||
if (result.ret === -4006) {
|
||||
const flowItems = result.data?.flow_items || [];
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: '存在多个开始状态,请选择要使用的状态后重新调用此工具,并指定flow_item_id参数',
|
||||
task_id: params.task_id,
|
||||
flow_items: flowItems,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
@@ -1289,6 +1356,58 @@ class DooTaskMCP {
|
||||
}
|
||||
});
|
||||
|
||||
// 以AI助手身份发送消息到任务对话
|
||||
this.mcp.addTool({
|
||||
name: 'send_task_ai_message',
|
||||
description: '以AI助手身份发送消息到任务对话。应在每个重要里程碑、遇到阻塞、以及全部完成时主动调用。',
|
||||
parameters: z.object({
|
||||
task_id: z.number()
|
||||
.describe('目标任务ID'),
|
||||
text: z.string()
|
||||
.min(1)
|
||||
.describe('消息内容,支持 Markdown'),
|
||||
nickname: z.string()
|
||||
.max(20)
|
||||
.optional()
|
||||
.describe('自定义发送者昵称(最多20字),不传或留空时默认显示“AI 助手”'),
|
||||
silence: z.boolean()
|
||||
.optional()
|
||||
.describe('静默发送,不触发推送提醒'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const payload = {
|
||||
task_id: params.task_id,
|
||||
text: params.text,
|
||||
text_type: 'md',
|
||||
};
|
||||
|
||||
if (params.nickname !== undefined) {
|
||||
payload.nickname = params.nickname;
|
||||
}
|
||||
|
||||
if (params.silence !== undefined) {
|
||||
payload.silence = params.silence ? 'yes' : 'no';
|
||||
}
|
||||
|
||||
const result = await this.request('POST', 'dialog/msg/send_ai_assistant', payload);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
task_id: params.task_id,
|
||||
message: result.data,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 获取对话消息列表
|
||||
this.mcp.addTool({
|
||||
name: 'get_message_list',
|
||||
@@ -1902,16 +2021,38 @@ class DooTaskMCP {
|
||||
// 文件管理:获取文件详情
|
||||
this.mcp.addTool({
|
||||
name: 'get_file_detail',
|
||||
description: '获取文件详情,包括类型、大小、共享状态等。支持文件ID或分享码。',
|
||||
description: '获取文件详情,包括类型、大小、正文内容、共享状态等。',
|
||||
parameters: z.object({
|
||||
file_id: z.union([z.number(), z.string()])
|
||||
.describe('文件ID(数字)或分享码(字符串)'),
|
||||
.describe('文件ID 或分享码'),
|
||||
with_content: z.boolean()
|
||||
.optional()
|
||||
.describe('是否提取文本内容'),
|
||||
text_offset: z.number()
|
||||
.optional()
|
||||
.describe('文本起始位置'),
|
||||
text_limit: z.number()
|
||||
.optional()
|
||||
.describe('获取长度,默认50000,最大200000'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const result = await this.request('GET', 'file/one', {
|
||||
const requestData = {
|
||||
id: params.file_id,
|
||||
with_url: 'yes',
|
||||
});
|
||||
};
|
||||
|
||||
// 如果需要提取文本内容
|
||||
if (params.with_content) {
|
||||
requestData.with_text = 'yes';
|
||||
if (params.text_offset !== undefined) {
|
||||
requestData.text_offset = params.text_offset;
|
||||
}
|
||||
if (params.text_limit !== undefined) {
|
||||
requestData.text_limit = Math.min(params.text_limit, 200000);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.request('GET', 'file/one', requestData);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
@@ -1934,6 +2075,19 @@ class DooTaskMCP {
|
||||
updated_at: file.updated_at,
|
||||
};
|
||||
|
||||
// 如果有文本内容
|
||||
if (file.text_content) {
|
||||
if (file.text_content.error) {
|
||||
fileDetail.text_error = file.text_content.error;
|
||||
} else {
|
||||
fileDetail.text_content = file.text_content.content;
|
||||
fileDetail.text_total_length = file.text_content.total_length;
|
||||
fileDetail.text_offset = file.text_content.offset;
|
||||
fileDetail.text_limit = file.text_content.limit;
|
||||
fileDetail.text_has_more = file.text_content.has_more;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
@@ -1943,6 +2097,47 @@ class DooTaskMCP {
|
||||
}
|
||||
});
|
||||
|
||||
// 文件管理:通过路径获取文件内容
|
||||
this.mcp.addTool({
|
||||
name: 'fetch_file_content',
|
||||
description: '通过文件路径获取文本内容。',
|
||||
parameters: z.object({
|
||||
path: z.string()
|
||||
.describe('系统内文件路径或URL'),
|
||||
offset: z.number()
|
||||
.optional()
|
||||
.describe('起始位置'),
|
||||
limit: z.number()
|
||||
.optional()
|
||||
.describe('获取长度,默认50000,最大200000'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const requestData = {
|
||||
path: params.path,
|
||||
};
|
||||
|
||||
if (params.offset !== undefined) {
|
||||
requestData.offset = params.offset;
|
||||
}
|
||||
if (params.limit !== undefined) {
|
||||
requestData.limit = Math.min(params.limit, 200000);
|
||||
}
|
||||
|
||||
const result = await this.request('GET', 'file/fetch', requestData);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result.data, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 智能搜索:统一搜索工具
|
||||
this.mcp.addTool({
|
||||
name: 'intelligent_search',
|
||||
|
||||
133
electron/lib/r2.js
vendored
Normal file
133
electron/lib/r2.js
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
const fs = require('fs');
|
||||
const {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
CopyObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
} = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
|
||||
const {
|
||||
R2_ACCESS_KEY_ID,
|
||||
R2_SECRET_ACCESS_KEY,
|
||||
R2_ENDPOINT,
|
||||
R2_BUCKET,
|
||||
R2_PUBLIC_URL,
|
||||
} = process.env;
|
||||
|
||||
function r2Configured() {
|
||||
return !!(R2_ACCESS_KEY_ID && R2_SECRET_ACCESS_KEY && R2_ENDPOINT && R2_BUCKET);
|
||||
}
|
||||
|
||||
function createR2Client() {
|
||||
return new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: R2_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: R2_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function contentTypeFor(name) {
|
||||
if (/\.ya?ml$/i.test(name)) return 'text/yaml';
|
||||
if (/\.json$/i.test(name)) return 'application/json';
|
||||
if (/\.md$/i.test(name)) return 'text/markdown; charset=utf-8';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** 流式上传本地文件,onProgress(loaded, total) */
|
||||
async function uploadFile(client, localFile, key, onProgress) {
|
||||
const total = fs.statSync(localFile).size;
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
Body: fs.createReadStream(localFile),
|
||||
ContentType: contentTypeFor(key),
|
||||
},
|
||||
});
|
||||
if (onProgress) {
|
||||
upload.on('httpUploadProgress', (p) => onProgress(p.loaded || 0, total));
|
||||
}
|
||||
await upload.done();
|
||||
}
|
||||
|
||||
/** 写入文本对象 */
|
||||
async function putText(client, key, text) {
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
Body: text,
|
||||
ContentType: contentTypeFor(key),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 读取文本对象,不存在返回 null */
|
||||
async function getText(client, key) {
|
||||
try {
|
||||
const res = await client.send(new GetObjectCommand({ Bucket: R2_BUCKET, Key: key }));
|
||||
return await res.Body.transformToString();
|
||||
} catch (err) {
|
||||
if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** 桶内服务端复制(文件名为安全 ASCII,无需额外编码) */
|
||||
async function copyObject(client, srcKey, destKey) {
|
||||
await client.send(new CopyObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
CopySource: `${R2_BUCKET}/${srcKey}`,
|
||||
Key: destKey,
|
||||
ContentType: contentTypeFor(destKey),
|
||||
MetadataDirective: 'REPLACE',
|
||||
}));
|
||||
}
|
||||
|
||||
/** 列举 key;delimiter='/' 时仅返回该前缀下的根层对象(子目录归 CommonPrefixes,不返回) */
|
||||
async function listKeys(client, prefix, delimiter) {
|
||||
const keys = [];
|
||||
let token;
|
||||
do {
|
||||
const res = await client.send(new ListObjectsV2Command({
|
||||
Bucket: R2_BUCKET,
|
||||
Prefix: prefix,
|
||||
Delimiter: delimiter,
|
||||
ContinuationToken: token,
|
||||
}));
|
||||
for (const o of res.Contents || []) keys.push(o.Key);
|
||||
token = res.IsTruncated ? res.NextContinuationToken : undefined;
|
||||
} while (token);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** 批量删除(每批 1000) */
|
||||
async function deleteKeys(client, keys) {
|
||||
for (let i = 0; i < keys.length; i += 1000) {
|
||||
const batch = keys.slice(i, i + 1000);
|
||||
if (!batch.length) continue;
|
||||
await client.send(new DeleteObjectsCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Delete: { Objects: batch.map((Key) => ({ Key })) },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
r2Configured,
|
||||
createR2Client,
|
||||
contentTypeFor,
|
||||
uploadFile,
|
||||
putText,
|
||||
getText,
|
||||
copyObject,
|
||||
listKeys,
|
||||
deleteKeys,
|
||||
R2_BUCKET,
|
||||
R2_PUBLIC_URL,
|
||||
};
|
||||
46
electron/lib/release-index.js
vendored
Normal file
46
electron/lib/release-index.js
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
// 仅这些扩展名进入下载索引(排除 .zip:mac 自动更新增量包,非下载按钮目标)
|
||||
const DOWNLOAD_EXTS = ['.dmg', '.exe', '.msi', '.appimage', '.deb', '.rpm', '.apk', '.pkg'];
|
||||
|
||||
/**
|
||||
* 从文件名解析 platform/arch(与官网 storage.ts 规则保持一致)
|
||||
* @returns {{platform: string, arch: string|null}|null}
|
||||
*/
|
||||
function parseFilename(filename) {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith('.apk')) {
|
||||
return { platform: 'android', arch: null };
|
||||
}
|
||||
if (!DOWNLOAD_EXTS.some((ext) => lower.endsWith(ext))) {
|
||||
return null;
|
||||
}
|
||||
let platform = null;
|
||||
if (/-mac-/i.test(filename) || lower.endsWith('.dmg') || lower.endsWith('.pkg')) {
|
||||
platform = 'mac';
|
||||
} else if (/-win-/i.test(filename) || /-win\./i.test(filename) || lower.endsWith('.msi')) {
|
||||
platform = 'win';
|
||||
} else if (/-linux-/i.test(filename) || lower.endsWith('.appimage') || lower.endsWith('.deb') || lower.endsWith('.rpm')) {
|
||||
platform = 'linux';
|
||||
}
|
||||
if (!platform) return null;
|
||||
let arch = null;
|
||||
if (/-arm64[.-]/i.test(filename)) arch = 'arm64';
|
||||
else if (/-x64[.-]/i.test(filename)) arch = 'x64';
|
||||
return { platform, arch };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下载索引:{ "<platform>": { "<arch|default>": filename } }
|
||||
*/
|
||||
function buildReleaseIndex(filenames) {
|
||||
const index = {};
|
||||
for (const filename of filenames) {
|
||||
const parsed = parseFilename(filename);
|
||||
if (!parsed) continue;
|
||||
const archKey = parsed.arch || 'default';
|
||||
index[parsed.platform] = index[parsed.platform] || {};
|
||||
index[parsed.platform][archKey] = filename;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
module.exports = { parseFilename, buildReleaseIndex, DOWNLOAD_EXTS };
|
||||
36
electron/lib/release-index.test.js
vendored
Normal file
36
electron/lib/release-index.test.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { parseFilename, buildReleaseIndex } = require('./release-index');
|
||||
|
||||
test('parseFilename: win exe x64', () => {
|
||||
assert.deepStrictEqual(parseFilename('DooTask-v1.7.56-win-x64.exe'), { platform: 'win', arch: 'x64' });
|
||||
});
|
||||
|
||||
test('parseFilename: mac dmg arm64', () => {
|
||||
assert.deepStrictEqual(parseFilename('DooTask-v1.7.56-mac-arm64.dmg'), { platform: 'mac', arch: 'arm64' });
|
||||
});
|
||||
|
||||
test('parseFilename: android apk has null arch', () => {
|
||||
assert.deepStrictEqual(parseFilename('app-release.apk'), { platform: 'android', arch: null });
|
||||
});
|
||||
|
||||
test('parseFilename: ignores yml/blockmap/zip', () => {
|
||||
assert.strictEqual(parseFilename('latest.yml'), null);
|
||||
assert.strictEqual(parseFilename('DooTask-v1.7.56-win-x64.exe.blockmap'), null);
|
||||
assert.strictEqual(parseFilename('DooTask-v1.7.56-mac-arm64.zip'), null);
|
||||
});
|
||||
|
||||
test('buildReleaseIndex: groups by platform/arch, .zip never overwrites .dmg', () => {
|
||||
const index = buildReleaseIndex([
|
||||
'DooTask-v1.7.56-mac-arm64.dmg',
|
||||
'DooTask-v1.7.56-mac-arm64.zip',
|
||||
'DooTask-v1.7.56-win-x64.exe',
|
||||
'latest.yml',
|
||||
'app-release.apk',
|
||||
]);
|
||||
assert.deepStrictEqual(index, {
|
||||
mac: { arm64: 'DooTask-v1.7.56-mac-arm64.dmg' },
|
||||
win: { x64: 'DooTask-v1.7.56-win-x64.exe' },
|
||||
android: { default: 'app-release.apk' },
|
||||
});
|
||||
});
|
||||
4
electron/lib/renderer.js
vendored
4
electron/lib/renderer.js
vendored
@@ -572,7 +572,7 @@ const renderer = {
|
||||
},
|
||||
}
|
||||
|
||||
const onRenderer = (mainWindow) => {
|
||||
const onRenderer = (getMainWindow) => {
|
||||
ipcMain.on("rendererReq", async (event, args) => {
|
||||
try {
|
||||
let ret = null;
|
||||
@@ -651,7 +651,7 @@ const onRenderer = (mainWindow) => {
|
||||
ret = await electronDown.updateWindow(args.language, args.theme);
|
||||
break;
|
||||
case 'createDownload':
|
||||
ret = await electronDown.createDownload(mainWindow, args.url, args.options || {});
|
||||
ret = await electronDown.createDownload(getMainWindow(), args.url, args.options || {});
|
||||
break;
|
||||
case 'watchFile':
|
||||
ret = await renderer.watchFile(args.path);
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"ora": "^4.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1052.0",
|
||||
"@aws-sdk/lib-storage": "^3.1052.0",
|
||||
"@dootask/electron-dl": "^4.0.0-rc.2",
|
||||
"axios": "^1.11.0",
|
||||
"crc": "^3.8.0",
|
||||
@@ -60,8 +62,8 @@
|
||||
"request": "^2.88.2",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.2",
|
||||
"zod": "^3.23.8",
|
||||
"yauzl": "^3.2.0"
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"trayIcon": {
|
||||
"dev": {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# 语言翻译工具说明
|
||||
|
||||
`language/translate.php` 脚本用于根据 `original-web.txt` 和 `original-api.txt` 中的内容,自动生成/更新 `translate.json` 以及前端使用的多语言文件。
|
||||
|
||||
## 使用步骤
|
||||
|
||||
1. 在项目根目录 `.env` 文件中配置:
|
||||
|
||||
```dotenv
|
||||
OPENAI_API_KEY=你的OpenAI密钥
|
||||
OPENAI_PROXY_URL=可选的代理地址
|
||||
```
|
||||
|
||||
2. 在 `language` 目录下执行:
|
||||
|
||||
```bash
|
||||
php translate.php
|
||||
```
|
||||
|
||||
3. 查看生成的翻译结果:
|
||||
|
||||
- 翻译详情:`language/translate.json`
|
||||
- API 文件:`public/language/api/*.json`
|
||||
- Web 文件:`public/language/web/*.js`
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 若 `.env` 未设置 `OPENAI_API_KEY`,脚本会直接退出。
|
||||
- `OPENAI_PROXY_URL` 可选,留空时不会设置代理。
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "dootask/language",
|
||||
"require": {
|
||||
"php": ">=7.4",
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"orhanerday/open-ai": "^5.2"
|
||||
}
|
||||
}
|
||||
82
language/composer.lock
generated
82
language/composer.lock
generated
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "ec9d23d3c9171a27ef10589ff18aaf1d",
|
||||
"packages": [
|
||||
{
|
||||
"name": "orhanerday/open-ai",
|
||||
"version": "5.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/orhanerday/open-ai.git",
|
||||
"reference": "d8c78fe2f5fed59e0ba458f90b5589ed9f13a367"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/orhanerday/open-ai/zipball/d8c78fe2f5fed59e0ba458f90b5589ed9f13a367",
|
||||
"reference": "d8c78fe2f5fed59e0ba458f90b5589ed9f13a367",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.0",
|
||||
"pestphp/pest": "^1.20",
|
||||
"spatie/ray": "^1.28"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Orhanerday\\OpenAi\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Orhan Erday",
|
||||
"email": "orhanerday@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "OpenAI GPT-3 Api Client in PHP",
|
||||
"homepage": "https://github.com/orhanerday/open-ai",
|
||||
"keywords": [
|
||||
"open-ai",
|
||||
"orhanerday"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/orhanerday/open-ai/issues",
|
||||
"source": "https://github.com/orhanerday/open-ai/tree/5.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/orhanerday",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-05-29T12:31:54+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": ">=7.4",
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
@@ -560,8 +560,6 @@ webhook地址最长仅支持255个字符。
|
||||
(*)将(*)移出群组
|
||||
(*)退出群组
|
||||
(*)已加入群组
|
||||
(*)当前正在共享,无法移动到另一个共享文件夹内
|
||||
(*)内含有共享文件,无法移动到另一个共享文件夹内
|
||||
处理错误
|
||||
仅限所有者或创建者操作
|
||||
没有修改写入权限
|
||||
@@ -575,13 +573,11 @@ webhook地址最长仅支持255个字符。
|
||||
子任务负责人填写错误
|
||||
任务负责人填写错误
|
||||
(*)负责或参与的未完成任务最多不能超过(*)个
|
||||
(*)已被其他成员设置
|
||||
邮件发送超时,请检查邮箱配置是否正确
|
||||
群主不可移出
|
||||
部门成员、项目人员或任务人员不可移出
|
||||
群主不可退出
|
||||
部门成员、项目人员或任务人员不可退出
|
||||
当前客户端版本(*)过低,最低版本要求(*)。
|
||||
验证码不能为空
|
||||
别名不能为空
|
||||
别名的长度在(*)个字符
|
||||
@@ -607,7 +603,6 @@ webhook地址最长仅支持255个字符。
|
||||
(*)的周报[(*)][(*)月第(*)周]
|
||||
(*)的日报[(*)]
|
||||
考勤机
|
||||
手动签到
|
||||
|
||||
(*)评论了(*)的「(**)」审批
|
||||
抄送(*)提交的「(**)」记录
|
||||
@@ -615,7 +610,6 @@ webhook地址最长仅支持255个字符。
|
||||
您发起的「(**)」已通过
|
||||
您发起的「(**)」被(*)拒绝
|
||||
|
||||
消息不存在或已被删除
|
||||
此消息不支持翻译
|
||||
消息内容为空
|
||||
翻译失败
|
||||
@@ -629,26 +623,15 @@ webhook地址最长仅支持255个字符。
|
||||
任务结束时间
|
||||
任务计划用时
|
||||
超时时间
|
||||
负责人
|
||||
创建人
|
||||
|
||||
(*)等(*)位成员的任务统计
|
||||
(*)的任务统计
|
||||
任务ID
|
||||
父级任务ID
|
||||
所属项目
|
||||
任务标题
|
||||
任务开始时间
|
||||
任务结束时间
|
||||
完成时间
|
||||
归档时间
|
||||
任务计划用时
|
||||
实际完成用时
|
||||
超时时间
|
||||
开发用时
|
||||
验收/测试用时
|
||||
负责人
|
||||
创建人
|
||||
状态
|
||||
|
||||
审批记录
|
||||
@@ -656,7 +639,6 @@ webhook地址最长仅支持255个字符。
|
||||
标题
|
||||
申请状态
|
||||
发起时间
|
||||
完成时间
|
||||
发起人工号
|
||||
发起人User ID
|
||||
发起人姓名
|
||||
@@ -665,7 +647,6 @@ webhook地址最长仅支持255个字符。
|
||||
部门负责人
|
||||
历史审批人
|
||||
历史办理人
|
||||
审批记录
|
||||
当前处理人
|
||||
审批节点
|
||||
审批人数
|
||||
@@ -819,7 +800,6 @@ AI机器人不存在
|
||||
|
||||
选择模型
|
||||
当前对话不支持
|
||||
会话不存在或已被删除
|
||||
开启新会话
|
||||
历史会话
|
||||
|
||||
@@ -839,13 +819,11 @@ AI机器人不存在
|
||||
(*)天(*)小时(*)分钟
|
||||
(*)天(*)小时
|
||||
(*)天(*)分钟
|
||||
(*)天
|
||||
(*)小时(*)分钟
|
||||
(*)小时
|
||||
(*)分钟
|
||||
|
||||
任务不存在或已被删除
|
||||
文件不存在或已被删除
|
||||
报告不存在或已被删除
|
||||
文件读取失败:(*)
|
||||
|
||||
@@ -919,7 +897,6 @@ URL格式不正确
|
||||
报告内容为空,无法进行分析
|
||||
工作汇报分析失败
|
||||
工作汇报分析结果为空
|
||||
缺少ID参数
|
||||
无权访问该工作汇报
|
||||
生成AI分析失败
|
||||
工作汇报内容不能为空
|
||||
@@ -937,15 +914,85 @@ URL格式不正确
|
||||
会员不存在
|
||||
请输入个性标签
|
||||
标签名称最多只能设置(*)个字
|
||||
标签已存在
|
||||
每位会员最多添加(*)个标签
|
||||
参数错误
|
||||
标签不存在
|
||||
无权操作该标签
|
||||
已取消认可
|
||||
认可成功
|
||||
选择模型
|
||||
请先配置 AI 助手
|
||||
请先在「AI 助手」设置中配置 OpenAI
|
||||
请先在「AI 助手」设置中配置 (*)
|
||||
今日未完成的工作
|
||||
本周未完成的工作
|
||||
本周未完成的工作
|
||||
|
||||
无效的建议类型
|
||||
任务不存在或无权限
|
||||
建议不存在
|
||||
建议内容为空
|
||||
AI建议:指派给 (*)
|
||||
AI建议:关联任务 (*)
|
||||
AI建议:采纳(*)建议
|
||||
已采纳
|
||||
已忽略
|
||||
|
||||
消息内容格式错误
|
||||
AI 调用失败
|
||||
AI 返回内容为空
|
||||
|
||||
修改AI自动分析
|
||||
关联不存在
|
||||
只能合并转发同一对话的消息
|
||||
所选消息均不支持转发
|
||||
无法创建任务对话
|
||||
最多转发(*)条消息
|
||||
此类型消息不支持转发
|
||||
没有权限操作此任务
|
||||
请选择要转发的消息
|
||||
LDAP 用户缺少邮箱属性,请联系管理员配置
|
||||
群管理员
|
||||
任命群管理员
|
||||
罢免群管理员
|
||||
该用户不是群成员
|
||||
不能将群主任命为群管理员
|
||||
仅群主或群管理员可操作
|
||||
仅限群主或群管理员操作
|
||||
群管理员不能移出群主或其他群管理员
|
||||
请选择有效的成员
|
||||
任命成功
|
||||
罢免成功
|
||||
项目管理员
|
||||
任命项目管理员
|
||||
罢免项目管理员
|
||||
该用户不是项目成员
|
||||
不能将负责人任命为项目管理员
|
||||
不能将部门负责人任命为部门管理员
|
||||
该用户不存在
|
||||
无权操作此模板
|
||||
修改共享模板
|
||||
修改负责人视角可见
|
||||
项目负责人数据异常,请先修复项目负责人
|
||||
项目成员列表必须包含项目负责人
|
||||
项目管理员不能移除项目负责人或项目管理员
|
||||
项目管理员必须是项目成员
|
||||
负责人不能任命为项目管理员
|
||||
普通成员不能移出群主或群管理员
|
||||
只有群主、群管理员或邀请人可以移出成员
|
||||
仅群主、项目/任务负责人或系统管理员可设置或取消他人待办
|
||||
请选择文件
|
||||
仅支持 xls/xlsx/csv 文件
|
||||
文件中没有可导入的数据
|
||||
导入完成
|
||||
昵称需为2-20个字
|
||||
邮箱、昵称、初始密码均为必填
|
||||
邮箱格式不正确
|
||||
文件内邮箱重复
|
||||
单次最多导入500条
|
||||
没有可导入的数据
|
||||
解析完成
|
||||
|
||||
请选择成员
|
||||
待办提醒
|
||||
你有一条待办到提醒时间啦
|
||||
发送者昵称最多不能超过20字
|
||||
AI 助手
|
||||
没有查看权限
|
||||
当前仅指定人员可以创建项目
|
||||
|
||||
@@ -1668,6 +1668,12 @@ WiFi签到延迟时长为±1分钟。
|
||||
|
||||
你确定将【(*)】设为管理员吗?
|
||||
你确定取消【(*)】管理员身份吗?
|
||||
你确定将【(*)】的邮箱标记为已认证吗?
|
||||
你确定将【(*)】的邮箱标记为未认证吗?
|
||||
标记邮箱为已认证
|
||||
标记邮箱为未认证
|
||||
标记选中(*)项为已认证
|
||||
标记选中(*)项为未认证
|
||||
|
||||
你确定要取消任务时间吗?
|
||||
更新子任务
|
||||
@@ -1689,7 +1695,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
|
||||
该任务尚未被领取,点击这里
|
||||
考勤机
|
||||
手动签到
|
||||
签到备注
|
||||
重复打卡提醒
|
||||
|
||||
@@ -1728,7 +1733,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
插入链接
|
||||
请输入完整的链接地址
|
||||
|
||||
自动通过,审批人与发起人为同一人
|
||||
自动通过,审批人已审核
|
||||
|
||||
你确定要删除项目吗?
|
||||
@@ -1757,9 +1761,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
定位失败
|
||||
位置
|
||||
你选择的位置「(*)」不在签到范围内
|
||||
定位签到
|
||||
通过在签到打卡机器人发送位置签到
|
||||
签到备注
|
||||
百度地图AK
|
||||
腾讯地图Key
|
||||
高德地图Key
|
||||
@@ -1813,7 +1814,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
系统别名
|
||||
用于网页默认标题、邮件发送等
|
||||
|
||||
权限设置
|
||||
打包权限
|
||||
允许所有人
|
||||
仅限管理员
|
||||
@@ -1882,7 +1882,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
只有在项目中才能创建任务
|
||||
项目不存在
|
||||
只有在任务中才能创建子任务
|
||||
任务不存在
|
||||
未知类型
|
||||
未找到内容
|
||||
再见
|
||||
@@ -1901,7 +1900,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
请输入标签描述
|
||||
标签颜色
|
||||
使用示例标签
|
||||
取消默认
|
||||
编辑标签
|
||||
确定要删除该标签吗?
|
||||
标签
|
||||
@@ -1909,7 +1907,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
暂无标签
|
||||
添加标签
|
||||
请选择示例标签
|
||||
全部保存成功
|
||||
|
||||
消息详情
|
||||
长文本
|
||||
@@ -2022,7 +2019,6 @@ API请求的URL路径
|
||||
附言
|
||||
|
||||
任务不存在或已被删除
|
||||
文件不存在或已被删除
|
||||
报告不存在或已被删除
|
||||
文件读取失败:(*)
|
||||
独立窗口显示
|
||||
@@ -2037,7 +2033,6 @@ API请求的URL路径
|
||||
删除机器人:(*)
|
||||
|
||||
默认:90天
|
||||
机器人名称
|
||||
|
||||
后退
|
||||
前进
|
||||
@@ -2089,12 +2084,8 @@ OKR群组
|
||||
会话名称
|
||||
值
|
||||
结果
|
||||
名称
|
||||
命令
|
||||
必填
|
||||
接口地址
|
||||
清理时间
|
||||
类型
|
||||
该机器人不支持
|
||||
说明
|
||||
属性
|
||||
@@ -2214,7 +2205,6 @@ Webhook事件
|
||||
打开会话
|
||||
成员加入
|
||||
成员退出
|
||||
是否拨打电话给(*)?
|
||||
是否发送邮件给(*)?
|
||||
|
||||
个人信息
|
||||
@@ -2237,7 +2227,6 @@ Webhook事件
|
||||
用户
|
||||
|
||||
应用此内容
|
||||
生成中...
|
||||
等待 AI 回复...
|
||||
请输入你的问题...
|
||||
选择模型
|
||||
@@ -2281,4 +2270,187 @@ URL不能为空
|
||||
前日
|
||||
次日
|
||||
|
||||
AI 搜索
|
||||
AI 搜索
|
||||
AI 项目助手
|
||||
AI 汇报分析
|
||||
AI 任务助手
|
||||
AI 消息助手
|
||||
欢迎使用 AI 助手
|
||||
|
||||
新建会话
|
||||
历史会话
|
||||
清空历史记录
|
||||
|
||||
编辑问题
|
||||
重启应用
|
||||
关闭应用
|
||||
请描述你想搜索的内容...
|
||||
搜索中...
|
||||
|
||||
工作流规则
|
||||
流转到
|
||||
时改变任务负责人为
|
||||
,原负责人移至协助人员
|
||||
仅限任务负责人和项目管理员修改状态
|
||||
时自动将任务移动至列表
|
||||
(并保留操作人),原负责人移至协助人员
|
||||
时添加
|
||||
至任务负责人
|
||||
连接失败,请重试
|
||||
最多上传(*)张图片
|
||||
松开以上传图片
|
||||
|
||||
清空历史会话
|
||||
确定要清空当前场景的所有历史会话吗?
|
||||
文件读取失败
|
||||
无效的图片数据
|
||||
图片加载失败
|
||||
应用成功
|
||||
请选择负责人
|
||||
未知的建议类型
|
||||
没有有效的子任务
|
||||
已忽略
|
||||
已采纳
|
||||
已创建
|
||||
已指派
|
||||
已关联
|
||||
采纳描述
|
||||
指派
|
||||
关联
|
||||
采纳
|
||||
|
||||
逐条转发
|
||||
合并转发
|
||||
复制原文
|
||||
所属部门
|
||||
留空则不修改密码
|
||||
职位
|
||||
请输入电话号码
|
||||
正在编辑帐号【ID:(*)】的信息。
|
||||
编辑用户信息
|
||||
|
||||
AI任务分析
|
||||
关闭后所有项目将不再自动分析任务。
|
||||
关闭后本项目将不再自动分析任务。
|
||||
新建任务后AI自动分析并给出建议。
|
||||
(最多(*)条)
|
||||
最多选择(*)条消息
|
||||
系统已关闭AI任务分析功能。
|
||||
聊天记录
|
||||
确定要解除与任务 #(*) 的关联吗?
|
||||
共(*)条消息
|
||||
已选(*)条
|
||||
(*)的聊天记录
|
||||
(*)和(*)的聊天记录
|
||||
(*)和(*)等人的聊天记录
|
||||
|
||||
生日
|
||||
请选择生日
|
||||
登录属性
|
||||
用于匹配登录用户名的 LDAP 属性,Active Directory 请选择 sAMAccountName
|
||||
请输入帐号
|
||||
群管理员
|
||||
任命群管理员
|
||||
罢免群管理员
|
||||
确定要罢免该群管理员吗?
|
||||
还没有群管理员
|
||||
添加群管理员
|
||||
确定将 (*) 任命为群管理员吗?
|
||||
项目管理员
|
||||
任命项目管理员
|
||||
罢免项目管理员
|
||||
确定要罢免该项目管理员吗?
|
||||
还没有项目管理员
|
||||
添加项目管理员
|
||||
确定将 (*) 任命为项目管理员吗?
|
||||
部门管理员
|
||||
任命部门管理员
|
||||
罢免部门管理员
|
||||
请选择部门管理员
|
||||
确定将 (*) 任命为部门管理员吗?
|
||||
部门管理员享有部门群的群管理员权限
|
||||
即将罢免项目管理员
|
||||
请确认以下操作,注意此操作不可逆!
|
||||
移除成员负责的任务将变成无负责人。
|
||||
搜索模板
|
||||
来自(*)
|
||||
暂无可用模板
|
||||
加载中
|
||||
共享模板
|
||||
开启后,添加任务时可使用其他项目共享的任务模板。
|
||||
关闭后,添加任务时仅加载本项目模板,不显示其他项目共享模板。
|
||||
根据系统设置的自动归档规则执行
|
||||
负责人视角
|
||||
开启后,部门负责人可只读查看本项目及其全员可见任务。
|
||||
关闭后,本项目及其群聊对部门负责人视角隐藏。
|
||||
个人项目,只读查看
|
||||
负责人视角,只读查看
|
||||
我的项目
|
||||
没有任何与"(*)"相关的结果
|
||||
标记未选
|
||||
标记已选
|
||||
可查看所选部门及所有下级部门成员参与的项目和任务,仅支持只读查看。
|
||||
反选
|
||||
切换失败
|
||||
当前为负责人视角:你可查看项目和任务,并参与讨论,但不能编辑项目或任务。
|
||||
选择项目管理员
|
||||
即将移除
|
||||
当前为负责人视角,并参与讨论,但不能编辑任务。
|
||||
部门负责人视角
|
||||
开启后,部门负责人/部门管理员可只读查看本部门及下级部门成员参与的项目和项目内全部任务。
|
||||
部门管理员同步失败
|
||||
待办设置权限
|
||||
允许:所有成员可设置/取消他人待办。
|
||||
禁止:仅本人、系统管理员、群主(含群管理员)、项目负责人(含项目管理员)、任务负责人可设置/取消待办。
|
||||
|
||||
批量导入用户
|
||||
请按模板填写后上传,列顺序:邮箱、昵称、初始密码、职位(选填);单次最多导入500条。
|
||||
下载模板
|
||||
导入结果:共(*)条,成功(*)条,失败(*)条
|
||||
行号
|
||||
失败原因
|
||||
导入失败
|
||||
仅支持 xls/xlsx/csv 文件
|
||||
创建用户
|
||||
批量导入
|
||||
初始密码
|
||||
请输入邮箱
|
||||
请输入初始密码
|
||||
员工首次登录需修改密码
|
||||
邮箱、昵称、初始密码均为必填
|
||||
员工下次登录需修改密码
|
||||
重新选择文件
|
||||
共(*)条 · 可导入(*)条 · 错误(*)条
|
||||
点击查看明文
|
||||
原因
|
||||
可导入
|
||||
错误
|
||||
确定导入(*)条
|
||||
解析失败
|
||||
|
||||
设置部门到选中(*)项
|
||||
提醒时间
|
||||
不提醒
|
||||
1 小时后
|
||||
今晚 20:00
|
||||
明早 9:00
|
||||
成功导入(*)条
|
||||
标记完成
|
||||
暂无待办
|
||||
暂无完成
|
||||
取消提醒
|
||||
确定取消该成员的提醒时间吗?
|
||||
项目与任务
|
||||
暂无项目
|
||||
暂无任务
|
||||
负责
|
||||
协作
|
||||
成员
|
||||
(*)分钟前
|
||||
(*)小时前
|
||||
(*)天前
|
||||
所有人:所有成员均可创建项目。
|
||||
可创建项目的人员
|
||||
系统管理员(始终可创建,不受开关限制)。
|
||||
部门负责人与部门管理员。
|
||||
下方指定的人员。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,347 +0,0 @@
|
||||
<?php
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Orhanerday\OpenAi\OpenAi;
|
||||
|
||||
// 读取 .env 文件的简单工具函数
|
||||
function language_parse_env_file(string $path): array
|
||||
{
|
||||
$env = [];
|
||||
$lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
return $env;
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$delimiterPosition = strpos($line, '=');
|
||||
if ($delimiterPosition === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim(substr($line, 0, $delimiterPosition));
|
||||
if (strpos($name, 'export ') === 0) {
|
||||
$name = trim(substr($name, 7));
|
||||
}
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim(substr($line, $delimiterPosition + 1));
|
||||
$length = strlen($value);
|
||||
if ($length >= 2) {
|
||||
$first = $value[0];
|
||||
$last = $value[$length - 1];
|
||||
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
|
||||
$value = substr($value, 1, $length - 2);
|
||||
}
|
||||
}
|
||||
|
||||
$env[$name] = $value;
|
||||
}
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
// 获取环境变量值的简单工具函数
|
||||
function language_env_value(string $key, array $env): ?string
|
||||
{
|
||||
if (array_key_exists($key, $env)) {
|
||||
return $env[$key];
|
||||
}
|
||||
|
||||
$value = getenv($key);
|
||||
if ($value !== false) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 读取语言环境配置
|
||||
$languageEnvFile = dirname(__DIR__) . '/.env';
|
||||
$languageEnv = is_readable($languageEnvFile) ? language_parse_env_file($languageEnvFile) : [];
|
||||
|
||||
// 优先从 .env 读取 OPENAI 配置,未找到时再次尝试 getenv 覆盖
|
||||
$openAiKey = trim(language_env_value('OPENAI_API_KEY', $languageEnv) ?? '');
|
||||
if ($openAiKey === '') {
|
||||
fwrite(STDERR, "OPENAI_API_KEY 未设置,请在项目根目录的 .env 中配置。\n");
|
||||
exit(1);
|
||||
}
|
||||
$openAiProxy = trim(language_env_value('OPENAI_PROXY_URL', $languageEnv) ?? '');
|
||||
|
||||
// 读取所有要翻译的内容
|
||||
$originals = [];
|
||||
$generateds = [];
|
||||
foreach (['web', 'api'] as $type) {
|
||||
$content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : "";
|
||||
$array = array_values(array_filter(array_unique(explode("\n", $content))));
|
||||
$generateds[$type] = $array;
|
||||
$originals = array_merge($originals, $array);
|
||||
}
|
||||
|
||||
// 判定是否存在translate.json文件
|
||||
if (!file_exists("translate.json")) {
|
||||
print_r("translate.json not exists");
|
||||
exit;
|
||||
}
|
||||
|
||||
$translations = []; // 翻译数据
|
||||
$regrror = []; // 正则匹配错误的数据
|
||||
$redundants = []; // 多余的数据
|
||||
$needs = []; // 需要翻译的数据
|
||||
|
||||
// 读取翻译数据
|
||||
$tmps = json_decode(file_get_contents("translate.json"), true);
|
||||
foreach ($tmps as $obj) {
|
||||
if (!isset($obj['key'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentKey = $obj['key'];
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
|
||||
$translations[$originalKey] = $obj;
|
||||
|
||||
if (!in_array($originalKey, $originals)) {
|
||||
unset($translations[$originalKey]);
|
||||
$redundants[$originalKey] = $obj;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
foreach ($obj as $k => $v) {
|
||||
if (empty($v)) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($v, $match)) {
|
||||
// 正则匹配错误
|
||||
$regrror[$originalKey] = [
|
||||
$k => $v,
|
||||
'match' => $match,
|
||||
'key' => $currentKey,
|
||||
];
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($regrror) > 0) {
|
||||
print_r("正则匹配错误的数据:\n");
|
||||
print_r($regrror);
|
||||
exit();
|
||||
}
|
||||
if (count($redundants) > 0) {
|
||||
print_r("多余的数据:\n");
|
||||
print_r(implode(", ", array_keys($redundants)) . "\n\n");
|
||||
}
|
||||
|
||||
// 需要翻译的数据
|
||||
foreach ($originals as $text) {
|
||||
$key = trim($text);
|
||||
if (!isset($translations[$key])) {
|
||||
$needs[$key] = $key;
|
||||
}
|
||||
}
|
||||
if (count($needs) > 0) {
|
||||
$array = array_chunk($needs, 10, true);
|
||||
$success = [];
|
||||
$error = [];
|
||||
$done = 0;
|
||||
foreach ($array as $index => $keys) {
|
||||
// 生成翻译内容
|
||||
foreach ($keys as &$key) {
|
||||
$c = 1;
|
||||
$key = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) {
|
||||
$label = strlen($m[1]) > 1 ? "M" : "T";
|
||||
return "(%" . $label . $c++ . ")";
|
||||
}, $key);
|
||||
}
|
||||
$content = implode("\n", $keys);
|
||||
|
||||
// 开始翻译
|
||||
print_r("正在翻译:" . (count($keys) + $done) . "/" . count($needs) . "...\n");
|
||||
$openAi = new OpenAi($openAiKey);
|
||||
if ($openAiProxy !== '') {
|
||||
$openAi->setProxy($openAiProxy);
|
||||
}
|
||||
$result = $openAi->chat([
|
||||
"model" => "gpt-5.2",
|
||||
"reasoning_effort" => "low",
|
||||
'messages' => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => <<<EOF
|
||||
你是一个专业的翻译器,翻译的结果尽量符合 “项目任务管理系统” 的使用,请将提供的内容按每行一个翻译成:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"key": "", // 原文本
|
||||
"zh": "", // 留空(不用翻译)
|
||||
"zh-CHT": "", // 繁体中文
|
||||
"en": "", // 英语
|
||||
"ko": "", // 韩语
|
||||
"ja": "", // 日语
|
||||
"de": "", // 德语
|
||||
"fr": "", // 法语
|
||||
"id": "", // 印度尼西亚语
|
||||
"ru": "" // 俄语
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
请注意:(%T1)、(%T2)、(%T3)、(%M1)、(%M2) ...... 这类以 `小括号(%+内容)` 的字符组合是一个变量,翻译时请保留。
|
||||
|
||||
例子1:
|
||||
原文:此(%T1)已经处于【(%T2)】共享文件夹中,无法重复共享。
|
||||
翻译成英语:This (%T1) is already in the 【(%T2)】 shared folder and cannot be shared again。
|
||||
|
||||
例子2:
|
||||
原文:(%T1)的周报[(%T2)][(%T3)月第(%T4)周]
|
||||
翻译成英语:Weekly report of (%T1) [(%T2)] [(Week (%T4) of (%T3) month)]
|
||||
|
||||
例子3:
|
||||
原文:(%T1)提交的「(%M2)」待你审批
|
||||
翻译成英语:'(%M2)' submitted by (%T1) is waiting for your approval
|
||||
|
||||
例子4:
|
||||
原文:您发起的「(%M1)」已通过
|
||||
翻译成英语:The '(%M1)' you initiated has been approved
|
||||
EOF,
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => $content,
|
||||
],
|
||||
]
|
||||
]);
|
||||
|
||||
// 处理结果
|
||||
$obj = json_decode($result);
|
||||
$txt = preg_replace('/(^\s*```json\s*|\s*```\s*$)/', "", $obj->choices[0]->message->content);
|
||||
$txt = preg_replace('/\(%([TM]\d+)\)/', '(%$1)', $txt);
|
||||
$arr = json_decode($txt, true);
|
||||
if (!$arr || !is_array($arr)) {
|
||||
$error = array_merge($error, array_flip($keys));
|
||||
print_r("翻译失败:\n" . $content . "\n\n");
|
||||
file_put_contents("translate-gpt.log", json_encode($obj, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n\n", FILE_APPEND);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证结果
|
||||
foreach ($arr as $item) {
|
||||
if (empty($item['key'])) {
|
||||
print_r("翻译结果不符合规范:key为空。\n");
|
||||
print_r($item);
|
||||
continue;
|
||||
}
|
||||
foreach (['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru'] as $lang) {
|
||||
if (!isset($item[$lang])) {
|
||||
print_r("翻译结果不符合规范:{$item['key']},缺少:{$lang} 的值。\n");
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$currentKey = $item['key'];
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
foreach ($item as $k => $v) {
|
||||
if (empty($v)) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($v, $match)) {
|
||||
// 正则匹配错误
|
||||
$error[$originalKey] = [
|
||||
'key' => $currentKey,
|
||||
$k => $v,
|
||||
'match' => $match,
|
||||
];
|
||||
continue 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$item['zh'] = "";
|
||||
$translations[$originalKey] = $item;
|
||||
$success[$originalKey] = $item;
|
||||
}
|
||||
print_r("翻译完成:" . (count($keys) + $done) . "/" . count($needs) . "\n\n");
|
||||
$done += count($keys);
|
||||
}
|
||||
|
||||
if (count($error) > 0) {
|
||||
print_r("正则匹配错误的数据:\n");
|
||||
print_r(json_encode(array_values($error), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n\n");
|
||||
}
|
||||
|
||||
// 保存翻译结果
|
||||
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
print_r("----------------\n\n");
|
||||
print_r("总翻译:" . count($needs) . " 条\n");
|
||||
print_r("成功:" . count($success) . " 条\n");
|
||||
print_r("错误:" . count($error) . " 条\n\n");
|
||||
print_r("----------------\n\n");
|
||||
}
|
||||
|
||||
// 生成前端使用的文件
|
||||
foreach ($generateds as $type => $array) {
|
||||
$datas = [];
|
||||
foreach ($array as $text) {
|
||||
$text = trim($text);
|
||||
if (isset($translations[$text])) {
|
||||
$datas[] = $translations[$text];
|
||||
}
|
||||
}
|
||||
// 按长度排序
|
||||
$inOrder = [];
|
||||
foreach ($datas as $index => $item) {
|
||||
if (preg_match('/\(%[TM]\d+\)/', $item['key'])) {
|
||||
$inOrder[$index] = strlen($item['key']);
|
||||
} else {
|
||||
$inOrder[$index] = strlen($item['key']) + 10000000000;
|
||||
}
|
||||
}
|
||||
array_multisort($inOrder, SORT_DESC, $datas);
|
||||
// 合成数组
|
||||
$results = [];
|
||||
$index = 0;
|
||||
foreach ($datas as $items) {
|
||||
foreach ($items as $kk => $item) {
|
||||
if (!isset($results)) {
|
||||
$results[$kk] = [];
|
||||
}
|
||||
$results[$kk][] = $item;
|
||||
}
|
||||
}
|
||||
// 生成文件
|
||||
if ($type === 'api') {
|
||||
if (!is_dir("../public/language/api")) {
|
||||
mkdir("../public/language/api", 0777, true);
|
||||
}
|
||||
foreach ($results as $kk => $item) {
|
||||
$file = "../public/language/api/$kk.json";
|
||||
file_put_contents($file, json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
} elseif ($type === 'web') {
|
||||
if (!is_dir("../public/language/web")) {
|
||||
mkdir("../public/language/web", 0777, true);
|
||||
}
|
||||
foreach ($results as $kk => $item) {
|
||||
$file = "../public/language/web/$kk.js";
|
||||
file_put_contents($file, "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
print_r("[$type] total: " . count($results['key']) . "\n");
|
||||
}
|
||||
|
||||
print_r("\n任务结束\n");
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user