Compare commits

..

No commits in common. "pro" and "v0.31.75" have entirely different histories.

1923 changed files with 123023 additions and 199370 deletions

View File

@ -1 +0,0 @@
.claude

View File

@ -1,119 +0,0 @@
---
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`

View File

@ -1,76 +0,0 @@
---
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

View File

@ -1,74 +0,0 @@
---
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_IDxxx已被其他实例使用` → 停止,让用户清空 `.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

View File

@ -1,204 +0,0 @@
---
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` 权限;触发后可挂后台轮询结果。
- 选「不发布」则结束。
## 失败处理
任何步骤失败立即停止、报告错误信息,交用户决定;不要自动重试或跳过。

View File

@ -1,239 +0,0 @@
<?php
// DooTask 发布——翻译流水线(纯本地 phphost 直接跑,不进容器、不调 OpenAI、不需 autoload
// 逐行对齐 language/translate.php 的检测/保存/生成逻辑,唯独把"调用外部模型翻译"那一段抽走,
// 翻译改在技能流程内完成。用 php 而非 node 的唯一原因array_multisort + json_encode
// 的逐字节产物必须与项目原生工具一致,否则每次发版都会产生大面积排序/转义噪声 diff已验证 host php 可字节级复现)。
//
// 子命令:
// language.php diff
// —— 输出 JSONneeds(待翻译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);

View File

@ -1,47 +0,0 @@
#!/usr/bin/env node
// 计算并写入新版本号到 package.jsonversion + 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));

View File

@ -1,83 +0,0 @@
---
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`

View File

@ -10,14 +10,13 @@ APP_URL=http://localhost
APP_ID= APP_ID=
APP_IPPR= APP_IPPR=
APP_PORT=2222 APP_PORT=2222
APP_SSL_PORT=
APP_DEV_PORT= APP_DEV_PORT=
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=mariadb DB_HOST="${APP_IPPR}.5"
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=dootask DB_DATABASE=dootask
DB_USERNAME=dootask DB_USERNAME=dootask
@ -34,7 +33,7 @@ SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_HOST=redis REDIS_HOST="${APP_IPPR}.4"
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
@ -57,6 +56,9 @@ PUSHER_APP_KEY=
PUSHER_APP_SECRET= PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1 PUSHER_APP_CLUSTER=mt1
JUKE_KEY_JOKE=
JUKE_KEY_SOUP=
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

View File

@ -1,432 +0,0 @@
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

33
.github/workflows/publish-desktop.yml vendored Normal file
View File

@ -0,0 +1,33 @@
name: Publish Desktop
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: macos-latest
environment: build
if: startsWith(github.event.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use Node.js 16.x
uses: actions/setup-node@v1
with:
node-version: 16.x
- name: Build
env:
APPLEID: ${{ secrets.APPLEID }}
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
DP_KEY: ${{ secrets.DP_KEY }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GH_REPOSITORY: ${{ github.repository }}
run: ./cmd electron all

View File

@ -1,286 +0,0 @@
name: "Publish"
on:
push:
branches:
- "pro"
jobs:
check-version:
permissions:
contents: read
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.check-tag.outputs.should_release }}
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
- name: Check if tag exists
id: check-tag
run: |
VERSION=${{ steps.get-version.outputs.version }}
if git ls-remote --tags origin | grep -q "refs/tags/v${VERSION}$"; then
echo "This version v${VERSION} has been released"
echo "should_release=false" >> $GITHUB_OUTPUT
else
echo "Version v${VERSION} has not been released, continue building"
echo "should_release=true" >> $GITHUB_OUTPUT
fi
create-release:
needs: check-version
if: needs.check-version.outputs.should_release == 'true'
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
steps:
- uses: actions/checkout@v4
- name: Create Release
id: create-release
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const version = '${{ needs.check-version.outputs.version }}';
// 从 CHANGELOG.md 提取当前版本段落
let changelog = '';
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();
}
}
// 创建 release
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `v${version}`,
name: version,
body: changelog || 'No significant changes in this release.',
draft: true,
prerelease: false
})
return data.id
pack-vendor:
needs: [ check-version, create-release ]
if: needs.check-version.outputs.should_release == 'true'
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.0'
extensions: mbstring, intl, gd, xml, zip, swoole
tools: composer:v2
- name: Install Dependencies
run: composer install
- name: Create Vendor Archive
run: tar -czf vendor.tar.gz vendor/
- name: Upload Vendor Archive
uses: actions/github-script@v7
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
const fs = require('fs');
const data = await fs.promises.readFile('vendor.tar.gz');
await github.rest.repos.uploadReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.RELEASE_ID,
name: 'vendor.tar.gz',
data: data
});
build-client:
needs: [ check-version, create-release, pack-vendor ]
if: needs.check-version.outputs.should_release == 'true'
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: "macos-latest"
build_type: "mac"
- platform: "ubuntu-latest"
build_type: "android"
- platform: "windows-latest"
build_type: "windows"
runs-on: ${{ matrix.platform }}
environment: build
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
# Android 构建步骤
- name: (Android) Build Js
if: matrix.build_type == 'android'
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: |
git submodule init
git submodule update --remote "resources/mobile"
./cmd appbuild publish
- name: (Android) Setup JDK 11
if: matrix.build_type == 'android'
uses: actions/setup-java@v3
with:
distribution: "zulu"
java-version: "11"
- name: (Android) Build App
if: matrix.build_type == 'android'
uses: nick-fields/retry@v2
with:
timeout_minutes: 20
max_attempts: 5
command: |
cd resources/mobile/platforms/android/eeuiApp
chmod +x ./gradlew
./gradlew assembleRelease --quiet
- name: (Android) Upload File
if: matrix.build_type == 'android'
env:
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
- name: (Android) Upload Release
if: matrix.build_type == 'android'
uses: actions/github-script@v7
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
const fs = require('fs');
const path = require('path');
const globby = require('globby');
// 查找 APK 文件
const files = await globby('resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release/*.apk');
for (const file of files) {
const data = await fs.promises.readFile(file);
await github.rest.repos.uploadReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.RELEASE_ID,
name: path.basename(file),
data: data
});
}
# Mac 构建步骤
- name: (Mac) Build Client
if: matrix.build_type == 'mac'
env:
APPLEID: ${{ secrets.APPLEID }}
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
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: |
./cmd electron mac
# Windows 构建步骤
- name: (Windows) Build Client
if: matrix.build_type == 'windows'
env:
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
run: |
./cmd electron win
publish-release:
needs: [ check-version, create-release, pack-vendor, build-client ]
if: needs.check-version.outputs.should_release == 'true' && github.ref == 'refs/heads/pro'
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish Release
uses: actions/github-script@v7
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.RELEASE_ID,
draft: false,
prerelease: false
})
- name: Upload Changelog & Publish to Website
env:
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 upload-changelog
node ./electron/build.js release

View File

@ -1,45 +0,0 @@
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

57
.gitignore vendored
View File

@ -1,67 +1,28 @@
# Dependencies
/node_modules /node_modules
/vendor
# Build and temporary files
/build
/public/hot /public/hot
/public/tmp /public/tmp
/tmp
/backup
# Uploads and user-generated content
/public/summary
/public/uploads/* /public/uploads/*
/public/.well-known /public/.well-known
/public/.user.ini /public/.user.ini
# Storage and configuration
/config/LICENSE
/storage/*.key /storage/*.key
/config/LICENSE
# Environment and configuration /vendor
/build
/tmp
._*
.env .env
vars.yaml
# IDE and editor files
.cursor/*
!.cursor/rules/
!.cursor/rules/**
.idea .idea
.vscode .vscode
.windsurfrules
# Development tools
.vagrant .vagrant
.phpunit.result.cache
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
# Development file
/index.html
# Testing
.phpunit.result.cache
test.*
# Logs and debug files
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
test.*
# Lock files
dootask.lock
package-lock.json package-lock.json
# Laravel/Swoole specific
laravels-timer-process.pid laravels-timer-process.pid
.DS_Store
vars.yaml
laravels.conf laravels.conf
laravels.pid laravels.pid
# System files
._*
.DS_Store
# Documentation
README_LOCAL.md
# playwright
.playwright-mcp/

13
.gitpod.yml Normal file
View File

@ -0,0 +1,13 @@
# This configuration file was automatically generated by Gitpod.
# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
# and commit this file to your remote git repository to share the goodness with others.
tasks:
- init: sudo ./cmd install
command: ./cmd dev
ports:
- port: 2222
visibility: public
- port: 22222
visibility: public

170
.prefetch
View File

@ -1,170 +0,0 @@
office/web-apps/apps/api/documents/api.js?hash={version}
office/{path}/fonts/000
office/{path}/fonts/001
office/{path}/fonts/002
office/{path}/fonts/020
office/{path}/fonts/022
office/{path}/fonts/023
office/{path}/fonts/024
office/{path}/fonts/027
office/{path}/fonts/028
office/{path}/fonts/029
office/{path}/fonts/030
office/{path}/fonts/036
office/{path}/fonts/037
office/{path}/fonts/038
office/{path}/fonts/039
office/{path}/fonts/050
office/{path}/fonts/051
office/{path}/fonts/052
office/{path}/fonts/053
office/{path}/fonts/058
office/{path}/fonts/059
office/{path}/fonts/060
office/{path}/fonts/061
office/{path}/fonts/062
office/{path}/fonts/063
office/{path}/fonts/064
office/{path}/fonts/065
office/{path}/fonts/066
office/{path}/fonts/067
office/{path}/fonts/068
office/{path}/fonts/069
office/{path}/fonts/070
office/{path}/fonts/071
office/{path}/fonts/072
office/{path}/fonts/073
office/{path}/fonts/074
office/{path}/fonts/075
office/{path}/fonts/076
office/{path}/fonts/077
office/{path}/fonts/078
office/{path}/fonts/079
office/{path}/fonts/080
office/{path}/fonts/081
office/{path}/fonts/086
office/{path}/fonts/091
office/{path}/fonts/092
office/{path}/fonts/093
office/{path}/fonts/094
office/{path}/fonts/095
office/{path}/fonts/096
office/{path}/fonts/097
office/{path}/fonts/098
office/{path}/fonts/099
office/{path}/fonts/100
office/{path}/fonts/101
office/{path}/fonts/102
office/{path}/fonts/103
office/{path}/fonts/131
office/{path}/fonts/132
office/{path}/fonts/133
office/{path}/fonts/134
office/{path}/fonts/135
office/{path}/fonts/136
office/{path}/fonts/137
office/{path}/fonts/138
office/{path}/fonts/139
office/{path}/fonts/140
office/{path}/fonts/141
office/{path}/fonts/142
office/{path}/fonts/143
office/{path}/fonts/145
office/{path}/fonts/147
office/{path}/fonts/152
office/{path}/fonts/154
office/{path}/fonts/177
office/{path}/fonts/178
office/{path}/fonts/179
office/{path}/fonts/180
office/{path}/fonts/181
office/{path}/fonts/182
office/{path}/fonts/183
office/{path}/fonts/184
office/{path}/fonts/185
office/{path}/fonts/186
office/{path}/fonts/187
office/{path}/fonts/188
office/{path}/fonts/189
office/{path}/fonts/190
office/{path}/fonts/191
office/{path}/fonts/192
office/{path}/fonts/193
office/{path}/fonts/198
office/{path}/fonts/199
office/{path}/fonts/200
office/{path}/fonts/201
office/{path}/fonts/202
office/{path}/fonts/203
office/{path}/fonts/204
office/{path}/fonts/205
office/{path}/fonts/206
office/{path}/fonts/207
office/{path}/fonts/208
office/{path}/fonts/209
office/{path}/fonts/210
office/{path}/fonts/211
office/{path}/fonts/212
office/{path}/fonts/214
office/{path}/fonts/215
office/{path}/fonts/216
office/{path}/fonts/217
office/{path}/sdkjs/cell/sdk-all-min.js
office/{path}/sdkjs/cell/sdk-all.js
office/{path}/sdkjs/common/AllFonts.js
office/{path}/sdkjs/common/AllFonts.js
office/{path}/sdkjs/common/AllFonts.js
office/{path}/sdkjs/common/Charts/ChartStyles.js
office/{path}/sdkjs/common/Charts/ChartStyles.js
office/{path}/sdkjs/common/Charts/ChartStyles.js
office/{path}/sdkjs/common/Images/fonts_thumbnail_ea@2x.png.bin
office/{path}/sdkjs/common/Images/fonts_thumbnail_ea@2x.png.bin
office/{path}/sdkjs/common/Images/fonts_thumbnail_ea@2x.png.bin
office/{path}/sdkjs/common/libfont/engine/fonts.js
office/{path}/sdkjs/common/libfont/engine/fonts.js
office/{path}/sdkjs/common/libfont/engine/fonts.js
office/{path}/sdkjs/common/libfont/engine/fonts.wasm
office/{path}/sdkjs/common/libfont/engine/fonts.wasm
office/{path}/sdkjs/common/libfont/engine/fonts.wasm
office/{path}/sdkjs/slide/sdk-all-min.js
office/{path}/sdkjs/slide/sdk-all.js
office/{path}/sdkjs/word/sdk-all-min.js
office/{path}/sdkjs/word/sdk-all.js
office/{path}/web-apps/apps/documenteditor/main/app.js
office/{path}/web-apps/apps/documenteditor/main/code.js
office/{path}/web-apps/apps/documenteditor/main/locale/zh.json
office/{path}/web-apps/apps/documenteditor/main/resources/css/app.css
office/{path}/web-apps/apps/documenteditor/main/resources/img/iconssmall@2.5x.svg
office/{path}/web-apps/apps/presentationeditor/main/app.js
office/{path}/web-apps/apps/presentationeditor/main/code.js
office/{path}/web-apps/apps/presentationeditor/main/locale/zh.json
office/{path}/web-apps/apps/presentationeditor/main/resources/css/app.css
office/{path}/web-apps/apps/presentationeditor/main/resources/img/iconsbig@2.5x.svg
office/{path}/web-apps/apps/presentationeditor/main/resources/img/iconsbig@2x.png
office/{path}/web-apps/apps/presentationeditor/main/resources/img/iconssmall@2.5x.svg
office/{path}/web-apps/apps/spreadsheeteditor/main/app.js
office/{path}/web-apps/apps/spreadsheeteditor/main/code.js
office/{path}/web-apps/apps/spreadsheeteditor/main/locale/zh.json
office/{path}/web-apps/apps/spreadsheeteditor/main/resources/css/app.css
office/{path}/web-apps/apps/spreadsheeteditor/main/resources/formula-lang/zh_desc.json
office/{path}/web-apps/apps/spreadsheeteditor/main/resources/img/iconssmall@2.5x.svg
office/{path}/web-apps/apps/spreadsheeteditor/main/resources/img/iconssmall@2x.png
office/{path}/web-apps/vendor/xregexp/xregexp-all-min.js
office/{path}/web-apps/vendor/xregexp/xregexp-all-min.js
office/{path}/web-apps/vendor/xregexp/xregexp-all-min.js
drawio/webapp/js/app.min.js
drawio/webapp/js/extensions.min.js
drawio/webapp/js/shapes-14-6-5.min.js
drawio/webapp/js/stencils.min.js
drawio/webapp/math/es5/core.js
drawio/webapp/math/es5/input/asciimath.js
drawio/webapp/math/es5/input/tex.js
drawio/webapp/math/es5/output/svg.js
drawio/webapp/math/es5/output/svg/fonts/tex.js
drawio/webapp/styles/grapheditor.css
minder/css/chunk-vendors.fe9c56c6.css
minder/js/app.aa385de3.js
minder/js/chunk-vendors.cc7455b8.js

View File

@ -1 +0,0 @@
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@ -1,62 +0,0 @@
## 项目概述
Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
## 开发命令
所有命令通过 `./cmd` 脚本执行(不要直接运行 `php artisan` 等):
- `./cmd artisan ...` / `./cmd composer ...` / `./cmd php ...` — PHP 相关命令
### AI 不要主动执行的命令
以下命令仅由用户人工触发AI 不要主动跑——包括"任务完成后 sanity check"、"看下能不能编译"等场景:
- `./cmd dev` — 用户已自行运行 dev server改完会自己 reloadAI 再跑会争抢进程
- `./cmd prod` / `./cmd build` — 发版才用,走 `/release` 流程
前端代码改动只做 Edit/Write不要为了"验证"启动 dev server。用户明确说"跑一下 / 出包"时除外。
## Gotchas
### LaravelS/Swoole
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
- 长生命周期逻辑WebSocket、定时器应复用现有模式避免阻塞协程/事件循环
### 后端
- **非 REST 路由**API 控制器(继承 `InvokeController`)在 `routes/web.php` 按资源注册路由URL 段映射为控制器方法(如 `api/project/lists``lists()`,带 action 则用双下划线:`api/project/invite/join``invite__join()`
- 路由最多两段:方法名最多一个双下划线(`method__action`),不支持 `method__action__xxx`(无对应路由,访问 404
- **响应格式**:统一使用 `Base::retSuccess($msg, $data)` / `Base::retError($msg)`,返回 `{"ret": 1, "msg": "...", "data": {...}}`——不要用 `response()->json()`
- 业务异常通过 `App\Exceptions\ApiException` 抛出,不要用通用 Exception
- 模型继承 `AbstractModel`,使用 `Model::createInstance($params)` 创建——不要用 `new Model()``Model::create()`
- 认证使用 `Doo::userId()`——不要用 `auth()->user()`
- 参数校验在控制器方法中手动进行——不要创建 FormRequest 类
- 异步任务使用 Swoole Task`app/Tasks/`)——不要用 Laravel Queue
- `app/Module/` 存放跨控制器/跨模型的业务逻辑(非标准 Laravel 目录)
- 所有表结构变更必须通过 Laravel migration禁止直接改库
### 前端
- API 调用使用 `store.dispatch("call", params)`,不要在组件中直接 axios/fetch
- `$A.modalXXX``$A.messageXXX``$A.noticeXXX` 内部自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当传入 `language: false` 时由调用方自行处理翻译
### 国际化
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
## Playwright 测试
- Playwright 测试结果放在 `tests/playwright-results/`,包含测试环境、测试用例、结果截图等信息
## 交互规范
- **提问时附带建议**:当需要向用户提问或请求澄清时,应同时提供具体的建议选项或推荐方案,帮助用户快速决策,而非仅抛出开放式问题
## 语言偏好
- 回复一律使用简体中文,除非用户明确要求其他语言

162
README.md
View File

@ -1,150 +1,146 @@
# DooTask - Open Source Task Management System # Install (Docker)
English | **[中文文档](./README_CN.md)** English | **[中文文档](./README_CN.md)**
- [Screenshot Preview](./README_PREVIEW.md) - [Screenshot Preview](README_PREVIEW.md)
- [Demo Site](http://www.dootask.com/) - [Demo site](http://www.dootask.com/)
**QQ Group** **QQ Group**
- Group Number: `546574618` Group No.: `546574618`
## 📍 Migration from 0.x to 1.x ## Setup
- Please ensure to back up your data before upgrading! - `Docker v20.10+` & `Docker Compose v2.0+` must be installed
- If the upgrade fails, try running `./cmd update` multiple times. - System: `Centos/Debian/Ubuntu/macOS`
- If you encounter "Container xxx not found" during upgrade, run `./cmd reup` and then execute `./cmd update`. - Hardware suggestion: 2 cores and above 4G memory
- If you see a 502 error after upgrading, run `./cmd reup` to restart the services.
- If you encounter "Application 'xxx' not installed" after upgrading, log in with the admin account and install the relevant applications from the App Store.
## Installation Requirements ### Deployment (Pro Edition)
- 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
```bash ```bash
# 1、Clone the project to your local machine or server # 1、Clone the repository
# Clone project from GitHub # Clone projects on github
git clone --depth=1 https://github.com/kuaifan/dootask.git git clone -b pro --depth=1 https://github.com/kuaifan/dootask.git
# Or you can use Gitee # Or you can use gitee
git clone --depth=1 https://gitee.com/aipaw/dootask.git git clone -b pro --depth=1 https://gitee.com/aipaw/dootask.git
# 2、Enter directory # 2、Enter directory
cd dootask cd dootask
# 3、One-click installation (Custom port installation: ./cmd install --port 80) # 3、InstallationCustom port installation: ./cmd install --port 2222
./cmd install ./cmd install
``` ```
### Reset Password ### Reset password
```bash ```bash
# Reset default administrator password # Reset default account password
./cmd repassword ./cmd repassword
``` ```
### Change Port ### Change port
```bash ```bash
# This method only changes HTTP port. For HTTPS port, please read SSL configuration below ./cmd port 2222
./cmd port 80
``` ```
### Stop Service ### Change App Url
```bash ```bash
./cmd down # This URL only affects the email reply.
./cmd url {Your domain url}
# example:
./cmd url https://domain.com
``` ```
### Start Service ### Stop server
```bash ```bash
./cmd up ./cmd stop
# P.S: Once application is set up, whenever you want to start the server (if it is stopped) run below command
./cmd start
``` ```
### Development & Build ### Development compilation
Please ensure you have installed `NodeJs 20+`
```bash ```bash
# Development mode # Development mode, Mac OS only
./cmd dev ./cmd dev
# Build project (This is for web client. For desktop apps, refer to ".github/workflows/publish.yml") # Production projects, macOS only
./cmd prod ./cmd prod
``` ```
### SSL Configuration ### Shortcuts for running command
#### Method 1: Automatic Configuration ```bash
# You can do this using the following command
```bash ./cmd artisan "your command" # To run a artisan command
# Run command and follow the prompts ./cmd php "your command" # To run a php command
./cmd https ./cmd nginx "your command" # To run a nginx command
./cmd redis "your command" # To run a redis command
./cmd composer "your command" # To run a composer command
./cmd supervisorctl "your command" # To run a supervisorctl command
./cmd test "your command" # To run a phpunit command
./cmd mysql "your command" # To run a mysql command (backup: Backup database, recovery: Restore database)
``` ```
#### Method 2: Nginx Proxy Configuration ### NGINX PROXY SSL
```bash ```bash
# 1、Add Nginx proxy configuration # 1、Nginx config add
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 2、Run command (To cancel Nginx proxy configuration: ./cmd https close) # 2、Running commands in a project
./cmd https agent ./cmd https
``` ```
## Upgrade & Update ## Upgrade
**Note: Please backup your data before upgrading!** **Note: Please back up your data before upgrading!**
```bash ```bash
# Method 1: Running commands in a project
./cmd update ./cmd update
```
* Please retry if upgrade fails across major versions. # Or method 2: use this method if method 1 fails
* If you encounter 502 errors after upgrade, run `./cmd reup` to restart services. git pull
## Project Migration
After installing the new project, follow these steps to complete migration:
1、Backup the MariaDB database
```bash
# Run command in the old project
./cmd mysql backup ./cmd mysql backup
``` ./cmd uninstall
./cmd install
> `./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`
- `docker/appstore`
- `public/uploads`
3、Restore database to new project
```bash
# Run command in the new project
./cmd mysql recovery ./cmd mysql recovery
``` ```
## Uninstall Project * Please try again if the upgrade fails across a large version.
* If 502 after the upgrade please run `./cmd restart` restart the service.
## Transfer
Follow these steps to complete the project migration after the new project is installed:
1. Backup original database
```bash ```bash
# Run command under old project
./cmd mysql backup
```
2. Copy `database backup file` and `public/uploads` directory to the new project.
3. Restore database to new project
```bash
# Run command under new project
./cmd mysql recovery
```
## Uninstall
```bash
# Running commands in a project
./cmd uninstall ./cmd uninstall
``` ```
### More Commands
```bash
./cmd help
```

21
README_CLIENT.md Normal file
View File

@ -0,0 +1,21 @@
# 客户端说明
## 1、App客户端
#### 1.1、说明
目录 `resources/mobile`,使用`eeui.app`框架遵从eeui的开发文档进行打包开发app
#### 1.2、编译App
1. 在项目目录执行 `./cmd appbuild` 编译
2. 进入 `resources/mobile` eeui框架内打包Android或iOS应用
## 2、PC/Mac客户端
#### 2.1、说明
目录 `electron`,使用`electron`框架遵从electron的开发文档进行打包客户端
#### 2.2、编译客户端
在项目目录执行 `./cmd electron` 根据提示编译

View File

@ -1,44 +1,34 @@
# DooTask - 开源任务管理系统 # Install (Docker)
**[English](./README.md)** | 中文文档 **[English](./README.md)** | 中文文档
- [截图预览](./README_PREVIEW.md) - [截图预览](README_PREVIEW.md)
- [演示站点](http://www.dootask.com/) - [演示站点](http://www.dootask.com/)
**QQ交流群** **QQ交流群**
- QQ群号: `546574618` - QQ群号: `546574618`
## 📍 0.x 迁移到 1.x
- 升级时请务必备份好数据!
- 如果升级失败请尝试执行 `./cmd update` 重试几次。
- 如果升级中出现 `没有找到 xxx 容器` 的提示,请运行 `./cmd reup` 后再执行 `./cmd update`
- 如果升级后出现502错误请运行 `./cmd reup` 重启服务即可。
- 如果升级后出现 `应用「xxx」未安装` 的提示,请使用管理员账号进入应用商店安装相关应用。
## 安装程序 ## 安装程序
- 必须安装:`Docker v20.10+``Docker Compose v2.0+` - 必须安装:`Docker v20.10+``Docker Compose v2.0+`
- 支持环境:`Centos/Debian/Ubuntu/macOS` 等 linux/unix 系统 - 支持环境:`Centos/Debian/Ubuntu/macOS`
- 硬件建议2核4G以上 - 硬件建议2核4G以上
- 数据库MariaDB默认 Docker Compose 中的 `mariadb` 服务)
- 特别说明Windows 可以使用 WSL2 安装 Linux 环境后再安装 DooTask。
### 部署项目 ### 部署项目Pro版
```bash ```bash
# 1、克隆项目到您的本地或服务器 # 1、克隆项目到您的本地或服务器
# 通过github克隆项目 # 通过github克隆项目
git clone --depth=1 https://github.com/kuaifan/dootask.git git clone -b pro --depth=1 https://github.com/kuaifan/dootask.git
# 或者你也可以使用gitee # 或者你也可以使用gitee
git clone --depth=1 https://gitee.com/aipaw/dootask.git git clone -b pro --depth=1 https://gitee.com/aipaw/dootask.git
# 2、进入目录 # 2、进入目录
cd dootask cd dootask
# 3、一键安装项目自定义端口安装,如:./cmd install --port 80 # 3、一键安装项目自定义端口安装 ./cmd install --port 2222
./cmd install ./cmd install
``` ```
@ -52,44 +42,54 @@ cd dootask
### 更换端口 ### 更换端口
```bash ```bash
# 此方法仅更换http端口更换https端口请阅读下面SSL配置 ./cmd port 2222
./cmd port 80 ```
### 更换URL
```bash
# 此地址仅影响邮件回复功能
./cmd url {域名地址}
# 例如:
./cmd url https://domain.com
``` ```
### 停止服务 ### 停止服务
```bash ```bash
./cmd down ./cmd stop
```
### 启动服务 # 一旦应用程序被设置,无论何时你想要启动服务器(如果它被停止)运行以下命令
./cmd start
```bash
./cmd up
``` ```
### 开发编译 ### 开发编译
请确保你已经安装了 `NodeJs 20+`
```bash ```bash
# 开发模式 # 开发模式仅限macOS
./cmd dev ./cmd dev
# 编译项目(这是网页端的,客户端请参考“.github/workflows/publish.yml”文件 # 编译项目仅限macOS
./cmd prod ./cmd prod
``` ```
### SSL 配置
#### 方法1自动配置 ### 运行命令的快捷方式
```bash ```bash
# 执行指令,根据提示执行即可 # 你可以使用以下命令来执行
./cmd https ./cmd artisan "your command" # 运行 artisan 命令
./cmd php "your command" # 运行 php 命令
./cmd nginx "your command" # 运行 nginx 命令
./cmd redis "your command" # 运行 redis 命令
./cmd composer "your command" # 运行 composer 命令
./cmd supervisorctl "your command" # 运行 supervisorctl 命令
./cmd test "your command" # 运行 phpunit 命令
./cmd mysql "your command" # 运行 mysql 命令 (backup: 备份数据库recovery: 还原数据库)
``` ```
#### 方法2Nginx 代理配置 ### NGINX 代理 SSL
```bash ```bash
# 1、Nginx 代理配置添加 # 1、Nginx 代理配置添加
@ -97,8 +97,8 @@ proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 2、执行指令(如果取消 Nginx 代理配置请运行:./cmd https close # 2、在项目下运行命令
./cmd https agent ./cmd https
``` ```
## 升级更新 ## 升级更新
@ -106,45 +106,42 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
**注意:在升级之前请备份好你的数据!** **注意:在升级之前请备份好你的数据!**
```bash ```bash
# 方法1在项目下运行命令
./cmd update ./cmd update
# 或者方法2如果方法1失败请使用此方法
git pull
./cmd mysql backup
./cmd uninstall
./cmd install
./cmd mysql recovery
``` ```
* 跨越大版本升级失败时请重试执行一次。 * 跨越大版本升级失败时请重试执行一次。
* 如果升级后出现502请运行 `./cmd reup` 重启服务即可。 * 如果升级后出现502请运行 `./cmd restart` 重启服务即可。
## 迁移项目 ## 迁移项目
在新项目安装好之后按照以下步骤完成项目迁移: 在新项目安装好之后按照以下步骤完成项目迁移:
1、备份 MariaDB 数据库 1、备份数据库
```bash ```bash
# 在旧的项目下执行指 # 在旧的项目下运行命
./cmd mysql backup ./cmd mysql backup
``` ```
> `./cmd mysql` 为 CLI 子命令名称,实际操作的是 MariaDB 容器。 2、将`数据库备份文件``public/uploads`目录拷贝至新项目
2、将旧项目以下文件和目录拷贝至新项目同路径位置
- `数据库备份文件`
- `docker/appstore`
- `public/uploads`
3、还原数据库至新项目 3、还原数据库至新项目
```bash ```bash
# 在新的项目下执行指 # 在新的项目下运行命令
./cmd mysql recovery ./cmd mysql recovery
``` ```
## 卸载项目 ## 卸载项目
```bash ```bash
# 在项目下运行命令
./cmd uninstall ./cmd uninstall
``` ```
### 更多指令
```bash
./cmd help
```

View File

@ -1,31 +1,26 @@
# 发布 # 发布说明
## 准备工作 ## 发布前
1. 添加环境变量 `APPLEID``APPLEIDPASS` 用于公证 1. 添加环境变量 `APPLEID``APPLEIDPASS` 用于公证
2. 添加环境变量 `CSC_LINK``CSC_KEY_PASSWORD` 用于签名 2. 添加环境变量 `CSC_LINK``CSC_KEY_PASSWORD` 用于签名
3. 添加环境变量 `GITHUB_TOKEN`、`GITHUB_REPOSITORY` 用于发布到GitHubGitHub Actions 发布不需要) 3. 添加环境变量 `GH_TOKEN`、`GH_REPOSITORY` 用于发布到GitHub
4. 添加环境变量 `PUBLISH_KEY` 用于发布到私有服务器 4. 添加环境变量 `DP_KEY` 用于发布到私有服务器
## 发布版本 ## 通过 GitHub Actions 发布
> 翻译、版本号、更新日志改由 `dootask-release` 技能完成(见 `.claude/skills/dootask-release/`)。 1. 执行 `npm run version` 生成版本
2. 执行 `npm run build` 编译前端
3. 执行 `git commit` 提交并推送
4. 添加并推送标签
```shell ## 本地发布
npm run build # 编译前端
```
说明: 1. 执行 `npm run version` 生成版本
2. 执行 `npm run build` 编译前端
3. 执行 `./cmd electron` 相关操作
- 执行 `npm run build` 作用是生成网页端; ## 编译App
- 客户端 (Windows、Mac、Android) 会通过 GitHub Actions 自动生成并发布所以如果要自动发布只需要提交git并推送即可
- 如果想手动生成客户端执行 `./cmd electron` 根据提示选择操作。
1. 执行 `./cmd appbuild``./cmd appbuild setting` 编译
## 编译 App 2. 进入 `resources/mobile` eeui框架内打包Android或iOS应用
```shell
./cmd appbuild publish # 编译生成App需要的资源
```
编译完后进入 `resources/mobile` EEUI框架目录内打包 Android 或 iOS 应用Android 以实现 GitHub Actions 自动发布)

View File

@ -16045,7 +16045,7 @@
/** /**
* *
* *
* @see \Maatwebsite\Excel\Mixins\DownloadCollectionMixin::downloadExcel() * @see \Maatwebsite\Excel\Mixins\DownloadCollection::downloadExcel()
* @param string $fileName * @param string $fileName
* @param string|null $writerType * @param string|null $writerType
* @param mixed $withHeadings * @param mixed $withHeadings
@ -16059,7 +16059,7 @@
/** /**
* *
* *
* @see \Maatwebsite\Excel\Mixins\StoreCollectionMixin::storeExcel() * @see \Maatwebsite\Excel\Mixins\StoreCollection::storeExcel()
* @param string $filePath * @param string $filePath
* @param string|null $disk * @param string|null $disk
* @param string|null $writerType * @param string|null $writerType
@ -16439,247 +16439,6 @@
} }
}
namespace Laravolt\Avatar {
/**
*
*
*/
class Facade {
/**
*
*
* @static
*/
public static function setGenerator($generator)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setGenerator($generator);
}
/**
*
*
* @static
*/
public static function create($name)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->create($name);
}
/**
*
*
* @static
*/
public static function applyTheme($config)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->applyTheme($config);
}
/**
*
*
* @static
*/
public static function addTheme($name, $config)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->addTheme($name, $config);
}
/**
*
*
* @static
*/
public static function toBase64()
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->toBase64();
}
/**
*
*
* @static
*/
public static function save($path, $quality = 90)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->save($path, $quality);
}
/**
*
*
* @static
*/
public static function toSvg()
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->toSvg();
}
/**
*
*
* @static
*/
public static function toGravatar($param = null)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->toGravatar($param);
}
/**
*
*
* @static
*/
public static function getInitial()
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->getInitial();
}
/**
*
*
* @static
*/
public static function getImageObject()
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->getImageObject();
}
/**
*
*
* @static
*/
public static function buildAvatar()
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->buildAvatar();
}
/**
*
*
* @static
*/
public static function getAttribute($key)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->getAttribute($key);
}
/**
*
*
* @static
*/
public static function setTheme($theme)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setTheme($theme);
}
/**
*
*
* @static
*/
public static function setBackground($hex)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setBackground($hex);
}
/**
*
*
* @static
*/
public static function setForeground($hex)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setForeground($hex);
}
/**
*
*
* @static
*/
public static function setDimension($width, $height = null)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setDimension($width, $height);
}
/**
*
*
* @static
*/
public static function setFontSize($size)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setFontSize($size);
}
/**
*
*
* @static
*/
public static function setFontFamily($font)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setFontFamily($font);
}
/**
*
*
* @static
*/
public static function setBorder($size, $color, $radius = 0)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setBorder($size, $color, $radius);
}
/**
*
*
* @static
*/
public static function setBorderRadius($radius)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setBorderRadius($radius);
}
/**
*
*
* @static
*/
public static function setShape($shape)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setShape($shape);
}
/**
*
*
* @static
*/
public static function setChars($chars)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setChars($chars);
}
/**
*
*
* @static
*/
public static function setFont($font)
{
/** @var \Laravolt\Avatar\Avatar $instance */
return $instance->setFont($font);
}
}
} }
namespace Maatwebsite\Excel\Facades { namespace Maatwebsite\Excel\Facades {
@ -16708,10 +16467,9 @@
/** /**
* *
* *
* @param string|null $disk Fallback for usage with named properties
* @param object $export * @param object $export
* @param string $filePath * @param string $filePath
* @param string|null $diskName * @param string|null $disk
* @param string $writerType * @param string $writerType
* @param mixed $diskOptions * @param mixed $diskOptions
* @return bool * @return bool
@ -16719,10 +16477,10 @@
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
* @static * @static
*/ */
public static function store($export, $filePath, $diskName = null, $writerType = null, $diskOptions = [], $disk = null) public static function store($export, $filePath, $diskName = null, $writerType = null, $diskOptions = [])
{ {
/** @var \Maatwebsite\Excel\Excel $instance */ /** @var \Maatwebsite\Excel\Excel $instance */
return $instance->store($export, $filePath, $diskName, $writerType, $diskOptions, $disk); return $instance->store($export, $filePath, $diskName, $writerType, $diskOptions);
} }
/** /**
* *
@ -16940,7 +16698,7 @@
* @param $pathToFile string The file to open * @param $pathToFile string The file to open
* @param \Madnest\Madzipper\Repositories\RepositoryInterface|string $type The type of the archive, defaults to zip, possible are zip, phar * @param \Madnest\Madzipper\Repositories\RepositoryInterface|string $type The type of the archive, defaults to zip, possible are zip, phar
* @throws \RuntimeException * @throws \RuntimeException
* @throws Exception * @throws \Exception
* @throws \InvalidArgumentException * @throws \InvalidArgumentException
* @return \Madnest\Madzipper\Madzipper Madzipper instance * @return \Madnest\Madzipper\Madzipper Madzipper instance
* @static * @static
@ -16954,7 +16712,7 @@
* Create a new zip archive or open an existing one. * Create a new zip archive or open an existing one.
* *
* @param string $pathToFile * @param string $pathToFile
* @throws Exception * @throws \Exception
* @return self * @return self
* @static * @static
*/ */
@ -16967,7 +16725,7 @@
* Create a new phar file or open one. * Create a new phar file or open one.
* *
* @param string $pathToFile * @param string $pathToFile
* @throws Exception * @throws \Exception
* @return self * @return self
* @static * @static
*/ */
@ -16980,7 +16738,7 @@
* Create a new rar file or open one. * Create a new rar file or open one.
* *
* @param string $pathToFile * @param string $pathToFile
* @throws Exception * @throws \Exception
* @return self * @return self
* @static * @static
*/ */
@ -16997,7 +16755,7 @@
* @param $path string The path to extract to * @param $path string The path to extract to
* @param array $files An array of files * @param array $files An array of files
* @param int $methodFlags The Method the files should be treated * @param int $methodFlags The Method the files should be treated
* @throws Exception * @throws \Exception
* @return void * @return void
* @static * @static
*/ */
@ -17024,7 +16782,7 @@
* Gets the content of a single file if available. * Gets the content of a single file if available.
* *
* @param $filePath string The full path (including all folders) of the file in the zip * @param $filePath string The full path (including all folders) of the file in the zip
* @throws Exception * @throws \Exception
* @return mixed returns the content or throws an exception * @return mixed returns the content or throws an exception
* @static * @static
*/ */
@ -19016,64 +18774,6 @@ namespace {
/** /**
* *
* *
* @see \Maatwebsite\Excel\Mixins\DownloadQueryMacro::__invoke()
* @param string $fileName
* @param string|null $writerType
* @param mixed $withHeadings
* @static
*/
public static function downloadExcel($fileName, $writerType = null, $withHeadings = false)
{
return \Illuminate\Database\Eloquent\Builder::downloadExcel($fileName, $writerType, $withHeadings);
}
/**
*
*
* @see \Maatwebsite\Excel\Mixins\StoreQueryMacro::__invoke()
* @param string $filePath
* @param string|null $disk
* @param string|null $writerType
* @param mixed $withHeadings
* @static
*/
public static function storeExcel($filePath, $disk = null, $writerType = null, $withHeadings = false)
{
return \Illuminate\Database\Eloquent\Builder::storeExcel($filePath, $disk, $writerType, $withHeadings);
}
/**
*
*
* @see \Maatwebsite\Excel\Mixins\ImportMacro::__invoke()
* @param string $filename
* @param string|null $disk
* @param string|null $readerType
* @static
*/
public static function import($filename, $disk = null, $readerType = null)
{
return \Illuminate\Database\Eloquent\Builder::import($filename, $disk, $readerType);
}
/**
*
*
* @see \Maatwebsite\Excel\Mixins\ImportAsMacro::__invoke()
* @param string $filename
* @param callable $mapping
* @param string|null $disk
* @param string|null $readerType
* @static
*/
public static function importAs($filename, $mapping, $disk = null, $readerType = null)
{
return \Illuminate\Database\Eloquent\Builder::importAs($filename, $mapping, $disk, $readerType);
}
/**
*
*
* @see \App\Providers\AppServiceProvider::boot() * @see \App\Providers\AppServiceProvider::boot()
* @static * @static
*/ */
@ -21114,7 +20814,6 @@ namespace {
class View extends \Illuminate\Support\Facades\View {} class View extends \Illuminate\Support\Facades\View {}
class Flare extends \Facade\Ignition\Facades\Flare {} class Flare extends \Facade\Ignition\Facades\Flare {}
class Image extends \Intervention\Image\Facades\Image {} class Image extends \Intervention\Image\Facades\Image {}
class Avatar extends \Laravolt\Avatar\Facade {}
class Excel extends \Maatwebsite\Excel\Facades\Excel {} class Excel extends \Maatwebsite\Excel\Facades\Excel {}
class Madzipper extends \Madnest\Madzipper\Facades\Madzipper {} class Madzipper extends \Madnest\Madzipper\Facades\Madzipper {}
class Captcha extends \Mews\Captcha\Facades\Captcha {} class Captcha extends \Mews\Captcha\Facades\Captcha {}

View File

@ -1,205 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\File;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\User;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreFile;
use App\Module\Manticore\ManticoreKeyValue;
use App\Module\Manticore\ManticoreMsg;
use App\Module\Manticore\ManticoreProject;
use App\Module\Manticore\ManticoreTask;
use App\Module\Manticore\ManticoreUser;
use Illuminate\Console\Command;
/**
* 异步向量生成命令
*
* 用于后台批量生成已索引数据的向量,与全文索引解耦
* 使用双指针追踪sync:xxxLastId全文已同步 vector:xxxLastId向量已生成
*
* 运行模式:
* - 持续处理直到所有待处理数据完成
* - 每批处理完成后休眠几秒,避免 API 过载
* - 定时器只作为兜底触发机制
*/
class GenerateManticoreVectors extends Command
{
use ManticoreSyncLock;
protected $signature = 'manticore:generate-vectors
{--type=all : 类型 (msg/file/task/project/user/all)}
{--batch=50 : 每批 embedding 数量}
{--sleep=3 : 每批处理后休眠秒数}
{--reset : 重置向量进度指针}';
protected $description = '批量生成 Manticore 已索引数据的向量';
/**
* 类型配置
*/
private const TYPE_CONFIG = [
'msg' => [
'syncKey' => 'sync:manticoreMsgLastId',
'vectorKey' => 'vector:manticoreMsgLastId',
'class' => ManticoreMsg::class,
'model' => WebSocketDialogMsg::class,
'idField' => 'id',
],
'file' => [
'syncKey' => 'sync:manticoreFileLastId',
'vectorKey' => 'vector:manticoreFileLastId',
'class' => ManticoreFile::class,
'model' => File::class,
'idField' => 'id',
],
'task' => [
'syncKey' => 'sync:manticoreTaskLastId',
'vectorKey' => 'vector:manticoreTaskLastId',
'class' => ManticoreTask::class,
'model' => ProjectTask::class,
'idField' => 'id',
],
'project' => [
'syncKey' => 'sync:manticoreProjectLastId',
'vectorKey' => 'vector:manticoreProjectLastId',
'class' => ManticoreProject::class,
'model' => Project::class,
'idField' => 'id',
],
'user' => [
'syncKey' => 'sync:manticoreUserLastId',
'vectorKey' => 'vector:manticoreUserLastId',
'class' => ManticoreUser::class,
'model' => User::class,
'idField' => 'userid',
],
];
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
if (!Apps::isInstalled("ai")) {
$this->error("应用「AI」未安装无法生成向量");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
$type = $this->option('type');
$batchSize = intval($this->option('batch'));
$sleepSeconds = intval($this->option('sleep'));
$reset = $this->option('reset');
if ($type === 'all') {
$types = array_keys(self::TYPE_CONFIG);
} else {
if (!isset(self::TYPE_CONFIG[$type])) {
$this->error("未知类型: {$type}。可用类型: msg, file, task, project, user, all");
$this->releaseLock();
return 1;
}
$types = [$type];
}
// 持续处理直到所有类型都没有待处理数据
$round = 0;
do {
$round++;
$totalPending = 0;
foreach ($types as $t) {
if ($this->shouldStop) {
break;
}
$pending = $this->processType($t, $batchSize, $reset && $round === 1);
$totalPending += $pending;
}
// 如果还有待处理数据,休眠后继续
if ($totalPending > 0 && !$this->shouldStop) {
$this->info("\n--- 第 {$round} 轮完成,剩余 {$totalPending} 条待处理,{$sleepSeconds} 秒后继续 ---\n");
sleep($sleepSeconds);
$this->setLock(); // 刷新锁
}
} while ($totalPending > 0 && !$this->shouldStop);
$this->info("\n向量生成完成(共 {$round} 轮)");
$this->releaseLock();
return 0;
}
/**
* 处理单个类型的向量生成(每次处理一批)
*
* @param string $type 类型
* @param int $batchSize 每批数量
* @param bool $reset 是否重置进度
* @return int 剩余待处理数量
*/
private function processType(string $type, int $batchSize, bool $reset): int
{
$config = self::TYPE_CONFIG[$type];
// 获取进度指针
$syncLastId = intval(ManticoreKeyValue::get($config['syncKey'], 0));
$vectorLastId = $reset ? 0 : intval(ManticoreKeyValue::get($config['vectorKey'], 0));
if ($reset) {
ManticoreKeyValue::set($config['vectorKey'], 0);
$this->info("[{$type}] 已重置向量进度指针");
}
// 计算待处理范围
$pendingCount = $syncLastId - $vectorLastId;
if ($pendingCount <= 0) {
return 0;
}
// 获取待处理的 ID 列表(每次处理 batchSize * 5 条,让 generateVectorsBatch 内部再分批调用 API
$modelClass = $config['model'];
$idField = $config['idField'];
$fetchCount = $batchSize * 5;
$ids = $modelClass::where($idField, '>', $vectorLastId)
->where($idField, '<=', $syncLastId)
->orderBy($idField)
->limit($fetchCount)
->pluck($idField)
->toArray();
if (empty($ids)) {
return 0;
}
// 批量生成向量
$manticoreClass = $config['class'];
$successCount = $manticoreClass::generateVectorsBatch($ids, $batchSize);
$currentLastId = end($ids);
// 更新向量进度指针
ManticoreKeyValue::set($config['vectorKey'], $currentLastId);
$remaining = $pendingCount - count($ids);
$this->info("[{$type}] 处理 " . count($ids) . " 条,成功 {$successCount}ID: {$vectorLastId} -> {$currentLastId},剩余 {$remaining}");
// 刷新锁
$this->setLock();
return max(0, $remaining);
}
}

View File

@ -1,188 +0,0 @@
<?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}");
}
}
}
}

View File

@ -1,155 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\File;
use App\Module\Apps;
use App\Module\Manticore\ManticoreFile;
use App\Module\Manticore\ManticoreKeyValue;
use Illuminate\Console\Command;
class SyncFileToManticore extends Command
{
use ManticoreSyncLock;
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新
*
* 清理数据
* --c: 清除索引
*
* 其他选项
* --sleep: 每批处理完成后休眠秒数
*/
protected $signature = 'manticore:sync-files {--f} {--i} {--c} {--batch=100} {--sleep=3}';
protected $description = '同步文件数据到 Manticore Search';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
// 清除索引
if ($this->option('c')) {
$this->info('清除索引...');
ManticoreKeyValue::clear();
ManticoreFile::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$this->info('开始同步文件数据...');
$this->syncFiles();
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
/**
* 同步文件数据
*/
private function syncFiles(): void
{
$lastKey = "sync:manticoreFileLastId";
$isIncremental = $this->option('i');
$sleepSeconds = intval($this->option('sleep'));
$batchSize = $this->option('batch');
$maxFileSize = ManticoreFile::getMaxFileSize();
$round = 0;
do {
$round++;
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
if ($round === 1) {
if ($lastId > 0) {
$this->info("\n增量同步文件数据从ID {$lastId} 开始)...");
} else {
$this->info("\n全量同步文件数据...");
}
}
$count = File::where('id', '>', $lastId)
->where('type', '!=', 'folder')
->where('size', '<=', $maxFileSize)
->count();
if ($count === 0) {
if ($round === 1) {
$this->info("无待同步数据");
}
break;
}
$this->info("[第 {$round} 轮] 待同步 {$count} 个文件");
$num = 0;
$total = 0;
do {
if ($this->shouldStop) {
break;
}
$files = File::where('id', '>', $lastId)
->where('type', '!=', 'folder')
->where('size', '<=', $maxFileSize)
->orderBy('id')
->limit($batchSize)
->get();
if ($files->isEmpty()) {
break;
}
$num += count($files);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
$this->info("{$num}/{$count} ({$progress}%) 文件ID {$files->first()->id} ~ {$files->last()->id}");
$this->setLock();
$syncCount = ManticoreFile::batchSync($files);
$total += $syncCount;
$lastId = $files->last()->id;
ManticoreKeyValue::set($lastKey, $lastId);
} while (count($files) == $batchSize && !$this->shouldStop);
$this->info("[第 {$round} 轮] 完成,同步 {$total}最后ID {$lastId}");
if ($isIncremental && !$this->shouldStop) {
$newCount = File::where('id', '>', $lastId)
->where('type', '!=', 'folder')
->where('size', '<=', $maxFileSize)
->count();
if ($newCount > 0) {
$this->info("发现 {$newCount} 个新文件,{$sleepSeconds} 秒后继续...");
sleep($sleepSeconds);
continue;
}
}
break;
} while (!$this->shouldStop);
$this->info("同步文件结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
$this->info("已索引文件数量: " . ManticoreFile::getIndexedCount());
}
}

View File

@ -1,232 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreMsg;
use App\Module\Manticore\ManticoreKeyValue;
use Illuminate\Console\Command;
class SyncMsgToManticore extends Command
{
use ManticoreSyncLock;
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新
*
* 清理数据
* --c: 清除索引
*
* 其他选项
* --dialog: 指定对话ID
* --sleep: 每批处理完成后休眠秒数
*/
protected $signature = 'manticore:sync-msgs {--f} {--i} {--c} {--batch=100} {--dialog=} {--sleep=3}';
protected $description = '同步消息数据到 Manticore Search';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
// 清除索引
if ($this->option('c')) {
$this->info('清除索引...');
ManticoreMsg::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$dialogId = $this->option('dialog') ? intval($this->option('dialog')) : 0;
if ($dialogId > 0) {
$this->info("开始同步对话 {$dialogId} 的消息数据...");
$this->syncDialogMsgs($dialogId);
} else {
$this->info('开始同步消息数据...');
$this->syncMsgs();
}
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
/**
* 同步所有消息
*/
private function syncMsgs(): void
{
$lastKey = "sync:manticoreMsgLastId";
$isIncremental = $this->option('i');
$sleepSeconds = intval($this->option('sleep'));
$batchSize = $this->option('batch');
$round = 0;
// 持续处理循环(增量模式下)
do {
$round++;
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
if ($round === 1) {
if ($lastId > 0) {
$this->info("\n增量同步消息数据从ID {$lastId} 开始)...");
} else {
$this->info("\n全量同步消息数据...");
}
}
// 构建基础查询条件
$count = WebSocketDialogMsg::where('id', '>', $lastId)
->whereNull('deleted_at')
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
->count();
if ($count === 0) {
if ($round === 1) {
$this->info("无待同步数据");
}
break;
}
$this->info("[第 {$round} 轮] 待同步 {$count} 条消息");
$num = 0;
$total = 0;
do {
if ($this->shouldStop) {
break;
}
$msgs = WebSocketDialogMsg::where('id', '>', $lastId)
->whereNull('deleted_at')
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
->orderBy('id')
->limit($batchSize)
->get();
if ($msgs->isEmpty()) {
break;
}
$num += count($msgs);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
$this->info("{$num}/{$count} ({$progress}%) 消息ID {$msgs->first()->id} ~ {$msgs->last()->id}");
$this->setLock();
$syncCount = ManticoreMsg::batchSync($msgs);
$total += $syncCount;
$lastId = $msgs->last()->id;
ManticoreKeyValue::set($lastKey, $lastId);
} while (count($msgs) == $batchSize && !$this->shouldStop);
$this->info("[第 {$round} 轮] 完成,同步 {$total}最后ID {$lastId}");
// 增量模式下,检查是否有新数据,有则继续
if ($isIncremental && !$this->shouldStop) {
$newCount = WebSocketDialogMsg::where('id', '>', $lastId)
->whereNull('deleted_at')
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
->count();
if ($newCount > 0) {
$this->info("发现 {$newCount} 条新数据,{$sleepSeconds} 秒后继续...");
sleep($sleepSeconds);
continue;
}
}
break; // 非增量模式或无新数据,退出循环
} while (!$this->shouldStop);
$this->info("同步消息结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
$this->info("已索引消息数量: " . ManticoreMsg::getIndexedCount());
}
/**
* 同步指定对话的消息
*
* @param int $dialogId 对话ID
*/
private function syncDialogMsgs(int $dialogId): void
{
$this->info("\n同步对话 {$dialogId} 的消息数据...");
$baseQuery = WebSocketDialogMsg::where('dialog_id', $dialogId)
->whereNull('deleted_at')
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES);
$num = 0;
$count = $baseQuery->count();
$batchSize = $this->option('batch');
$lastId = 0;
$total = 0;
$lastNum = 0;
do {
$msgs = WebSocketDialogMsg::where('dialog_id', $dialogId)
->where('id', '>', $lastId)
->whereNull('deleted_at')
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
->orderBy('id')
->limit($batchSize)
->get();
if ($msgs->isEmpty()) {
break;
}
$num += count($msgs);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
if ($progress < 100) {
$progress = number_format($progress, 2);
}
$this->info("{$num}/{$count} ({$progress}%) 正在同步消息ID {$msgs->first()->id} ~ {$msgs->last()->id} ({$total}|{$lastNum})");
$this->setLock();
$lastNum = ManticoreMsg::batchSync($msgs);
$total += $lastNum;
$lastId = $msgs->last()->id;
} while (count($msgs) == $batchSize);
$this->info("同步对话 {$dialogId} 消息结束");
$this->info("该对话已索引消息数量: " . \App\Module\Manticore\ManticoreBase::getDialogIndexedMsgCount($dialogId));
}
}

View File

@ -1,146 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\Project;
use App\Module\Apps;
use App\Module\Manticore\ManticoreProject;
use App\Module\Manticore\ManticoreKeyValue;
use Illuminate\Console\Command;
class SyncProjectToManticore extends Command
{
use ManticoreSyncLock;
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新
*
* 清理数据
* --c: 清除索引
*
* 其他选项
* --sleep: 每批处理完成后休眠秒数
*/
protected $signature = 'manticore:sync-projects {--f} {--i} {--c} {--batch=100} {--sleep=3}';
protected $description = '同步项目数据到 Manticore Search';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
if ($this->option('c')) {
$this->info('清除索引...');
ManticoreProject::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$this->info('开始同步项目数据...');
$this->syncProjects();
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
private function syncProjects(): void
{
$lastKey = "sync:manticoreProjectLastId";
$isIncremental = $this->option('i');
$sleepSeconds = intval($this->option('sleep'));
$batchSize = $this->option('batch');
$round = 0;
do {
$round++;
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
if ($round === 1) {
if ($lastId > 0) {
$this->info("\n增量同步项目数据从ID {$lastId} 开始)...");
} else {
$this->info("\n全量同步项目数据...");
}
}
$count = Project::where('id', '>', $lastId)
->whereNull('archived_at')
->count();
if ($count === 0) {
if ($round === 1) {
$this->info("无待同步数据");
}
break;
}
$this->info("[第 {$round} 轮] 待同步 {$count} 个项目");
$num = 0;
$total = 0;
do {
if ($this->shouldStop) {
break;
}
$projects = Project::where('id', '>', $lastId)
->whereNull('archived_at')
->orderBy('id')
->limit($batchSize)
->get();
if ($projects->isEmpty()) {
break;
}
$num += count($projects);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
$this->info("{$num}/{$count} ({$progress}%) 项目ID {$projects->first()->id} ~ {$projects->last()->id}");
$this->setLock();
$syncCount = ManticoreProject::batchSync($projects);
$total += $syncCount;
$lastId = $projects->last()->id;
ManticoreKeyValue::set($lastKey, $lastId);
} while (count($projects) == $batchSize && !$this->shouldStop);
$this->info("[第 {$round} 轮] 完成,同步 {$total}最后ID {$lastId}");
if ($isIncremental && !$this->shouldStop) {
$newCount = Project::where('id', '>', $lastId)
->whereNull('archived_at')
->count();
if ($newCount > 0) {
$this->info("发现 {$newCount} 个新项目,{$sleepSeconds} 秒后继续...");
sleep($sleepSeconds);
continue;
}
}
break;
} while (!$this->shouldStop);
$this->info("同步项目结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
$this->info("已索引项目数量: " . ManticoreProject::getIndexedCount());
}
}

View File

@ -1,149 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\ProjectTask;
use App\Module\Apps;
use App\Module\Manticore\ManticoreTask;
use App\Module\Manticore\ManticoreKeyValue;
use Illuminate\Console\Command;
class SyncTaskToManticore extends Command
{
use ManticoreSyncLock;
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新
*
* 清理数据
* --c: 清除索引
*
* 其他选项
* --sleep: 每批处理完成后休眠秒数
*/
protected $signature = 'manticore:sync-tasks {--f} {--i} {--c} {--batch=100} {--sleep=3}';
protected $description = '同步任务数据到 Manticore Search';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
if ($this->option('c')) {
$this->info('清除索引...');
ManticoreTask::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$this->info('开始同步任务数据...');
$this->syncTasks();
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
private function syncTasks(): void
{
$lastKey = "sync:manticoreTaskLastId";
$isIncremental = $this->option('i');
$sleepSeconds = intval($this->option('sleep'));
$batchSize = $this->option('batch');
$round = 0;
do {
$round++;
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
if ($round === 1) {
if ($lastId > 0) {
$this->info("\n增量同步任务数据从ID {$lastId} 开始)...");
} else {
$this->info("\n全量同步任务数据...");
}
}
$count = ProjectTask::where('id', '>', $lastId)
->whereNull('archived_at')
->whereNull('deleted_at')
->count();
if ($count === 0) {
if ($round === 1) {
$this->info("无待同步数据");
}
break;
}
$this->info("[第 {$round} 轮] 待同步 {$count} 个任务");
$num = 0;
$total = 0;
do {
if ($this->shouldStop) {
break;
}
$tasks = ProjectTask::where('id', '>', $lastId)
->whereNull('archived_at')
->whereNull('deleted_at')
->orderBy('id')
->limit($batchSize)
->get();
if ($tasks->isEmpty()) {
break;
}
$num += count($tasks);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
$this->info("{$num}/{$count} ({$progress}%) 任务ID {$tasks->first()->id} ~ {$tasks->last()->id}");
$this->setLock();
$syncCount = ManticoreTask::batchSync($tasks);
$total += $syncCount;
$lastId = $tasks->last()->id;
ManticoreKeyValue::set($lastKey, $lastId);
} while (count($tasks) == $batchSize && !$this->shouldStop);
$this->info("[第 {$round} 轮] 完成,同步 {$total}最后ID {$lastId}");
if ($isIncremental && !$this->shouldStop) {
$newCount = ProjectTask::where('id', '>', $lastId)
->whereNull('archived_at')
->whereNull('deleted_at')
->count();
if ($newCount > 0) {
$this->info("发现 {$newCount} 个新任务,{$sleepSeconds} 秒后继续...");
sleep($sleepSeconds);
continue;
}
}
break;
} while (!$this->shouldStop);
$this->info("同步任务结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
$this->info("已索引任务数量: " . ManticoreTask::getIndexedCount());
}
}

View File

@ -1,149 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\User;
use App\Module\Apps;
use App\Module\Manticore\ManticoreUser;
use App\Module\Manticore\ManticoreKeyValue;
use Illuminate\Console\Command;
class SyncUserToManticore extends Command
{
use ManticoreSyncLock;
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新
*
* 清理数据
* --c: 清除索引
*
* 其他选项
* --sleep: 每批处理完成后休眠秒数
*/
protected $signature = 'manticore:sync-users {--f} {--i} {--c} {--batch=100} {--sleep=3}';
protected $description = '同步用户数据到 Manticore Search';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
if ($this->option('c')) {
$this->info('清除索引...');
ManticoreUser::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$this->info('开始同步用户数据...');
$this->syncUsers();
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
private function syncUsers(): void
{
$lastKey = "sync:manticoreUserLastId";
$isIncremental = $this->option('i');
$sleepSeconds = intval($this->option('sleep'));
$batchSize = $this->option('batch');
$round = 0;
do {
$round++;
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
if ($round === 1) {
if ($lastId > 0) {
$this->info("\n增量同步用户数据从ID {$lastId} 开始)...");
} else {
$this->info("\n全量同步用户数据...");
}
}
$count = User::where('userid', '>', $lastId)
->where('bot', 0)
->whereNull('disable_at')
->count();
if ($count === 0) {
if ($round === 1) {
$this->info("无待同步数据");
}
break;
}
$this->info("[第 {$round} 轮] 待同步 {$count} 个用户");
$num = 0;
$total = 0;
do {
if ($this->shouldStop) {
break;
}
$users = User::where('userid', '>', $lastId)
->where('bot', 0)
->whereNull('disable_at')
->orderBy('userid')
->limit($batchSize)
->get();
if ($users->isEmpty()) {
break;
}
$num += count($users);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
$this->info("{$num}/{$count} ({$progress}%) 用户ID {$users->first()->userid} ~ {$users->last()->userid}");
$this->setLock();
$syncCount = ManticoreUser::batchSync($users);
$total += $syncCount;
$lastId = $users->last()->userid;
ManticoreKeyValue::set($lastKey, $lastId);
} while (count($users) == $batchSize && !$this->shouldStop);
$this->info("[第 {$round} 轮] 完成,同步 {$total}最后ID {$lastId}");
if ($isIncremental && !$this->shouldStop) {
$newCount = User::where('userid', '>', $lastId)
->where('bot', 0)
->whereNull('disable_at')
->count();
if ($newCount > 0) {
$this->info("发现 {$newCount} 个新用户,{$sleepSeconds} 秒后继续...");
sleep($sleepSeconds);
continue;
}
}
break;
} while (!$this->shouldStop);
$this->info("同步用户结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
$this->info("已索引用户数量: " . ManticoreUser::getIndexedCount());
}
}

View File

@ -1,90 +0,0 @@
<?php
namespace App\Console\Commands\Traits;
use Cache;
/**
* Manticore 同步命令通用锁机制
*
* 提供:
* - 锁的获取、设置、释放
* - 信号处理(优雅退出)
* - 通用的命令初始化检查
*/
trait ManticoreSyncLock
{
private bool $shouldStop = false;
/**
* 获取锁信息
*/
private function getLock(): ?array
{
$lockKey = $this->getLockKey();
return Cache::has($lockKey) ? Cache::get($lockKey) : null;
}
/**
* 设置锁30分钟有效期持续处理时需不断刷新
*/
private function setLock(): void
{
$lockKey = $this->getLockKey();
Cache::put($lockKey, ['started_at' => date('Y-m-d H:i:s')], 1800);
}
/**
* 释放锁
*/
private function releaseLock(): void
{
$lockKey = $this->getLockKey();
Cache::forget($lockKey);
}
/**
* 获取锁的缓存键
*/
private function getLockKey(): string
{
return md5($this->signature);
}
/**
* 信号处理器SIGINT/SIGTERM
*/
public function handleSignal(int $signal): void
{
$this->info("\n收到信号,将在当前批次完成后退出...");
$this->shouldStop = true;
}
/**
* 注册信号处理器
*/
private function registerSignalHandlers(): void
{
if (extension_loaded('pcntl')) {
pcntl_async_signals(true);
pcntl_signal(SIGINT, [$this, 'handleSignal']);
pcntl_signal(SIGTERM, [$this, 'handleSignal']);
}
}
/**
* 检查命令是否可以启动(锁检查)
*
* @return bool 返回 true 表示可以启动false 表示已被占用
*/
private function acquireLock(): bool
{
$lockInfo = $this->getLock();
if ($lockInfo) {
$this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}");
return false;
}
$this->setLock();
return true;
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Events;
use Hhxsv5\LaravelS\Swoole\Events\ServerStartInterface;
use Swoole\Http\Server;
class ServerStartEvent implements ServerStartInterface
{
public function __construct()
{
}
public function handle(Server $server)
{
$server->startMsecTime = $this->msecTime();
}
private function msecTime()
{
list($msec, $sec) = explode(' ', microtime());
$time = explode(".", $sec . ($msec * 1000));
return $time[0];
}
}

View File

@ -3,7 +3,7 @@
namespace App\Events; namespace App\Events;
use App\Models\WebSocket; use App\Models\WebSocket;
use App\Services\RequestContext; use Cache;
use Hhxsv5\LaravelS\Swoole\Events\WorkerStartInterface; use Hhxsv5\LaravelS\Swoole\Events\WorkerStartInterface;
use Swoole\Http\Server; use Swoole\Http\Server;
@ -16,16 +16,9 @@ class WorkerStartEvent implements WorkerStartInterface
public function handle(Server $server, $workerId) public function handle(Server $server, $workerId)
{ {
// 仅在Worker进程启动时执行一次初始化代码 if (isset($server->startMsecTime) && Cache::get("swooleServerStartMsecTime") != $server->startMsecTime) {
$initTable = app('swoole')->initFlagTable; Cache::forever("swooleServerStartMsecTime", $server->startMsecTime);
if ($initTable->incr('init_flag', 'value') === 1) { WebSocket::query()->delete();
$this->handleFirstWorkerTasks();
} }
} }
private function handleFirstWorkerTasks()
{
WebSocket::query()->delete();
RequestContext::clearBaseUrlCache();
}
} }

View File

@ -10,18 +10,13 @@ class ApiException extends RuntimeException
*/ */
protected $data; protected $data;
/**
* @var bool
*/
protected $writeLog = true;
/** /**
* ApiException constructor. * ApiException constructor.
* @param string|array $msg * @param string $msg
* @param array $data * @param array $data
* @param int $code * @param int $code
*/ */
public function __construct($msg = '', $data = [], $code = 0, $writeLog = true) public function __construct($msg = '', $data = [], $code = 0)
{ {
if (is_array($msg) && isset($msg['code'])) { if (is_array($msg) && isset($msg['code'])) {
$code = $msg['code']; $code = $msg['code'];
@ -29,7 +24,6 @@ class ApiException extends RuntimeException
$msg = $msg['msg']; $msg = $msg['msg'];
} }
$this->data = $data; $this->data = $data;
$this->writeLog = $writeLog && $code !== -1;
parent::__construct($msg, $code); parent::__construct($msg, $code);
} }
@ -40,12 +34,4 @@ class ApiException extends RuntimeException
{ {
return $this->data; return $this->data;
} }
/**
* @return bool
*/
public function isWriteLog(): bool
{
return $this->writeLog;
}
} }

View File

@ -3,11 +3,9 @@
namespace App\Exceptions; namespace App\Exceptions;
use App\Module\Base; use App\Module\Base;
use App\Module\Image;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable; use Throwable;
class Handler extends ExceptionHandler class Handler extends ExceptionHandler
@ -53,11 +51,6 @@ class Handler extends ExceptionHandler
*/ */
public function render($request, Throwable $e) public function render($request, Throwable $e)
{ {
if ($e instanceof NotFoundHttpException) {
if ($result = $this->ImagePathHandler($request)) {
return $result;
}
}
if ($e instanceof ApiException) { if ($e instanceof ApiException) {
return response()->json(Base::retError($e->getMessage(), $e->getData(), $e->getCode())); return response()->json(Base::retError($e->getMessage(), $e->getData(), $e->getCode()));
} elseif ($e instanceof ModelNotFoundException) { } elseif ($e instanceof ModelNotFoundException) {
@ -74,7 +67,7 @@ class Handler extends ExceptionHandler
public function report(Throwable $e) public function report(Throwable $e)
{ {
if ($e instanceof ApiException) { if ($e instanceof ApiException) {
if ($e->isWriteLog()) { if ($e->getCode() !== -1) {
Log::error($e->getMessage(), [ Log::error($e->getMessage(), [
'code' => $e->getCode(), 'code' => $e->getCode(),
'data' => $e->getData(), 'data' => $e->getData(),
@ -85,157 +78,4 @@ class Handler extends ExceptionHandler
parent::report($e); parent::report($e);
} }
} }
/**
* 图片路径处理
* @param $request
* @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\BinaryFileResponse|null
*/
private function ImagePathHandler($request)
{
$path = $request->path();
// 处理图片
$patternCrop = '/^(uploads\/.*\.(png|jpg|jpeg))\/crop\/([^\/]+)$/';
$patternThumb = '/^(uploads\/.*)_thumb\.(png|jpg|jpeg)$/';
$matchesCrop = null;
$matchesThumb = null;
if (preg_match($patternCrop, $path, $matchesCrop) || preg_match($patternThumb, $path, $matchesThumb)) {
// 获取参数
if ($matchesCrop) {
$file = $matchesCrop[1];
$ext = $matchesCrop[2];
$rules = preg_replace('/\s+/', '', $matchesCrop[3]);
$rules = str_replace(['=', '&'], [':', ','], $rules);
$rules = explode(',', $rules);
} elseif ($matchesThumb) {
$file = $matchesThumb[1];
$ext = $matchesThumb[2];
$rules = ['percentage:320x0'];
} else {
return null;
}
if (empty($rules)) {
return null;
}
// 提取年月
$Ym = date("Ym");
if (preg_match('/\/(\d{6})\//', $file, $ms)) {
$Ym = $ms[1];
}
// 文件存在直接返回
$dirName = str_replace(['/', '.'], '_', $file);
$fileName = str_replace([':', ','], ['-', '_'], implode(',', $rules)) . '.' . $ext;
$savePath = public_path('uploads/tmp/crop/' . $Ym . '/' . $dirName . '/' . $fileName);
if (file_exists($savePath)) {
// 设置头部声明图片缓存
return response()->file($savePath, [
'Pragma' => 'public',
'Cache-Control' => 'max-age=1814400',
'Expires' => gmdate('D, d M Y H:i:s', time() + 1814400) . ' GMT',
'Last-Modified' => gmdate('D, d M Y H:i:s', filemtime($savePath)) . ' GMT',
'ETag' => md5_file($savePath)
]);
}
// 文件不存在处理
$sourcePath = public_path($file);
if (!file_exists($sourcePath)) {
return null;
}
// 判断删除多余文件
$saveDir = dirname($savePath);
if (is_dir($saveDir)) {
$items = glob($saveDir . '/*');
if (count($items) > 5) {
usort($items, function ($a, $b) {
return filemtime($b) - filemtime($a);
});
$itemsToDelete = array_slice($items, 5);
foreach ($itemsToDelete as $item) {
if (is_file($item)) {
unlink($item);
}
}
}
} else {
Base::makeDir($saveDir);
}
// 处理图片
try {
$handle = 0;
$image = new Image($sourcePath);
foreach ($rules as $rule) {
if (!str_contains($rule, ':')) {
continue;
}
[$type, $value] = explode(':', $rule);
if (!in_array($type, ['ratio', 'size', 'percentage', 'cover', 'contain'])) {
continue;
}
switch ($type) {
// 按比例裁剪
case 'ratio':
if (is_numeric($value)) {
$image->ratioCrop($value);
$handle++;
}
break;
// 按尺寸缩放
case 'size':
$size = Base::newIntval(explode('x', $value));
if (count($size) === 2) {
$image->resize($size[0], $size[1]);
$handle++;
}
break;
// 按尺寸缩放
case 'percentage':
case 'cover':
case 'contain':
$size = Base::newIntval(explode('x', $value));
if (count($size) === 2) {
$image->thumb($size[0], $size[1], $type);
$handle++;
}
break;
}
}
if ($handle > 0) {
$image->saveTo($savePath);
Image::compressImage($savePath, 80);
return response()->file($savePath, [
'Pragma' => 'public',
'Cache-Control' => 'max-age=1814400',
'Expires' => gmdate('D, d M Y H:i:s', time() + 1814400) . ' GMT',
'Last-Modified' => gmdate('D, d M Y H:i:s', filemtime($savePath)) . ' GMT',
'ETag' => md5_file($savePath)
]);
} else {
$image->destroy();
}
} catch (\ImagickException) { }
}
// 容错处理
$patternFault = '/^(images\/.*\.(png|jpg|jpeg))\/crop\/([^\/]+)$/';
$matchesFault = null;
if (preg_match($patternFault, $path, $matchesFault)) {
$file = public_path($matchesFault[1]);
if (!file_exists($file)) {
$file = public_path('images/other/imgerr.jpg');
}
if (file_exists($file)) {
return response()->file($file);
}
}
return null;
}
} }

View File

@ -3,7 +3,7 @@
if (!function_exists('asset_main')) { if (!function_exists('asset_main')) {
function asset_main($path, $secure = null) function asset_main($path, $secure = null)
{ {
return preg_replace("/^https?:\/\//", "//", app('url')->asset($path, $secure)); return preg_replace("/^https*:\/\//", "//", app('url')->asset($path, $secure));
} }
} }

View File

@ -3,28 +3,22 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use Request; use Request;
use Session;
use Response; use Response;
use Madzipper; use Madzipper;
use Carbon\Carbon; use Carbon\Carbon;
use App\Module\Down;
use App\Models\User; use App\Models\User;
use App\Module\Base; use App\Module\Base;
use App\Module\Doo;
use App\Module\Timer;
use App\Module\Ihttp; use App\Module\Ihttp;
use App\Tasks\PushTask; use App\Tasks\PushTask;
use App\Module\BillExport; use App\Module\BillExport;
use App\Models\WebSocketDialog; use App\Models\WebSocketDialog;
use App\Models\ApproveProcMsg; use App\Models\ApproveProcMsg;
use App\Models\ApproveProcInstHistory;
use App\Exceptions\ApiException; use App\Exceptions\ApiException;
use App\Models\UserDepartment; use App\Models\UserDepartment;
use App\Models\WebSocketDialogMsg; use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\BillMultipleExport; use App\Module\BillMultipleExport;
use Hhxsv5\LaravelS\Swoole\Task\Task; use Hhxsv5\LaravelS\Swoole\Task\Task;
use Swoole\Coroutine;
/** /**
* @apiDefine approve * @apiDefine approve
* *
@ -33,19 +27,16 @@ use Swoole\Coroutine;
class ApproveController extends AbstractController class ApproveController extends AbstractController
{ {
private $flow_url = ''; private $flow_url = '';
public function __construct() public function __construct()
{ {
Apps::isInstalledThrow('approve');
$this->flow_url = env('FLOW_URL') ?: 'http://approve'; $this->flow_url = env('FLOW_URL') ?: 'http://approve';
} }
/** /**
* @api {get} api/approve/verifyToken 验证APi登录 * @api {get} api/approve/verifyToken 01. 验证APi登录
* *
* @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup approve * @apiGroup users
* @apiName verifyToken * @apiName verifyToken
* *
* @apiSuccess {String} version * @apiSuccess {String} version
@ -63,7 +54,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/procdef/all 查询流程定义 * @api {post} api/approve/procdef/all 02. 查询流程定义
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -80,7 +71,7 @@ class ApproveController extends AbstractController
{ {
User::auth(); User::auth();
$data['name'] = Request::input('name'); $data['name'] = Request::input('name');
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/procdef/findAll', json_encode($data)); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/procdef/findAll', json_encode($data));
$procdef = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $procdef = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$procdef || $procdef['status'] != 200 || $ret['ret'] == 0) { if (!$procdef || $procdef['status'] != 200 || $ret['ret'] == 0) {
// info($ret); // info($ret);
@ -90,7 +81,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {get} api/approve/procdef/del 删除流程定义 * @api {get} api/approve/procdef/del 03. 删除流程定义
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -107,7 +98,7 @@ class ApproveController extends AbstractController
{ {
User::auth('admin'); User::auth('admin');
$data['id'] = Request::input('id'); $data['id'] = Request::input('id');
$ret = Ihttp::ihttp_get($this->flow_url . '/api/v1/workflow/procdef/delById?' . http_build_query($data)); $ret = Ihttp::ihttp_get($this->flow_url.'/api/v1/workflow/procdef/delById?'.http_build_query($data));
$procdef = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $procdef = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$procdef || $procdef['status'] != 200) { if (!$procdef || $procdef['status'] != 200) {
return Base::retError($procdef['message'] ?? '删除失败'); return Base::retError($procdef['message'] ?? '删除失败');
@ -116,7 +107,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/process/start 启动流程(审批中) * @api {post} api/approve/process/start 04. 启动流程(审批中)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -140,7 +131,7 @@ class ApproveController extends AbstractController
// //
$var = json_decode(Request::input('var'), true); $var = json_decode(Request::input('var'), true);
$data['var'] = $var; $data['var'] = $var;
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/start', json_encode(Base::arrayKeyToCamel($data))); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/process/start', json_encode(Base::arrayKeyToCamel($data)));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '启动失败'); return Base::retError($process['message'] ?? '启动失败');
@ -179,7 +170,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/process/addGlobalComment 添加全局评论 * @api {post} api/approve/process/addGlobalComment 05. 添加全局评论
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -202,7 +193,7 @@ class ApproveController extends AbstractController
$processInst = $this->getProcessById($data['proc_inst_id']); $processInst = $this->getProcessById($data['proc_inst_id']);
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/addGlobalComment', json_encode(Base::arrayKeyToCamel($data))); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/process/addGlobalComment', json_encode(Base::arrayKeyToCamel($data)));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '添加失败'); return Base::retError($process['message'] ?? '添加失败');
@ -210,11 +201,11 @@ class ApproveController extends AbstractController
// 推送通知 // 推送通知
$botUser = User::botGetOrCreate('approval-alert'); $botUser = User::botGetOrCreate('approval-alert');
foreach ($processInst['userids'] as $id) { foreach ( $processInst['userids'] as $id) {
if ($id != $user->userid) { if($id != $user->userid){
$dialog = WebSocketDialog::checkUserDialog($botUser, $id); $dialog = WebSocketDialog::checkUserDialog($botUser, $id);
$processInst['comment_user_id'] = $user->userid; $processInst['comment_user_id'] = $user->userid;
$processInst['comment_contents'] = json_decode($data['content'], true) ?? []; $processInst['comment_content'] = json_decode($data['content'],true)['content'];
$this->approveMsg('approve_comment_notifier', $dialog, $botUser, $processInst, $processInst); $this->approveMsg('approve_comment_notifier', $dialog, $botUser, $processInst, $processInst);
} }
} }
@ -224,7 +215,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/task/complete 审批 * @api {post} api/approve/task/complete 06. 审批
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -246,7 +237,7 @@ class ApproveController extends AbstractController
$data['task_id'] = intval(Request::input('task_id')); $data['task_id'] = intval(Request::input('task_id'));
$data['pass'] = Request::input('pass'); $data['pass'] = Request::input('pass');
$data['comment'] = Request::input('comment'); $data['comment'] = Request::input('comment');
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/task/complete', json_encode(Base::arrayKeyToCamel($data))); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/task/complete', json_encode(Base::arrayKeyToCamel($data)));
$task = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $task = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$task || $task['status'] != 200) { if (!$task || $task['status'] != 200) {
return Base::retError($task['message'] ?? '审批失败'); return Base::retError($task['message'] ?? '审批失败');
@ -269,12 +260,12 @@ class ApproveController extends AbstractController
$this->approveMsg('approve_reviewer', $dialog, $botUser, $val, $process, $pass); $this->approveMsg('approve_reviewer', $dialog, $botUser, $val, $process, $pass);
} }
// 发起人 // 发起人
if ($process['is_finished']) { if($process['is_finished'] == true) {
$dialog = WebSocketDialog::checkUserDialog($botUser, $process['start_user_id']); $dialog = WebSocketDialog::checkUserDialog($botUser, $process['start_user_id']);
if (!empty($dialog)) { if (!empty($dialog)) {
$this->approveMsg('approve_submitter', $dialog, $botUser, ['userid' => $data['userid']], $process, $pass); $this->approveMsg('approve_submitter', $dialog, $botUser, ['userid' => $data['userid']], $process, $pass);
} }
} else if ($process['candidate']) { }else if ($process['candidate']) {
// 下个审批人 // 下个审批人
$userid = explode(',', $process['candidate']); $userid = explode(',', $process['candidate']);
$toUser = User::whereIn('userid', $userid)->get()->toArray(); $toUser = User::whereIn('userid', $userid)->get()->toArray();
@ -286,12 +277,12 @@ class ApproveController extends AbstractController
if (empty($dialog)) { if (empty($dialog)) {
continue; continue;
} }
$this->approveMsg('approve_reviewer', $dialog, $botUser, $val, $process, 'start'); $this->approveMsg('approve_reviewer', $dialog, $botUser, $val, $process,'start');
} }
} }
// 抄送人 // 抄送人
$notifier = $this->handleProcessNode($process); $notifier = $this->handleProcessNode($process, $task['step']);
if ($notifier && $pass == 'pass') { if ($notifier && $pass == 'pass') {
foreach ($notifier as $val) { foreach ($notifier as $val) {
$dialog = WebSocketDialog::checkUserDialog($botUser, $val['target_id']); $dialog = WebSocketDialog::checkUserDialog($botUser, $val['target_id']);
@ -300,11 +291,11 @@ class ApproveController extends AbstractController
} }
} }
} }
return Base::retSuccess($pass == 'pass' ? '已通过' : '已拒绝', $task); return Base::retSuccess( $pass == 'pass' ? '已通过' : '已拒绝', $task);
} }
/** /**
* @api {post} api/approve/task/withdraw 撤回 * @api {post} api/approve/task/withdraw 07. 撤回
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -324,7 +315,7 @@ class ApproveController extends AbstractController
$data['userid'] = (string)$user->userid; $data['userid'] = (string)$user->userid;
$data['task_id'] = intval(Request::input('task_id')); $data['task_id'] = intval(Request::input('task_id'));
$data['proc_inst_id'] = intval(Request::input('proc_inst_id')); $data['proc_inst_id'] = intval(Request::input('proc_inst_id'));
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/task/withdraw', json_encode(Base::arrayKeyToCamel($data))); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/task/withdraw', json_encode(Base::arrayKeyToCamel($data)));
$task = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $task = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$task || $task['status'] != 200) { if (!$task || $task['status'] != 200) {
return Base::retError($task['message'] ?? '撤回失败'); return Base::retError($task['message'] ?? '撤回失败');
@ -349,38 +340,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/process/delById 删除审批(流程实例) * @api {post} api/approve/process/findTask 08. 查询需要我审批的流程(审批中)
*
* @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 查询需要我审批的流程(审批中)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -405,7 +365,7 @@ class ApproveController extends AbstractController
$data['sort'] = Request::input('sort'); $data['sort'] = Request::input('sort');
$data['pageIndex'] = intval(Request::input('page')); $data['pageIndex'] = intval(Request::input('page'));
$data['pageSize'] = intval(Request::input('page_size')); $data['pageSize'] = intval(Request::input('page_size'));
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/findTask', json_encode(Base::arrayKeyToCamel($data))); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/process/findTask', json_encode(Base::arrayKeyToCamel($data)));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '查询失败'); return Base::retError($process['message'] ?? '查询失败');
@ -419,11 +379,11 @@ class ApproveController extends AbstractController
} }
$val['userimg'] = User::getAvatar($info->userid, $info->userimg, $info->email, $info->nickname); $val['userimg'] = User::getAvatar($info->userid, $info->userimg, $info->email, $info->nickname);
} }
return Base::retSuccess('success', $res); return Base::retSuccess('success',$res);
} }
/** /**
* @api {post} api/approve/process/startByMyselfAll 查询我启动的流程(全部) * @api {post} api/approve/process/startByMyselfAll 09. 查询我启动的流程(全部)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -443,12 +403,12 @@ class ApproveController extends AbstractController
{ {
$user = User::auth(); $user = User::auth();
$data['userid'] = (string)$user->userid; $data['userid'] = (string)$user->userid;
$data['username'] = Request::input('username'); $data['username'] = Request::input('username');
$data['procName'] = Request::input('proc_def_name'); //分类 $data['procName'] = Request::input('proc_def_name'); //分类
$data['state'] = intval(Request::input('state')); //状态 $data['state'] = intval(Request::input('state')); //状态
$data['pageIndex'] = intval(Request::input('page')); $data['pageIndex'] = intval(Request::input('page'));
$data['pageSize'] = intval(Request::input('page_size')); $data['pageSize'] = intval(Request::input('page_size'));
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/startByMyselfAll', json_encode($data)); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/process/startByMyselfAll', json_encode($data));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '查询失败'); return Base::retError($process['message'] ?? '查询失败');
@ -466,7 +426,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/process/startByMyself 查询我启动的流程(审批中) * @api {post} api/approve/process/startByMyself 10. 查询我启动的流程(审批中)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -486,7 +446,7 @@ class ApproveController extends AbstractController
$data['userid'] = (string)$user->userid; $data['userid'] = (string)$user->userid;
$data['pageIndex'] = intval(Request::input('page')); $data['pageIndex'] = intval(Request::input('page'));
$data['pageSize'] = intval(Request::input('page_size')); $data['pageSize'] = intval(Request::input('page_size'));
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/startByMyself', json_encode($data)); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/process/startByMyself', json_encode($data));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '查询失败'); return Base::retError($process['message'] ?? '查询失败');
@ -504,7 +464,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/process/findProcNotify 查询抄送我的流程(审批中) * @api {post} api/approve/process/findProcNotify 11. 查询抄送我的流程(审批中)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -530,7 +490,7 @@ class ApproveController extends AbstractController
$data['pageIndex'] = intval(Request::input('page')); $data['pageIndex'] = intval(Request::input('page'));
$data['pageSize'] = intval(Request::input('page_size')); $data['pageSize'] = intval(Request::input('page_size'));
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/findProcNotify', json_encode($data)); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/process/findProcNotify', json_encode($data));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '查询失败'); return Base::retError($process['message'] ?? '查询失败');
@ -548,7 +508,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {get} api/approve/identitylink/findParticipant 查询流程实例的参与者(审批中) * @api {get} api/approve/identitylink/findParticipant 12. 查询流程实例的参与者(审批中)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -565,7 +525,7 @@ class ApproveController extends AbstractController
{ {
User::auth(); User::auth();
$proc_inst_id = Request::input('proc_inst_id'); $proc_inst_id = Request::input('proc_inst_id');
$ret = Ihttp::ihttp_get($this->flow_url . '/api/v1/workflow/identitylink/findParticipant?procInstId=' . $proc_inst_id); $ret = Ihttp::ihttp_get($this->flow_url.'/api/v1/workflow/identitylink/findParticipant?procInstId=' . $proc_inst_id);
$identitylink = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $identitylink = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$identitylink || $identitylink['status'] != 200) { if (!$identitylink || $identitylink['status'] != 200) {
return Base::retError($identitylink['message'] ?? '查询失败'); return Base::retError($identitylink['message'] ?? '查询失败');
@ -583,7 +543,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/procHistory/findTask 查询需要我审批的流程(已结束) * @api {post} api/approve/procHistory/findTask 13. 查询需要我审批的流程(已结束)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -608,7 +568,7 @@ class ApproveController extends AbstractController
$data['sort'] = Request::input('sort'); $data['sort'] = Request::input('sort');
$data['pageIndex'] = intval(Request::input('page')); $data['pageIndex'] = intval(Request::input('page'));
$data['pageSize'] = intval(Request::input('page_size')); $data['pageSize'] = intval(Request::input('page_size'));
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/procHistory/findTask', json_encode(Base::arrayKeyToCamel($data))); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/procHistory/findTask', json_encode(Base::arrayKeyToCamel($data)));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '查询失败'); return Base::retError($process['message'] ?? '查询失败');
@ -626,7 +586,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/procHistory/startByMyself 查询我启动的流程(已结束) * @api {post} api/approve/procHistory/startByMyself 14. 查询我启动的流程(已结束)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -646,7 +606,7 @@ class ApproveController extends AbstractController
$data['userid'] = (string)$user->userid; $data['userid'] = (string)$user->userid;
$data['pageIndex'] = intval(Request::input('page')); $data['pageIndex'] = intval(Request::input('page'));
$data['pageSize'] = intval(Request::input('page_size')); $data['pageSize'] = intval(Request::input('page_size'));
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/procHistory/startByMyself', json_encode($data)); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/procHistory/startByMyself', json_encode($data));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '查询失败'); return Base::retError($process['message'] ?? '查询失败');
@ -664,7 +624,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/procHistory/findProcNotify 查询抄送我的流程(已结束) * @api {post} api/approve/procHistory/findProcNotify 15. 查询抄送我的流程(已结束)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -690,7 +650,7 @@ class ApproveController extends AbstractController
$data['pageIndex'] = intval(Request::input('page')); $data['pageIndex'] = intval(Request::input('page'));
$data['pageSize'] = intval(Request::input('page_size')); $data['pageSize'] = intval(Request::input('page_size'));
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/procHistory/findProcNotify', json_encode($data)); $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/procHistory/findProcNotify', json_encode($data));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '查询失败'); return Base::retError($process['message'] ?? '查询失败');
@ -708,7 +668,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {get} api/approve/identitylinkHistory/findParticipant 查询流程实例的参与者(已结束) * @api {get} api/approve/identitylinkHistory/findParticipant 16. 查询流程实例的参与者(已结束)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -725,7 +685,7 @@ class ApproveController extends AbstractController
{ {
User::auth(); User::auth();
$proc_inst_id = Request::input('proc_inst_id'); $proc_inst_id = Request::input('proc_inst_id');
$ret = Ihttp::ihttp_get($this->flow_url . '/api/v1/workflow/identitylinkHistory/findParticipant?procInstId=' . $proc_inst_id); $ret = Ihttp::ihttp_get($this->flow_url.'/api/v1/workflow/identitylinkHistory/findParticipant?procInstId=' . $proc_inst_id);
$identitylink = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $identitylink = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$identitylink || $identitylink['status'] != 200) { if (!$identitylink || $identitylink['status'] != 200) {
return Base::retError($identitylink['message'] ?? '查询失败'); return Base::retError($identitylink['message'] ?? '查询失败');
@ -743,7 +703,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {get} api/approve/process/detail 根据流程ID查询流程详情 * @api {get} api/approve/process/detail 17. 根据流程ID查询流程详情
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -765,7 +725,7 @@ class ApproveController extends AbstractController
} }
/** /**
* @api {post} api/approve/export 导出数据 * @api {post} api/approve/export 18. 导出数据
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -789,207 +749,146 @@ class ApproveController extends AbstractController
$data['isFinished'] = intval(Request::input('is_finished')); //是否完成 $data['isFinished'] = intval(Request::input('is_finished')); //是否完成
$date = Request::input('date'); $date = Request::input('date');
$data['startTime'] = $date[0]; //开始时间 $data['startTime'] = $date[0]; //开始时间
$data['endTime'] = Carbon::parse($date[1])->addDay()->toDateString(); //结束时间 + 1天 $data['endTime'] =Carbon::parse($date[1])->addDay()->toDateString(); //结束时间 + 1天
// //
if (empty($name) || empty($date)) { if (empty($name) || empty($date)) {
return Base::retError('参数错误'); return Base::retError('参数错误');
} }
if (!(is_array($date) && Timer::isDate($date[0]) && Timer::isDate($date[1]))) { if (!(is_array($date) && Base::isDate($date[0]) && Base::isDate($date[1]))) {
return Base::retError('日期选择错误'); return Base::retError('日期选择错误');
} }
if (Carbon::parse($date[1])->timestamp - Carbon::parse($date[0])->timestamp > 35 * 86400) { if (Carbon::parse($date[1])->timestamp - Carbon::parse($date[0])->timestamp > 35 * 86400) {
return Base::retError('日期范围限制最大35天'); return Base::retError('日期范围限制最大35天');
} }
$botUser = User::botGetOrCreate('system-msg'); //
if (empty($botUser)) { $ret = Ihttp::ihttp_post($this->flow_url.'/api/v1/workflow/process/findAllProcIns', json_encode($data));
return Base::retError('系统机器人不存在'); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '查询失败');
} }
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
// //
$doo = Doo::load(); $res = Base::arrayKeyToUnderline($process['data']);
go(function () use ($doo, $data, $user, $botUser, $dialog) { //
Coroutine::sleep(1); $headings = [];
$headings[] = '申请编号';
$headings[] = '标题';
$headings[] = '申请状态';
$headings[] = '发起时间';
$headings[] = '完成时间';
$headings[] = '发起人工号';
$headings[] = '发起人User ID';
$headings[] = '发起人姓名';
$headings[] = '发起人部门';
$headings[] = '发起人部门ID';
$headings[] = '部门负责人';
$headings[] = '历史审批人';
$headings[] = '历史办理人';
$headings[] = '审批记录';
$headings[] = '当前处理人';
$headings[] = '审批节点';
$headings[] = '审批人数';
$headings[] = '审批耗时';
$headings[] = '假期类型';
$headings[] = '开始时间';
$headings[] = '结束时间';
$headings[] = '时长';
$headings[] = '请假事由';
$headings[] = '请假单位';
//
$sheets = [];
$datas = [];
foreach ($res as $val) {
// //
$content = []; $nickname = Base::filterEmoji($val['start_user_name']);
$content[] = [ $participant = $this->getUserProcessParticipantById($val['id']); // 获取参与人
'content' => '导出审批数据已完成', $participant = $this->handleParticipant($val, $participant['data']); // 处理参与人返回数据
'style' => 'font-weight: bold;padding-bottom: 4px;', //
$job_number = ''; // 发起人工号
$department_leader = User::userid2nickname(UserDepartment::find(1, ['owner_userid'])['owner_userid']); // 部门负责人
$historical_approver = $participant['historical_approver'] ?? ''; // 历史审批人
$historical_agent = ''; // 历史办理人
$approval_record = $participant['approval_record'] ?? ''; // 审批记录
$current_handler = !$val['is_finished'] ? implode(',', User::whereIn('userid', explode(';', $val['candidate']))->pluck('nickname')->toArray()) : ''; // 当前处理人
$approved_node = $participant['approved_node'] ?? 0; // 审批节点
$approved_num = $participant['approved_num'] ?? 0; // 审批人数
// 计算审批耗时
$startTime = Carbon::parse($val['start_time'])->timestamp;
$endTime = $val['end_time'] ? Carbon::parse($val['end_time'])->timestamp : time();
$approval_time = Base::timeDiff($startTime, $endTime); // 审批耗时
// 计算时长
$varStartTime = Carbon::parse($val['var']['start_time']);
$varEndTime = Carbon::parse($val['var']['end_time']);
$duration = $varEndTime->floatDiffInHours($varStartTime);
$duration_unit = '小时'; // 时长单位
$datas[] = [
$val['id'], // 申请编号
$val['proc_def_name'], // 标题
$this->getStateDescription($val['state']), // 申请状态
$val['start_time'], // 发起时间
$val['end_time'], // 完成时间
$job_number, // 发起人工号
$val['start_user_id'], // 发起人User ID
$nickname, // 发起人姓名
$val['department'], // 发起人部门
$val['department_id'], // 发起人部门ID
$department_leader, // 部门负责人
$historical_approver, // 历史审批人
$historical_agent, // 历史办理人
$approval_record, // 审批记录
$current_handler, // 当前处理人
$approved_node, // 审批节点
$approved_num, // 审批人数
$approval_time, // 审批耗时
$val['var']['type'], // 假期类型
$val['var']['start_time'], // 开始时间
$val['var']['end_time'], // 结束时间
$duration, // 时长
$val['var']['description'], // 请假事由
$duration_unit, // 请假单位
]; ];
// }
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/findAllProcIns', json_encode($data)); if (empty($datas)) {
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); return Base::retError('没有任何数据');
if (!$process || $process['status'] != 200) { }
$content[] = [
'content' => $process['message'] ?? '查询失败',
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
return;
}
//
$res = Base::arrayKeyToUnderline($process['data']);
//
$headings = [];
$headings[] = $doo->translate('申请编号');
$headings[] = $doo->translate('标题');
$headings[] = $doo->translate('申请状态');
$headings[] = $doo->translate('发起时间');
$headings[] = $doo->translate('完成时间');
$headings[] = $doo->translate('发起人工号');
$headings[] = $doo->translate('发起人User ID');
$headings[] = $doo->translate('发起人姓名');
$headings[] = $doo->translate('发起人部门');
$headings[] = $doo->translate('发起人部门ID');
$headings[] = $doo->translate('部门负责人');
$headings[] = $doo->translate('历史审批人');
$headings[] = $doo->translate('历史办理人');
$headings[] = $doo->translate('审批记录');
$headings[] = $doo->translate('当前处理人');
$headings[] = $doo->translate('审批节点');
$headings[] = $doo->translate('审批人数');
$headings[] = $doo->translate('审批耗时');
$headings[] = $doo->translate('假期类型');
$headings[] = $doo->translate('开始时间');
$headings[] = $doo->translate('结束时间');
$headings[] = $doo->translate('时长');
$headings[] = $doo->translate('请假事由');
$headings[] = $doo->translate('请假单位');
//
$datas = [];
foreach ($res as $val) {
//
$nickname = Base::filterEmoji($val['start_user_name']);
$participant = $this->getUserProcessParticipantById($val['id']); // 获取参与人
$participant = $this->handleParticipant($val, $participant['data']); // 处理参与人返回数据
//
$job_number = ''; // 发起人工号
$department_leader = User::userid2nickname(UserDepartment::find(1, ['owner_userid'])['owner_userid']); // 部门负责人
$historical_approver = $participant['historical_approver'] ?? ''; // 历史审批人
$historical_agent = ''; // 历史办理人
$approval_record = $participant['approval_record'] ?? ''; // 审批记录
$current_handler = !$val['is_finished'] ? implode(',', User::whereIn('userid', explode(';', $val['candidate']))->pluck('nickname')->toArray()) : ''; // 当前处理人
$approved_node = $participant['approved_node'] ?? 0; // 审批节点
$approved_num = $participant['approved_num'] ?? 0; // 审批人数
// 计算审批耗时
$startTime = Carbon::parse($val['start_time'])->timestamp;
$endTime = $val['end_time'] ? Carbon::parse($val['end_time'])->timestamp : time();
$approval_time = $doo->translate(Timer::timeDiff($startTime, $endTime)); // 审批耗时
// 计算时长
$varStartTime = Carbon::parse($val['var']['start_time']);
$varEndTime = Carbon::parse($val['var']['end_time']);
$duration = $varEndTime->floatDiffInHours($varStartTime);
$duration_unit = $doo->translate('小时'); // 时长单位
$datas[] = [
$val['id'], // 申请编号
$val['proc_def_name'], // 标题
$this->getStateDescription($val['state']), // 申请状态
$val['start_time'], // 发起时间
$val['end_time'], // 完成时间
$job_number, // 发起人工号
$val['start_user_id'], // 发起人User ID
$nickname, // 发起人姓名
$val['department'], // 发起人部门
$val['department_id'], // 发起人部门ID
$department_leader, // 部门负责人
$historical_approver, // 历史审批人
$historical_agent, // 历史办理人
$approval_record, // 审批记录
$current_handler, // 当前处理人
$approved_node, // 审批节点
$approved_num, // 审批人数
$approval_time, // 审批耗时
$val['var']['type'], // 假期类型
$val['var']['start_time'], // 开始时间
$val['var']['end_time'], // 结束时间
$duration, // 时长
$val['var']['description'], // 请假事由
$duration_unit, // 请假单位
];
}
if (empty($datas)) {
$content[] = [
'content' => '没有任何数据',
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
return;
}
//
$title = $doo->translate("审批记录");
$sheets = [
BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles(["A1:Y1" => ["font" => ["bold" => true]]])
];
//
$fileName = $title . '_' . Timer::time() . '.xlsx';
$filePath = "temp/approve/export/" . date("Ym", Timer::time());
$export = new BillMultipleExport($sheets);
$res = $export->store($filePath . "/" . $fileName);
if ($res != 1) {
$content[] = [
'content' => "导出失败,{$fileName}",
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
return;
}
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xlsx') . ".zip";
$zipPath = storage_path($zipFile);
if (file_exists($zipPath)) {
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
} catch (\Throwable) {
}
//
if (file_exists($zipPath)) {
$key = Down::cache_encode([
'file' => $zipFile,
]);
$fileUrl = Base::fillUrl('api/approve/down?key=' . $key);
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'file_download',
'title' => '导出审批数据已完成',
'name' => $fileName,
'size' => filesize($zipPath),
'url' => $fileUrl,
], $botUser->userid, true, false, true);
} else {
$content[] = [
'content' => "打包失败,请稍后再试...",
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
}
});
// //
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [ $title = "Sheet1";
'type' => 'content', $sheets = [
'content' => '正在导出审批数据,请稍等...', BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles(["A1:Y1" => ["font" => ["bold" => true]]])
], $botUser->userid, true, false, true); ];
// //
return Base::retSuccess('success'); $fileName = '审批记录_' . Base::time() . '.xls';
$filePath = "temp/approve/export/" . date("Ym", Base::time());
$export = new BillMultipleExport($sheets);
$res = $export->store($filePath . "/" . $fileName);
if ($res != 1) {
return Base::retError('导出失败,' . $fileName . '');
}
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xls') . ".zip";
$zipPath = storage_path($zipFile);
if (file_exists($zipPath)) {
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
} catch (\Throwable) {
}
//
if (file_exists($zipPath)) {
$base64 = base64_encode(Base::array2string([
'file' => $zipFile,
]));
Session::put('approve::export:userid', $user->userid);
return Base::retSuccess('success', [
'size' => Base::twoFloat(filesize($zipPath) / 1024, true),
'url' => Base::fillUrl('api/approve/down?key=' . urlencode($base64)),
]);
} else {
return Base::retError('打包失败,请稍后再试...');
}
} }
function getStateDescription($state) function getStateDescription($state) {
{
$state_map = array( $state_map = array(
0 => '全部', 0 => '全部',
1 => '审批中', 1 => '审批中',
@ -997,14 +896,14 @@ class ApproveController extends AbstractController
3 => '拒绝', 3 => '拒绝',
4 => '撤回' 4 => '撤回'
); );
return $state_map[$state] ?? ''; return isset($state_map[$state]) ? $state_map[$state] : '';
} }
/** /**
* @api {get} api/approve/down 下载导出的审批数据 * @api {get} api/approve/down 19. 下载导出的审批数据
* *
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup approve * @apiGroup system
* @apiName down * @apiName down
* *
* @apiParam {String} key 通过export接口得到的下载钥匙 * @apiParam {String} key 通过export接口得到的下载钥匙
@ -1013,10 +912,15 @@ class ApproveController extends AbstractController
*/ */
public function down() public function down()
{ {
$array = Down::cache_decode(); $userid = Session::get('approve::export:userid');
if (empty($userid)) {
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
}
//
$array = Base::string2array(base64_decode(urldecode(Request::input('key'))));
$file = $array['file']; $file = $array['file'];
if (empty($file) || !file_exists(storage_path($file))) { if (empty($file) || !file_exists(storage_path($file))) {
return Base::ajaxError("文件不存在!", [], 0, 403); return Base::ajaxError("文件不存在!", [], 0, 502);
} }
return Response::download(storage_path($file)); return Response::download(storage_path($file));
} }
@ -1046,19 +950,20 @@ class ApproveController extends AbstractController
} }
// 审批记录 // 审批记录
$name = $val['username'] . '|'; $name = $val['username'] . '|';
$call = $val['step'] == 0 ? '发起审批' . '|' : '同意' . '|'; $call = $val['step'] == 0 ? '发起审批'. '|' : '同意' . '|';
$time = $val['step'] == 0 ? $process['start_time'] . '|' : ''; $time =$val['step'] == 0 ? $process['start_time'] . '|' : '';
$comment = $val['step'] == 0 ? '' : ($val['comment'] ?? '') . '|'; $comment = $val['step'] == 0 ? '' : ($val['comment'] ?? '') . '|';
$res['approval_record'] .= $name . $call . $time . $comment; $res['approval_record'] .= $name . $call . $time . $comment;
} }
} }
$res['historical_approver'] = trim(implode(';', $historical_approver), ';'); $res['historical_approver'] = trim(implode(';', $historical_approver), ';');
$res['approved_node'] = $approved_node; $res['approved_node'] = $approved_node;
$res['approved_num'] = $approved_num; $res['approved_num'] = $approved_num;
$res['historical_agent'] = $res['historical_approver']; $res['historical_agent'] = $res['historical_approver'];
return $res; return $res;
} }
// 审批机器人消息 // 审批机器人消息
public function approveMsg($type, $dialog, $botUser, $toUser, $process, $action = null) public function approveMsg($type, $dialog, $botUser, $toUser, $process, $action = null)
{ {
@ -1070,71 +975,50 @@ class ApproveController extends AbstractController
'department' => $process['department'], 'department' => $process['department'],
'type' => $process['var']['type'], 'type' => $process['var']['type'],
'start_time' => $process['var']['start_time'], 'start_time' => $process['var']['start_time'],
'start_day_of_week' => '周' . Timer::getWeek(Carbon::parse($process['var']['start_time'])->timestamp), 'start_day_of_week' => '周'.Base::getTimeWeek(Carbon::parse($process['var']['start_time'])->timestamp),
'end_time' => $process['var']['end_time'], 'end_time' => $process['var']['end_time'],
'end_day_of_week' => '周' . Timer::getWeek(Carbon::parse($process['var']['end_time'])->timestamp), 'end_day_of_week' => '周'.Base::getTimeWeek(Carbon::parse($process['var']['end_time'])->timestamp),
'description' => $process['var']['description'], 'description' => $process['var']['description'],
'comment_nickname' => $process['comment_user_id'] ? User::userid2nickname($process['comment_user_id']) : '', 'comment_nickname' => $process['comment_user_id'] ? User::userid2nickname($process['comment_user_id']) : '',
'comment_content' => $process['comment_contents']['content'] ?? '', 'comment_content' => $process['comment_content'] ?? ''
'comment_pictures' => $process['comment_contents']['pictures'] ?? []
]; ];
$thumb = null; $text = view('push.bot', ['type' => $type, 'action' => $action, 'is_finished' => $process['is_finished'], 'data' => (object)$data])->render();
if ($type === 'approve_reviewer') { $text = preg_replace("/^\x20+/", "", $text);
$thumb = $process['var']['other']; $text = preg_replace("/\n\x20+/", "\n", $text);
} elseif ($type === 'approve_comment_notifier') { $msg_action = null;
$thumb = $data['comment_pictures'] ? $data['comment_pictures'][0] : null;
}
if ($thumb && file_exists(public_path($thumb))) {
$imageSize = getimagesize(public_path($thumb));
$data['thumb'] = [
'url' => Base::fillUrl($thumb),
'width' => $imageSize[0],
'height' => $imageSize[1]
];
}
$msgAction = null;
$msgData = [
'type' => $type,
'action' => $action,
'is_finished' => $process['is_finished'],
'data' => $data
];
$msgData['title'] = match ($type) {
'approve_reviewer' => $data['nickname'] . " 提交的「{$data['proc_def_name']}」待你审批",
'approve_notifier' => "抄送 {$data['nickname']} 提交的「{$data['proc_def_name']}」记录",
'approve_comment_notifier' => $data['comment_nickname'] . " 评论了 {$data['nickname']} 的「{$data['proc_def_name']}」审批",
'approve_submitter' => $action == 'pass' ? "您发起的「{$data['proc_def_name']}」已通过" : "您发起的「{$data['proc_def_name']}」被 {$data['nickname']} 拒绝",
default => '不支持的指令',
};
if ($action == 'withdraw' || $action == 'pass' || $action == 'refuse') { if ($action == 'withdraw' || $action == 'pass' || $action == 'refuse') {
// 任务完成,给发起人发送消息 // 任务完成,给发起人发送消息
if ($type == 'approve_submitter' && $action != 'withdraw') { if($type == 'approve_submitter' && $action != 'withdraw'){
return WebSocketDialogMsg::sendMsg($msgAction, $dialog->id, 'template', $msgData, $botUser->userid, false, false, true); return WebSocketDialogMsg::sendMsg($msg_action, $dialog->id, 'text', ['text' => $text], $botUser->userid, false, false, true);
} }
// 查找最后一条消息msg_id // 查找最后一条消息msg_id
$msgAction = 'change-' . $toUser['msg_id']; $msg_action = 'update-'.$toUser['msg_id'];
} }
// //
$msg = WebSocketDialogMsg::sendMsg($msgAction, $dialog->id, 'template', $msgData, $process['start_user_id'], false, false, true); try {
// 关联信息 $msg = WebSocketDialogMsg::sendMsg($msg_action, $dialog->id, 'text', ['text' => $text], $botUser->userid, false, false, true);
if ($action == 'start') { // 关联信息
$proc_msg = new ApproveProcMsg(); if ($action == 'start') {
$proc_msg->proc_inst_id = $process['id']; $proc_msg = new ApproveProcMsg();
$proc_msg->msg_id = $msg['data']->id; $proc_msg->proc_inst_id = $process['id'];
$proc_msg->userid = $toUser['userid']; $proc_msg->msg_id = $msg['data']->id;
$proc_msg->save(); $proc_msg->userid = $toUser['userid'];
} $proc_msg->save();
// 更新审批 未读数量 }
if ($type == 'approve_reviewer' && $toUser['userid']) { // 更新工作报告 未读数量
$params = [ if($type == 'approve_reviewer' && $toUser['userid']){
'userid' => [$toUser['userid'], User::userid()], $params = [
'msg' => [ 'userid' => [ $toUser['userid'], User::auth()->userid() ],
'type' => 'approve', 'msg' => [
'action' => 'unread', 'type' => 'approve',
'userid' => $toUser['userid'], 'action' => 'unread',
] 'userid' => $toUser['userid'],
]; ]
Task::deliver(new PushTask($params, false)); ];
Task::deliver(new PushTask($params, false));
}
} catch (\Throwable $th) {
//throw $th;
} }
return true; return true;
} }
@ -1143,7 +1027,7 @@ class ApproveController extends AbstractController
public function getProcessById($id) public function getProcessById($id)
{ {
$data['id'] = intval($id); $data['id'] = intval($id);
$ret = Ihttp::ihttp_get($this->flow_url . "/api/v1/workflow/process/findById?" . http_build_query($data)); $ret = Ihttp::ihttp_get($this->flow_url."/api/v1/workflow/process/findById?".http_build_query($data));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
throw new ApiException($process['message'] ?? '查询失败'); throw new ApiException($process['message'] ?? '查询失败');
@ -1162,16 +1046,15 @@ class ApproveController extends AbstractController
$val['node_user_list'][$k]['userimg'] = User::getAvatar($info->userid, $info->userimg, $info->email, $info->nickname); $val['node_user_list'][$k]['userimg'] = User::getAvatar($info->userid, $info->userimg, $info->email, $info->nickname);
$res['userids'][] = $item['target_id']; $res['userids'][] = $item['target_id'];
} }
} else if ($val['aprover_id']) { }else if($val['aprover_id']){
$info = User::whereUserid($val['aprover_id'])->first(); $info = User::whereUserid($val['aprover_id'])->first();
$val['userimg'] = $info ? User::getAvatar($info->userid, $info->userimg, $info->email, $info->nickname) : ''; $val['userimg'] = $info ? User::getAvatar($info->userid, $info->userimg, $info->email, $info->nickname) : '';
$res['userids'][] = $val['aprover_id']; $res['userids'][] = $val['aprover_id'];
} }
} }
// 全局评论 // 全局评论
unset($res['global_comment']); if(isset($res['global_comments'])){
if (isset($res['global_comments'])) { foreach ($res['global_comments'] as $k => &$globalComment) {
foreach ($res['global_comments'] as $k => $globalComment) {
$info = User::whereUserid($globalComment['user_id'])->first(); $info = User::whereUserid($globalComment['user_id'])->first();
if (!$info) { if (!$info) {
continue; continue;
@ -1179,8 +1062,6 @@ class ApproveController extends AbstractController
$res['global_comments'][$k]['userimg'] = User::getAvatar($info->userid, $info->userimg, $info->email, $info->nickname); $res['global_comments'][$k]['userimg'] = User::getAvatar($info->userid, $info->userimg, $info->email, $info->nickname);
$res['global_comments'][$k]['nickname'] = $info->nickname; $res['global_comments'][$k]['nickname'] = $info->nickname;
} }
} else {
$res['global_comments'] = [];
} }
$info = User::whereUserid($res['start_user_id'])->first(); $info = User::whereUserid($res['start_user_id'])->first();
$res['userimg'] = $info ? User::getAvatar($info->userid, $info->userimg, $info->email, $info->nickname) : ''; $res['userimg'] = $info ? User::getAvatar($info->userid, $info->userimg, $info->email, $info->nickname) : '';
@ -1213,7 +1094,7 @@ class ApproveController extends AbstractController
public function getUserProcessParticipantById($id) public function getUserProcessParticipantById($id)
{ {
$data['procInstId'] = intval($id); $data['procInstId'] = intval($id);
$ret = Ihttp::ihttp_get($this->flow_url . "/api/v1/workflow/identitylink/findParticipantAll?" . http_build_query($data)); $ret = Ihttp::ihttp_get($this->flow_url."/api/v1/workflow/identitylink/findParticipantAll?".http_build_query($data));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true); $process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) { if (!$process || $process['status'] != 200) {
throw new ApiException($process['message'] ?? '查询失败'); throw new ApiException($process['message'] ?? '查询失败');
@ -1223,46 +1104,26 @@ class ApproveController extends AbstractController
/** /**
* @api {get} api/approve/user/status 获取用户审批状态 * @api {get} api/approve/user/status 20. 获取用户审批状态
* *
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup approve * @apiGroup system
* @apiName user__status * @apiName user__status
* *
* @apiParam {String} userid * @apiParam {String} userid
* *
* @apiSuccess {Number} ret 返回状态码1正确、0错误 * @apiSuccess {String}
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/ */
public function user__status() public function user__status()
{ {
$userid = intval(Request::input('userid')); $data['userid'] = intval(Request::input('userid'));
$status = ApproveProcInstHistory::getUserApprovalStatus($userid); $ret = Ihttp::ihttp_get($this->flow_url.'/api/v1/workflow/process/getUserApprovalStatus?'.http_build_query($data));
return Base::retSuccess('success', $status); $procdef = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
} if (isset($procdef['status']) && $procdef['status'] == 200) {
return Base::retSuccess('success', isset($procdef['data']["proc_def_name"]) ? $procdef['data']["proc_def_name"] : '');
/** }
* @api {get} api/approve/process/doto 查询需要我审批的流程数量 return Base::retSuccess('success', '');
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup approve
* @apiName process__doto
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function process__doto()
{
$user = User::auth();
$ret = Ihttp::ihttp_get($this->flow_url . '/api/v1/workflow/process/findTaskTotal?userid=' . $user->userid);
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '查询失败');
}
return Base::retSuccess('success', $process['data']);
} }
} }

View File

@ -1,307 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\AiAssistantSession;
use App\Models\User;
use App\Module\AI;
use App\Module\Apps;
use App\Module\Base;
use Request;
/**
* @apiDefine assistant
*
* 助手
*/
class AssistantController extends AbstractController
{
public function __construct()
{
Apps::isInstalledThrow('ai');
}
/**
* @api {post} api/assistant/auth 生成授权码
*
* @apiDescription 需要token身份生成 AI 流式会话的 stream_key
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName auth
*
* @apiParam {String} model_type 模型类型
* @apiParam {String} model_name 模型名称
* @apiParam {JSON} context 上下文数组
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.stream_key 流式会话凭证
*/
public function auth()
{
$user = User::auth();
$user->checkChatInformation();
$modelType = trim(Request::input('model_type', ''));
$modelName = trim(Request::input('model_name', ''));
$contextInput = Request::input('context', []);
return AI::createStreamKey($modelType, $modelName, $contextInput);
}
/**
* @api {get} api/assistant/models 获取AI模型
*
* @apiDescription 获取所有AI机器人模型设置
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName models
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function models()
{
$setting = Base::setting('aibotSetting');
$setting = array_filter($setting, function ($value, $key) {
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
}, ARRAY_FILTER_USE_BOTH);
return Base::retSuccess('success', $setting ?: json_decode('{}'));
}
/**
* @api {post} api/assistant/match-elements 元素向量匹配
*
* @apiDescription 通过向量相似度匹配页面元素,用于智能查找与查询语义相关的元素
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName match_elements
*
* @apiParam {String} query 搜索关键词
* @apiParam {Array} elements 元素列表,每个元素包含 ref name 字段
* @apiParam {Number} [top_k=10] 返回的匹配数量最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {Array} data.matches 匹配结果数组,按相似度降序排列
*/
public function match_elements()
{
User::auth();
$query = trim(Request::input('query', ''));
$elements = Request::input('elements', []);
$topK = min(intval(Request::input('top_k', 10)), 50);
if (empty($query) || empty($elements)) {
return Base::retError('参数不能为空');
}
// 获取查询向量
$queryResult = AI::getEmbedding($query);
if (Base::isError($queryResult)) {
return $queryResult;
}
$queryVector = $queryResult['data'];
// 计算相似度并排序
$scored = [];
foreach ($elements as $el) {
$name = $el['name'] ?? '';
if (empty($name)) {
continue;
}
$elResult = AI::getEmbedding($name);
if (Base::isError($elResult)) {
continue;
}
$similarity = $this->cosineSimilarity($queryVector, $elResult['data']);
$scored[] = [
'element' => $el,
'similarity' => $similarity,
];
}
// 按相似度降序排序
usort($scored, fn($a, $b) => $b['similarity'] <=> $a['similarity']);
return Base::retSuccess('success', [
'matches' => array_slice($scored, 0, $topK),
]);
}
/**
* 计算两个向量的余弦相似度
*/
private function cosineSimilarity(array $a, array $b): float
{
$dotProduct = 0;
$normA = 0;
$normB = 0;
$count = count($a);
for ($i = 0; $i < $count; $i++) {
$dotProduct += $a[$i] * $b[$i];
$normA += $a[$i] * $a[$i];
$normB += $b[$i] * $b[$i];
}
$denominator = sqrt($normA) * sqrt($normB);
if ($denominator == 0) {
return 0;
}
return $dotProduct / $denominator;
}
/**
* 获取会话列表
*/
public function session__list()
{
$user = User::auth();
$sessionKey = trim(Request::input('session_key', 'default'));
$sessions = AiAssistantSession::where('userid', $user->userid)
->where('session_key', $sessionKey)
->orderByDesc('updated_at')
->get();
$list = [];
foreach ($sessions as $session) {
$data = Base::json2array($session->data);
$images = Base::json2array($session->images);
foreach ($images as $imageId => $path) {
$images[$imageId] = Base::fillUrl($path);
}
$list[] = [
'id' => $session->session_id,
'title' => $session->title,
'responses' => $data,
'images' => $images,
'sceneKey' => $session->scene_key,
'createdAt' => $session->created_at ? $session->created_at->getTimestampMs() : 0,
'updatedAt' => $session->updated_at ? $session->updated_at->getTimestampMs() : 0,
];
}
return Base::retSuccess('success', $list);
}
/**
* 保存会话
*/
public function session__save()
{
$user = User::auth();
$sessionKey = trim(Request::input('session_key', 'default'));
$sessionId = trim(Request::input('session_id', ''));
$sceneKey = trim(Request::input('scene_key', ''));
$title = trim(Request::input('title', ''));
$data = Request::input('data', []);
$newImages = Request::input('new_images', []);
if (empty($sessionId)) {
return Base::retError('session_id 不能为空');
}
$newImageUrls = [];
if (is_array($newImages)) {
$path = 'uploads/assistant/' . date('Ym') . '/' . $user->userid . '/';
foreach ($newImages as $img) {
$imageId = $img['imageId'] ?? '';
$dataUrl = $img['dataUrl'] ?? '';
if (empty($imageId) || empty($dataUrl)) {
continue;
}
$result = Base::image64save([
'image64' => $dataUrl,
'path' => $path,
'autoThumb' => false,
]);
if (Base::isSuccess($result)) {
$newImageUrls[$imageId] = $result['data']['path'];
}
}
}
$session = AiAssistantSession::where('userid', $user->userid)
->where('session_key', $sessionKey)
->where('session_id', $sessionId)
->first();
$imageMap = $newImageUrls;
if ($session) {
$existingImages = Base::json2array($session->images);
$imageMap = array_merge($existingImages, $newImageUrls);
}
$session = AiAssistantSession::createInstance([
'userid' => $user->userid,
'session_key' => $sessionKey,
'session_id' => $sessionId,
'scene_key' => $sceneKey,
'title' => mb_substr($title, 0, 255),
'data' => Base::array2json(is_array($data) ? $data : []),
'images' => Base::array2json($imageMap),
], $session?->id);
$session->save();
// 仅返回本次新增的图片URL
$urls = [];
foreach ($newImageUrls as $imageId => $path) {
$urls[$imageId] = Base::fillUrl($path);
}
return Base::retSuccess('success', [
'image_urls' => $urls,
]);
}
/**
* 删除会话
*/
public function session__delete()
{
$user = User::auth();
$sessionKey = trim(Request::input('session_key', 'default'));
$sessionId = trim(Request::input('session_id', ''));
$clearAll = Request::input('clear_all', false);
$query = AiAssistantSession::where('userid', $user->userid)
->where('session_key', $sessionKey);
if ($clearAll) {
$sessions = $query->get();
foreach ($sessions as $session) {
$this->deleteSessionImages($session);
}
$query->delete();
} else {
if (empty($sessionId)) {
return Base::retError('session_id 不能为空');
}
$session = $query->where('session_id', $sessionId)->first();
if ($session) {
$this->deleteSessionImages($session);
$session->delete();
}
}
return Base::retSuccess('success');
}
private function deleteSessionImages(AiAssistantSession $session)
{
$images = Base::json2array($session->images);
foreach ($images as $path) {
$fullPath = public_path($path);
if (file_exists($fullPath)) {
@unlink($fullPath);
}
}
}
}

View File

@ -1,196 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use Request;
use App\Models\User;
use App\Module\Base;
use App\Models\Complaint;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
/**
* @apiDefine complaint
*
* 投诉
*/
class ComplaintController extends AbstractController
{
/**
* @api {get} api/complaint/lists 获取举报投诉列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup complaint
* @apiName lists
*
* @apiParam {Number} [type] 类型
* @apiParam {Number} [status] 状态
*
* @apiParam {Number} [page] 当前页,默认:1
* @apiParam {Number} [pagesize] 每页显示数量,默认:50,最大:100
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* @apiSuccessExample {json} Success-Response-Data:
* {
* "current_page": 1,
* "data": [
* {
* "id": 1,
* "dialog_id": 100,
* "userid": 1,
* "type": 1,
* "reason": "举报原因",
* "imgs": [],
* "status": 0,
* "created_at": "2025-01-01 00:00:00",
* "updated_at": "2025-01-01 00:00:00"
* }
* ],
* "first_page_url": "http://example.com/api/complaint/lists?page=1",
* "from": 1,
* "last_page": 1,
* "last_page_url": "http://example.com/api/complaint/lists?page=1",
* "next_page_url": null,
* "path": "http://example.com/api/complaint/lists",
* "per_page": 50,
* "prev_page_url": null,
* "to": 1,
* "total": 1
* }
*/
public function lists()
{
$user = User::auth();
$user->identity('admin');
//
$type = intval(Request::input('type'));
$status = Request::input('status');
//
$complaints = Complaint::query()
->when($type, function($q) use($type) {
$q->where('type', $type);
})
->when($status != "", function($q) use($status) {
$q->where('status', $status);
})
->orderByDesc('id')
->paginate(Base::getPaginate(100, 50));
//
return Base::retSuccess('success', $complaints);
}
/**
* @api {post} api/complaint/submit 举报投诉
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup complaint
* @apiName submit
*
* @apiBody {Number} dialog_id 对话ID
* @apiBody {Number} type 类型
* @apiBody {String} reason 原因
* @apiBody {Object[]} [imgs] 图片数组(可选)
* @apiBody {String} imgs.path 图片路径
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* @apiSuccessExample {json} Success-Response-Data:
* []
*/
public function submit()
{
$user = User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
$type = intval(Request::input('type'));
$reason = trim(Request::input('reason'));
$imgs = Request::input('imgs');
//
WebSocketDialog::checkDialog($dialog_id);
//
if (!$type) {
return Base::retError('请选择举报类型');
}
if (!$reason) {
return Base::retError('请填写举报原因');
}
//
$report_imgs = [];
if (!empty($imgs) && is_array($imgs)) {
foreach ($imgs as $img) {
$report_imgs[] = Base::unFillUrl($img['path']);
}
}
//
Complaint::createInstance([
'dialog_id' => $dialog_id,
'userid' => $user->userid,
'type' => $type,
'reason' => $reason,
'imgs' => $report_imgs,
])->save();
// 通知管理员
$botUser = User::botGetOrCreate('system-msg');
User::where("identity", "like", "%,admin,%")
->orderByDesc('line_at')
->take(10)
->get()
->each(function ($adminUser) use ($reason, $botUser) {
$dialog = WebSocketDialog::checkUserDialog($botUser, $adminUser->userid);
if ($dialog) {
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => '收到新的举报信息',
'content' => "收到新的举报信息:{$reason} (请前往应用查看详情)"
], $botUser->userid);
}
});
//
return Base::retSuccess('success');
}
/**
* @api {post} api/complaint/action 举报投诉 - 操作
*
* @apiDescription 需要token身份管理员权限
* @apiVersion 1.0.0
* @apiGroup complaint
* @apiName action
*
* @apiBody {Number} id 投诉ID
* @apiBody {String} type 操作类型handle=已处理delete=删除
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* @apiSuccessExample {json} Success-Response-Data:
* []
*/
public function action()
{
$user = User::auth();
$user->identity('admin');
//
$id = intval(Request::input('id'));
$type = trim(Request::input('type'));
//
if ($type == 'handle') {
Complaint::whereId($id)->update([
"status" => 1
]);
}
if ($type == 'delete') {
Complaint::whereId($id)->delete();
}
//
return Base::retSuccess('success');
}
}

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,6 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialog;
use App\Exceptions\ApiException; use App\Exceptions\ApiException;
use App\Models\AbstractModel; use App\Models\AbstractModel;
use App\Models\File; use App\Models\File;
@ -11,19 +9,11 @@ use App\Models\FileContent;
use App\Models\FileLink; use App\Models\FileLink;
use App\Models\FileUser; use App\Models\FileUser;
use App\Models\User; use App\Models\User;
use App\Models\UserRecentItem;
use App\Module\Base; use App\Module\Base;
use App\Module\Down;
use App\Module\Lock;
use App\Module\Timer;
use App\Module\Ihttp; use App\Module\Ihttp;
use App\Module\Manticore\ManticoreFile;
use Response;
use Swoole\Coroutine;
use Carbon\Carbon; use Carbon\Carbon;
use Redirect; use Redirect;
use Request; use Request;
use ZipArchive;
/** /**
* @apiDefine file * @apiDefine file
@ -33,7 +23,7 @@ use ZipArchive;
class FileController extends AbstractController class FileController extends AbstractController
{ {
/** /**
* @api {get} api/file/lists 获取文件列表 * @api {get} api/file/lists 01. 获取文件列表
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -50,13 +40,14 @@ class FileController extends AbstractController
{ {
$user = User::auth(); $user = User::auth();
// //
$pid = intval(Request::input('pid')); $data = Request::all();
$pid = intval($data['pid']);
// //
return Base::retSuccess('success', (new File)->getFileList($user, $pid)); return Base::retSuccess('success', (new File)->getFileList($user, $pid));
} }
/** /**
* @api {get} api/file/one 获取单条数据 * @api {get} api/file/one 02. 获取单条数据
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -66,14 +57,6 @@ class FileController extends AbstractController
* @apiParam {Number|String} id * @apiParam {Number|String} id
* - Number 文件ID需要登录 * - Number 文件ID需要登录
* - String 链接码(不需要登录,用于预览) * - String 链接码(不需要登录,用于预览)
* @apiParam {String} [with_url] 是否返回文件访问URL
* - no: 不返回(默认)
* - yes: 返回content_url字段
* @apiParam {String} [with_text] 是否提取文件文本内容用于AI阅读支持分页
* - no: 不提取(默认)
* - yes: 提取文本内容,支持 docx/xlsx/pptx/pdf/txt 等格式
* @apiParam {Number} [text_offset] with_text=yes时有效文本起始位置字符数默认0
* @apiParam {Number} [text_limit] with_text=yes时有效文本获取长度字符数默认50000最大200000
* *
* @apiSuccess {Number} ret 返回状态码1正确、0错误 * @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述) * @apiSuccess {String} msg 返回信息(错误描述)
@ -82,15 +65,11 @@ class FileController extends AbstractController
public function one() public function one()
{ {
$id = Request::input('id'); $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; $permission = 0;
if (Base::isNumber($id)) { if (Base::isNumber($id)) {
$user = User::auth(); $user = User::auth();
$file = File::permissionFind(intval($id), $user, $with_url === 'yes' ? 1 : 0, $permission); $file = File::permissionFind(intval($id), $user, 0, $permission);
} elseif ($id) { } elseif ($id) {
$fileLink = FileLink::whereCode($id)->first(); $fileLink = FileLink::whereCode($id)->first();
$file = $fileLink?->file; $file = $fileLink?->file;
@ -102,87 +81,25 @@ class FileController extends AbstractController
} }
return Base::retError($msg, $data); return Base::retError($msg, $data);
} }
// 如果文件不允许游客访问,则需要登录
if (!$file->guest_access) {
User::auth();
}
$fileLink->increment("num");
} else { } else {
return Base::retError('参数错误'); return Base::retError('参数错误');
} }
// //
$array = $file->toArray(); $array = $file->toArray();
$array['permission'] = $permission; $array['permission'] = $permission;
// 如果请求返回文件URL
if ($with_url === 'yes') {
$array['content_url'] = FileContent::getFileUrl($file->id);
}
// 如果请求提取文本内容
if ($with_text === 'yes') {
$array['text_content'] = ManticoreFile::extractFileContentPaginated($file, $text_offset, $text_limit);
}
return Base::retSuccess('success', $array); return Base::retSuccess('success', $array);
} }
/** /**
* @api {get} api/file/fetch 通过路径获取文件文本内容 * @api {get} api/file/search 03. 搜索文件列表
* *
* @apiDescription 用于 MCP/AI 工具通过文件路径获取内容,支持分页获取大文件 * @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup file
* @apiName fetch
*
* @apiParam {String} path 文件路径(相对于系统根目录,如 uploads/file/...
* @apiParam {Number} [offset] 起始位置字符数默认0
* @apiParam {Number} [limit] 获取长度字符数默认50000最大200000
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* - content: 文本内容
* - total_length: 完整内容总长度
* - offset: 当前起始位置
* - limit: 本次获取长度
* - has_more: 是否还有更多内容
*/
public function fetch()
{
User::auth();
//
$path = trim(Request::input('path'));
$offset = intval(Request::input('offset', 0));
$limit = intval(Request::input('limit', 50000));
if (empty($path)) {
return Base::retError('参数错误path 不能为空');
}
// 直接传入路径ManticoreFile 内部处理 URL 解析
$result = ManticoreFile::extractFileContentPaginated($path, $offset, $limit);
if (isset($result['error'])) {
return Base::retError($result['error']);
}
return Base::retSuccess('success', $result);
}
/**
* @api {get} api/file/search 搜索文件列表
*
* @apiDescription 需要token身份仅搜索文件名AI 内容搜索请使用 api/search/file
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup file * @apiGroup file
* @apiName search * @apiName search
* *
* @apiParam {String} [link] 通过分享地址搜索https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ== * @apiParam {String} [link] 通过分享地址搜索https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==
* @apiParam {String} [key] 关键词 * @apiParam {String} [key] 关键词
* @apiParam {Number} [take] 获取数量默认50最大100
* *
* @apiSuccess {Number} ret 返回状态码1正确、0错误 * @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述) * @apiSuccess {String} msg 返回信息(错误描述)
@ -195,7 +112,7 @@ class FileController extends AbstractController
$link = trim(Request::input('link')); $link = trim(Request::input('link'));
$key = trim(Request::input('key')); $key = trim(Request::input('key'));
$id = 0; $id = 0;
$take = Base::getPaginate(100, 50, 'take'); $take = 50;
if (preg_match("/\/single\/file\/(.*?)$/i", $link, $match)) { if (preg_match("/\/single\/file\/(.*?)$/i", $link, $match)) {
$id = intval(FileLink::whereCode($match[1])->value('file_id')); $id = intval(FileLink::whereCode($match[1])->value('file_id'));
$take = 1; $take = 1;
@ -203,20 +120,13 @@ class FileController extends AbstractController
return Base::retSuccess('success', []); return Base::retSuccess('success', []);
} }
} }
// 搜索自己的 // 搜索自己的
$builder = File::whereUserid($user->userid); $builder = File::whereUserid($user->userid);
if ($id) { if ($id) {
$builder->where("id", $id); $builder->where("id", $id);
} }
if ($key) { if ($key) {
if (!$id && Base::isNumber($key)) { $builder->where("name", "like", "%{$key}%");
$builder->where(function ($query) use ($key) {
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
});
} else {
$builder->where("name", "like", "%{$key}%");
}
} }
$array = $builder->take($take)->get()->toArray(); $array = $builder->take($take)->get()->toArray();
// 搜索共享的 // 搜索共享的
@ -235,13 +145,7 @@ class FileController extends AbstractController
$builder->where("id", $id); $builder->where("id", $id);
} }
if ($key) { if ($key) {
if (Base::isNumber($key)) { $builder->where("name", "like", "%{$key}%");
$builder->where(function ($query) use ($key) {
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
});
} else {
$builder->where("name", "like", "%{$key}%");
}
} }
$list = $builder->take($take)->get(); $list = $builder->take($take)->get();
if ($list->isNotEmpty()) { if ($list->isNotEmpty()) {
@ -259,7 +163,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/add 添加、修改文件() * @api {get} api/file/add 04. 添加、修改文件()
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -285,8 +189,8 @@ class FileController extends AbstractController
$pid = intval(Request::input('pid')); $pid = intval(Request::input('pid'));
if (mb_strlen($name) < 2) { if (mb_strlen($name) < 2) {
return Base::retError('文件名称不可以少于2个字'); return Base::retError('文件名称不可以少于2个字');
} elseif (mb_strlen($name) > 100) { } elseif (mb_strlen($name) > 32) {
return Base::retError('文件名称最多只能设置100个字'); return Base::retError('文件名称最多只能设置32个字');
} }
$tmpName = preg_replace("/[\\\\\/:*?\"<>|]/", '', $name); $tmpName = preg_replace("/[\\\\\/:*?\"<>|]/", '', $name);
if ($tmpName != $name) { if ($tmpName != $name) {
@ -368,7 +272,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/copy 复制文件() * @api {get} api/file/copy 05. 复制文件()
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -391,7 +295,6 @@ class FileController extends AbstractController
// //
$userid = $user->userid; $userid = $user->userid;
if ($row->pid > 0) { if ($row->pid > 0) {
File::permissionFind($row->pid, $user, 1);
$userid = intval(File::whereId($row->pid)->value('userid')); $userid = intval(File::whereId($row->pid)->value('userid'));
} }
// //
@ -429,7 +332,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/move 移动文件() * @api {get} api/file/move 06. 移动文件()
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -476,7 +379,7 @@ class FileController extends AbstractController
throw new ApiException("{$file->name} 内含有共享文件,无法移动到另一个共享文件夹内"); throw new ApiException("{$file->name} 内含有共享文件,无法移动到另一个共享文件夹内");
} }
$file->userid = $toShareFile->userid; $file->userid = $toShareFile->userid;
$file->updateChildFilesUserid($toShareFile->userid); File::where('pids', 'LIKE', "%,{$file->id},%")->update(['userid' => $toShareFile->userid]);
} }
// //
$tmpId = $pid; $tmpId = $pid;
@ -488,7 +391,7 @@ class FileController extends AbstractController
} }
} else { } else {
$file->userid = $user->userid; $file->userid = $user->userid;
$file->updateChildFilesUserid($user->userid); File::where('pids', 'LIKE', "%,{$file->id},%")->update(['userid' => $user->userid]);
} }
// //
$file->pid = $pid; $file->pid = $pid;
@ -504,7 +407,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/remove 删除文件() * @api {get} api/file/remove 07. 删除文件()
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -543,7 +446,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/content 获取文件内容 * @api {get} api/file/content 08. 获取文件内容
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -575,7 +478,7 @@ class FileController extends AbstractController
// //
if (Base::isNumber($id)) { if (Base::isNumber($id)) {
$user = User::auth(); $user = User::auth();
$file = File::permissionFind(intval($id), $user, $down == 'yes' ? 1 : 0); $file = File::permissionFind(intval($id), $user);
} elseif ($id) { } elseif ($id) {
$fileLink = FileLink::whereCode($id)->first(); $fileLink = FileLink::whereCode($id)->first();
$file = $fileLink?->file; $file = $fileLink?->file;
@ -591,10 +494,6 @@ class FileController extends AbstractController
return Base::retError('参数错误'); return Base::retError('参数错误');
} }
// //
if ($down == 'no') {
File::isNeedInstallApp($file->type);
}
//
if ($only_update_at == 'yes') { if ($only_update_at == 'yes') {
return Base::retSuccess('success', [ return Base::retSuccess('success', [
'id' => $file->id, 'id' => $file->id,
@ -607,16 +506,6 @@ class FileController extends AbstractController
$builder->whereId($history_id); $builder->whereId($history_id);
} }
$content = $builder->orderByDesc('id')->first(); $content = $builder->orderByDesc('id')->first();
if (isset($user)) {
UserRecentItem::record(
$user->userid,
UserRecentItem::TYPE_FILE,
$file->id,
UserRecentItem::SOURCE_FILESYSTEM,
intval($file->pid)
);
}
if ($down === 'preview') { if ($down === 'preview') {
return Redirect::to(FileContent::formatPreview($file, $content?->content)); return Redirect::to(FileContent::formatPreview($file, $content?->content));
} }
@ -624,7 +513,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/content/save 保存文件内容 * @api {get} api/file/content/save 09. 保存文件内容
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -664,7 +553,7 @@ class FileController extends AbstractController
} }
} }
$text = strip_tags($data['content']); $text = strip_tags($data['content']);
if ($isRep) { if ($isRep == true) {
$content = Base::array2json($data); $content = Base::array2json($data);
} }
} }
@ -679,12 +568,10 @@ class FileController extends AbstractController
$contentArray = Base::json2array($content); $contentArray = Base::json2array($content);
$contentString = $contentArray['xml']; $contentString = $contentArray['xml'];
$file->ext = 'drawio'; $file->ext = 'drawio';
File::isNeedInstallApp($file->type);
break; break;
case 'mind': case 'mind':
$contentString = $content; $contentString = $content;
$file->ext = 'mind'; $file->ext = 'mind';
File::isNeedInstallApp($file->type);
break; break;
case 'txt': case 'txt':
case 'code': case 'code':
@ -719,9 +606,9 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/office/token 获取token * @api {get} api/file/office/token 10. 获取token
* *
* @apiDescription 用于生成office在线编辑的token * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup file * @apiGroup file
* @apiName office__token * @apiName office__token
@ -734,7 +621,7 @@ class FileController extends AbstractController
*/ */
public function office__token() public function office__token()
{ {
File::isNeedInstallApp('office'); User::auth();
// //
$config = Request::input('config'); $config = Request::input('config');
$token = \Firebase\JWT\JWT::encode($config, env('APP_KEY') ,'HS256'); $token = \Firebase\JWT\JWT::encode($config, env('APP_KEY') ,'HS256');
@ -744,7 +631,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/content/office 保存文件内容office * @api {get} api/file/content/office 11. 保存文件内容office
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -761,8 +648,6 @@ class FileController extends AbstractController
{ {
$user = User::auth(); $user = User::auth();
// //
File::isNeedInstallApp('office');
//
$id = intval(Request::input('id')); $id = intval(Request::input('id'));
$status = intval(Request::input('status')); $status = intval(Request::input('status'));
$key = Request::input('key'); $key = Request::input('key');
@ -772,7 +657,7 @@ class FileController extends AbstractController
// //
if ($status === 2) { if ($status === 2) {
$parse = parse_url($url); $parse = parse_url($url);
$from = 'http://nginx' . $parse['path'] . '?' . $parse['query']; $from = 'http://' . env('APP_IPPR') . '.3' . $parse['path'] . '?' . $parse['query'];
$path = 'uploads/file/' . $file->type . '/' . date("Ym") . '/' . $file->id . '/' . $key; $path = 'uploads/file/' . $file->type . '/' . date("Ym") . '/' . $file->id . '/' . $key;
$save = public_path($path); $save = public_path($path);
Base::makeDir(dirname($save)); Base::makeDir(dirname($save));
@ -800,7 +685,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/content/upload 保存文件内容(上传文件) * @api {get} api/file/content/upload 12. 保存文件内容(上传文件)
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -808,9 +693,6 @@ class FileController extends AbstractController
* @apiName content__upload * @apiName content__upload
* *
* @apiParam {Number} [pid] 父级ID * @apiParam {Number} [pid] 父级ID
* @apiParam {Number} [cover] 覆盖已存在的文件
* - 0:不覆盖,保留两者(默认)
* - 1:覆盖
* @apiParam {String} [files] 文件名 * @apiParam {String} [files] 文件名
* *
* @apiSuccess {Number} ret 返回状态码1正确、0错误 * @apiSuccess {Number} ret 返回状态码1正确、0错误
@ -821,24 +703,13 @@ class FileController extends AbstractController
{ {
$user = User::auth(); $user = User::auth();
$pid = intval(Request::input('pid')); $pid = intval(Request::input('pid'));
// 同一用户往相同父目录上传时排队,避免并发导致数据库死锁 $webkitRelativePath = Request::input('webkitRelativePath');
try { $data = (new File)->contentUpload($user, $pid, $webkitRelativePath);
return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid) { return Base::retSuccess($data['data']['name'] . ' 上传成功', $data['addItem']);
$overwrite = intval(Request::input('cover'));
$webkitRelativePath = Request::input('webkitRelativePath');
$data = (new File)->contentUpload($user, $pid, $webkitRelativePath, $overwrite);
return Base::retSuccess($data['data']['name'] . ' 上传成功', $data['addItem']);
}, 120000, 120000);
} catch (\Exception $e) {
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
throw new ApiException('上传繁忙,请稍后再试');
}
throw $e;
}
} }
/** /**
* @api {get} api/file/content/history 获取内容历史 * @api {get} api/file/content/history 13. 获取内容历史
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -870,7 +741,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/content/restore 恢复文件历史 * @api {get} api/file/content/restore 14. 恢复文件历史
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -893,8 +764,6 @@ class FileController extends AbstractController
// //
$file = File::permissionFind($id, $user); $file = File::permissionFind($id, $user);
// //
File::isNeedInstallApp($file->type);
//
$history = FileContent::whereFid($file->id)->whereId($history_id)->first(); $history = FileContent::whereFid($file->id)->whereId($history_id)->first();
if (empty($history)) { if (empty($history)) {
return Base::retError('历史数据不存在或已被删除'); return Base::retError('历史数据不存在或已被删除');
@ -912,7 +781,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/share 获取共享信息 * @api {get} api/file/share 15. 获取共享信息
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -948,7 +817,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/share/update 设置共享 * @api {get} api/file/share/update 16. 设置共享
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -1038,7 +907,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/share/out 退出共享 * @api {get} api/file/share/out 17. 退出共享
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -1072,7 +941,7 @@ class FileController extends AbstractController
} }
/** /**
* @api {get} api/file/link 获取链接 * @api {get} api/file/link 18. 获取链接
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -1083,9 +952,6 @@ class FileController extends AbstractController
* @apiParam {String} refresh 刷新链接 * @apiParam {String} refresh 刷新链接
* - no: 只获取(默认) * - no: 只获取(默认)
* - yes: 刷新链接,之前的将失效 * - yes: 刷新链接,之前的将失效
* @apiParam {String} guest_access 是否允许游客访问
* - no: 不允许(默认)
* - yes: 允许游客访问
* *
* @apiSuccess {Number} ret 返回状态码1正确、0错误 * @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述) * @apiSuccess {String} msg 返回信息(错误描述)
@ -1097,156 +963,10 @@ class FileController extends AbstractController
// //
$id = intval(Request::input('id')); $id = intval(Request::input('id'));
$refresh = Request::input('refresh', 'no'); $refresh = Request::input('refresh', 'no');
$guestAccess = Request::input('guest_access', 'no');
// //
$file = File::permissionFind($id, $user); $file = File::permissionFind($id, $user);
// 更新文件的游客访问权限
$file->guest_access = $guestAccess === 'yes' ? 1 : 0;
$file->save();
$fileLink = $file->getShareLink($user->userid, $refresh == 'yes'); $fileLink = $file->getShareLink($user->userid, $refresh == 'yes');
$fileLink['guest_access'] = $file->guest_access;
// //
return Base::retSuccess('success', $fileLink); return Base::retSuccess('success', $fileLink);
} }
/**
* @api {get} api/file/download/pack 打包文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup file
* @apiName download__pack
*
* @apiParam {Array} [ids] 文件ID格式: [id, id2, id3]
* @apiParam {String} [name] 下载文件名
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function download__pack()
{
if (Request::has('key')) {
$array = Down::cache_decode();
$file = $array['file'];
if (empty($file) || !file_exists(storage_path($file))) {
return Base::ajaxError("文件不存在!", [], 0, 403);
}
return Response::download(storage_path($file));
}
$user = User::auth();
if ($user->isTemp()) {
return Base::retError('无法打包下载');
}
$setting = Base::setting('fileSetting');
switch ($setting['permission_pack_type']) {
case 'admin':
if (!$user->isAdmin()) {
return Base::retError('此功能仅管理员可用');
}
break;
case 'appointAllow':
if (!in_array($user->userid, $setting['permission_pack_userids'])) {
return Base::retError('此功能仅指定用户可用');
}
break;
case 'appointProhibit':
if (in_array($user->userid, $setting['permission_pack_userids'])) {
return Base::retError('此功能已禁止使用');
}
break;
}
$ids = Request::input('ids');
$fileName = Request::input('name');
$fileName = preg_replace("/[\/\\\:\*\?\"\<\>\|]/", "", $fileName);
if (empty($fileName)) {
$fileName = 'Package_' . $user->userid;
}
$fileName .= '_' . Timer::time() . '.zip';
$filePath = "temp/file/pack/" . date("Ym", Timer::time());
$zipFile = "app/" . $filePath . "/" . $fileName;
$zipPath = storage_path($zipFile);
if (!is_array($ids) || empty($ids)) {
return Base::retError('请选择下载的文件或文件夹');
}
if (count($ids) > 100) {
return Base::retError('一次最多可以下载100个文件或文件夹');
}
$botUser = User::botGetOrCreate('system-msg');
if (empty($botUser)) {
return Base::retError('系统机器人不存在');
}
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
$files = [];
$totalSize = 0;
foreach ($ids as $k => $id) {
$files[] = File::getFilesTree(intval($id), $user, 1);
$totalSize += $files[$k]->totalSize;
}
if ($totalSize > File::zipMaxSize) {
return Base::retError('文件总大小已超过1GB请分批下载');
}
$key = Down::cache_encode([
'file' => $zipFile,
]);
$fileUrl = Base::fillUrl('api/file/download/pack?key=' . $key);
$zip = new \ZipArchive();
Base::makeDir(dirname($zipPath));
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
return Base::retError('创建压缩文件失败');
}
$userid = $user->userid;
go(function () use ($userid, $zipPath, $fileUrl, $zip, $files, $fileName, $botUser, $dialog) {
Coroutine::sleep(0.1);
// 压缩进度
$progress = 0;
$zip->registerProgressCallback(0.05, function ($ratio) use ($userid, $fileUrl, $fileName, &$progress) {
$progress = round($ratio * 100);
File::pushMsgSimple('compress', [
'name' => $fileName,
'url' => $fileUrl,
'progress' => $progress
], $userid);
});
//
foreach ($files as $file) {
File::addFileTreeToZip($zip, $file);
}
$zip->close();
//
if ($progress < 100) {
File::pushMsgSimple('compress', [
'name' => $fileName,
'url' => $fileUrl,
'progress' => 100
], $userid);
}
//
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'file_download',
'title' => '文件下载打包已完成',
'name' => $fileName,
'size' => filesize($zipPath),
'url' => $fileUrl,
], $botUser->userid, false, false, true);
});
return Base::retSuccess('success', [
'name' => $fileName,
'url' => $fileUrl,
]);
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,15 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Models\UserBot; use App\Models\UserBot;
use App\Models\UserCheckinMac;
use App\Models\UserCheckinRecord;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Module\Base; use App\Module\Base;
use Cache;
use Carbon\Carbon;
use Request; use Request;
/** /**
@ -65,9 +72,7 @@ class PublicController extends AbstractController
} }
/** /**
* {post} 签到 - 上报 * {post} 签到 - 路由器openwrt上报
* - 1、路由器openwrt签到上报
* - 2、考勤机签到上报
* *
* @apiParam {String} key * @apiParam {String} key
* @apiParam {String} mac 使用逗号分割多个 * @apiParam {String} mac 使用逗号分割多个
@ -80,30 +85,20 @@ class PublicController extends AbstractController
$key = trim(Request::input('key')); $key = trim(Request::input('key'));
$mac = trim(Request::input('mac')); $mac = trim(Request::input('mac'));
$time = intval(Request::input('time')); $time = intval(Request::input('time'));
$type = trim(Request::input('type'));
// //
$setting = Base::setting('checkinSetting'); $setting = Base::setting('checkinSetting');
if ($setting['open'] !== 'open') { if ($setting['open'] !== 'open') {
return 'function off'; return 'function off';
} }
$alreadyTip = false; if (!in_array('auto', $setting['modes'])) {
if ($type === 'face') { return 'mode off';
if (!in_array('face', $setting['modes'])) { }
return 'mode off'; if ($key != $setting['key']) {
} return 'key error';
if ($key != $setting['face_key']) { }
return 'key error'; if ($error = UserBot::checkinBotCheckin($mac, $time)) {
} return $error;
$alreadyTip = $setting['face_retip'] === 'open';
} else {
if (!in_array('auto', $setting['modes'])) {
return 'mode off';
}
if ($key != $setting['key']) {
return 'key error';
}
} }
UserBot::checkinBotCheckin($mac, $time, $alreadyTip);
return 'success'; return 'success';
} }
} }

View File

@ -6,16 +6,14 @@ use App\Exceptions\ApiException;
use App\Models\AbstractModel; use App\Models\AbstractModel;
use App\Models\ProjectTask; use App\Models\ProjectTask;
use App\Models\Report; use App\Models\Report;
use App\Models\ReportAnalysis;
use App\Models\ReportLink;
use App\Models\ReportReceive; use App\Models\ReportReceive;
use App\Models\User; use App\Models\User;
use App\Models\WebSocketDialogMsg;
use App\Module\Base; use App\Module\Base;
use App\Module\Doo; use App\Module\Doo;
use App\Tasks\PushTask; use App\Tasks\PushTask;
use Carbon\Carbon; use Carbon\Carbon;
use Hhxsv5\LaravelS\Swoole\Task\Task; use Hhxsv5\LaravelS\Swoole\Task\Task;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Request; use Request;
@ -29,15 +27,13 @@ use Illuminate\Support\Facades\Validator;
class ReportController extends AbstractController class ReportController extends AbstractController
{ {
/** /**
* @api {get} api/report/my 我发送的汇报 * @api {get} api/report/my 01. 我发送的汇报
* *
* @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup report * @apiGroup report
* @apiName my * @apiName my
* *
* @apiParam {Object} [keys] 搜索条件 * @apiParam {Object} [keys] 搜索条件
* - keys.key: 关键词
* - keys.type: 汇报类型weekly:周报daily:日报 * - keys.type: 汇报类型weekly:周报daily:日报
* - keys.created_at: 汇报时间 * - keys.created_at: 汇报时间
* @apiParam {Number} [page] 当前页,默认:1 * @apiParam {Number} [page] 当前页,默认:1
@ -51,31 +47,15 @@ class ReportController extends AbstractController
{ {
$user = User::auth(); $user = User::auth();
// //
$builder = Report::with(['receivesUser']) $builder = Report::with(['receivesUser'])->whereUserid($user->userid);
->select(Report::LIST_FIELDS)
->whereUserid($user->userid);
$keys = Request::input('keys'); $keys = Request::input('keys');
if (is_array($keys)) { if (is_array($keys)) {
if ($keys['key']) {
if (str_contains($keys['key'], '@')) {
$builder->whereHas('sendUser', function ($q2) use ($keys) {
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
});
} elseif (Base::isNumber($keys['key'])) {
$builder->where(function ($query) use ($keys) {
$query->where("id", intval($keys['key']))
->orWhere("title", "LIKE", "%{$keys['key']}%");
});
} else {
$builder->where("title", "LIKE", "%{$keys['key']}%");
}
}
if (in_array($keys['type'], [Report::WEEKLY, Report::DAILY])) { if (in_array($keys['type'], [Report::WEEKLY, Report::DAILY])) {
$builder->whereType($keys['type']); $builder->whereType($keys['type']);
} }
if (is_array($keys['created_at'])) { if (is_array($keys['created_at'])) {
if ($keys['created_at'][0] > 0) $builder->where('created_at', '>=', Base::newCarbon($keys['created_at'][0])->startOfDay()); if ($keys['created_at'][0] > 0) $builder->where('created_at', '>=', date('Y-m-d H:i:s', Base::dayTimeF($keys['created_at'][0])));
if ($keys['created_at'][1] > 0) $builder->where('created_at', '<=', Base::newCarbon($keys['created_at'][1])->endOfDay()); if ($keys['created_at'][1] > 0) $builder->where('created_at', '<=', date('Y-m-d H:i:s', Base::dayTimeE($keys['created_at'][1])));
} }
} }
$list = $builder->orderByDesc('created_at')->paginate(Base::getPaginate(50, 20)); $list = $builder->orderByDesc('created_at')->paginate(Base::getPaginate(50, 20));
@ -83,18 +63,15 @@ class ReportController extends AbstractController
} }
/** /**
* @api {get} api/report/receive 我接收的汇报 * @api {get} api/report/receive 02. 我接收的汇报
* *
* @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup report * @apiGroup report
* @apiName receive * @apiName receive
* *
* @apiParam {Object} [keys] 搜索条件 * @apiParam {Object} [keys] 搜索条件
* - keys.key: 关键词 * - keys.key: 关键词
* - keys.department_id: 部门ID
* - keys.type: 汇报类型weekly:周报daily:日报 * - keys.type: 汇报类型weekly:周报daily:日报
* - keys.status: 状态unread:未读read:已读
* - keys.created_at: 汇报时间 * - keys.created_at: 汇报时间
* @apiParam {Number} [page] 当前页,默认:1 * @apiParam {Number} [page] 当前页,默认:1
* @apiParam {Number} [pagesize] 每页显示数量,默认:20,最大:50 * @apiParam {Number} [pagesize] 每页显示数量,默认:20,最大:50
@ -106,59 +83,39 @@ class ReportController extends AbstractController
public function receive(): array public function receive(): array
{ {
$user = User::auth(); $user = User::auth();
$builder = Report::with(['receivesUser']) $builder = Report::with(['receivesUser']);
->select(Report::LIST_FIELDS);
$builder->whereHas("receivesUser", function ($query) use ($user) { $builder->whereHas("receivesUser", function ($query) use ($user) {
$query->where("report_receives.userid", $user->userid); $query->where("report_receives.userid", $user->userid);
}); });
$keys = Request::input('keys'); $keys = Request::input('keys');
if (is_array($keys)) { if (is_array($keys)) {
if ($keys['key']) { if ($keys['key']) {
if (str_contains($keys['key'], '@')) { $builder->where(function($query) use ($keys) {
$builder->whereHas('sendUser', function ($q2) use ($keys) { $query->whereHas('sendUser', function ($q2) use ($keys) {
$q2->where("users.email", "LIKE", "%{$keys['key']}%"); $q2->where("users.email", "LIKE", "%{$keys['key']}%");
}); })->orWhere("title", "LIKE", "%{$keys['key']}%");
} elseif (Base::isNumber($keys['key'])) {
$builder->where(function ($query) use ($keys) {
$query->where("userid", intval($keys['key']))
->orWhere("id", intval($keys['key']))
->orWhere("title", "LIKE", "%{$keys['key']}%");
});
} else {
$builder->where("title", "LIKE", "%{$keys['key']}%");
}
}
if ($keys['department_id']) {
$builder->whereHas('sendUser', function ($query) use ($keys) {
$query->where("users.department", "LIKE", "%,{$keys['department_id']},%");
}); });
} }
if (in_array($keys['type'], [Report::WEEKLY, Report::DAILY])) { if (in_array($keys['type'], [Report::WEEKLY, Report::DAILY])) {
$builder->whereType($keys['type']); $builder->whereType($keys['type']);
} }
if (in_array($keys['status'], ['unread', 'read'])) {
$builder->whereHas("receivesUser", function ($query) use ($user, $keys) {
$query->where("report_receives.userid", $user->userid)->where("report_receives.read", $keys['status'] === 'unread' ? 0 : 1);
});
}
if (is_array($keys['created_at'])) { if (is_array($keys['created_at'])) {
if ($keys['created_at'][0] > 0) $builder->where('created_at', '>=', Base::newCarbon($keys['created_at'][0])->startOfDay()); if ($keys['created_at'][0] > 0) $builder->where('created_at', '>=', date('Y-m-d H:i:s', Base::dayTimeF($keys['created_at'][0])));
if ($keys['created_at'][1] > 0) $builder->where('created_at', '<=', Base::newCarbon($keys['created_at'][1])->endOfDay()); if ($keys['created_at'][1] > 0) $builder->where('created_at', '<=', date('Y-m-d H:i:s', Base::dayTimeE($keys['created_at'][1])));
} }
} }
$list = $builder->orderByDesc('created_at')->paginate(Base::getPaginate(50, 20)); $list = $builder->orderByDesc('created_at')->paginate(Base::getPaginate(50, 20));
if ($list->items()) { if ($list->items()) {
foreach ($list->items() as $item) { foreach ($list->items() as $item) {
$item->receive_at = ReportReceive::query()->whereRid($item["id"])->whereUserid($user->userid)->value("receive_at"); $item->receive_time = ReportReceive::query()->whereRid($item["id"])->whereUserid($user->userid)->value("receive_time");
} }
} }
return Base::retSuccess('success', $list); return Base::retSuccess('success', $list);
} }
/** /**
* @api {get} api/report/store 保存并发送工作汇报 * @api {get} api/report/store 03. 保存并发送工作汇报
* *
* @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup report * @apiGroup report
* @apiName store * @apiName store
@ -218,7 +175,7 @@ class ReportController extends AbstractController
foreach ($input["receive"] as $userid) { foreach ($input["receive"] as $userid) {
$input["receive_content"][] = [ $input["receive_content"][] = [
"receive_at" => Carbon::now()->toDateTimeString(), "receive_time" => Carbon::now()->toDateTimeString(),
"userid" => $userid, "userid" => $userid,
"read" => 0, "read" => 0,
]; ];
@ -234,6 +191,7 @@ class ReportController extends AbstractController
$report->updateInstance([ $report->updateInstance([
"title" => $input["title"], "title" => $input["title"],
"type" => $input["type"], "type" => $input["type"],
"content" => htmlspecialchars($input["content"]),
]); ]);
} else { } else {
// 生成唯一标识 // 生成唯一标识
@ -247,25 +205,11 @@ class ReportController extends AbstractController
"title" => $input["title"], "title" => $input["title"],
"type" => $input["type"], "type" => $input["type"],
"userid" => $user->userid, "userid" => $user->userid,
"content" => htmlspecialchars($input["content"]),
]); ]);
} }
$report->save(); $report->save();
// 保存内容
$content = $input["content"];
preg_match_all("/<img\s+src=\"data:image\/(png|jpg|jpeg|webp);base64,(.*?)\"/s", $content, $matchs);
foreach ($matchs[2] as $key => $text) {
$tmpPath = "uploads/report/" . Carbon::parse($report->created_at)->format("Ym") . "/" . $report->id . "/attached/";
Base::makeDir(public_path($tmpPath));
$tmpPath .= md5($text) . "." . $matchs[1][$key];
if (Base::saveContentImage(public_path($tmpPath), base64_decode($text))) {
$paramet = getimagesize(public_path($tmpPath));
$content = str_replace($matchs[0][$key], '<img src="' . Base::fillUrl($tmpPath) . '" original-width="' . $paramet[0] . '" original-height="' . $paramet[1] . '"', $content);
}
}
$report->content = htmlspecialchars($content);
$report->save();
// 删除关联 // 删除关联
$report->Receives()->delete(); $report->Receives()->delete();
if ($input["receive_content"]) { if ($input["receive_content"]) {
@ -295,9 +239,8 @@ class ReportController extends AbstractController
} }
/** /**
* @api {get} api/report/template 生成汇报模板 * @api {get} api/report/template 04. 生成汇报模板
* *
* @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup report * @apiGroup report
* @apiName template * @apiName template
@ -317,7 +260,6 @@ class ReportController extends AbstractController
$offset = abs(intval(Request::input("offset", 0))); $offset = abs(intval(Request::input("offset", 0)));
$id = intval(Request::input("offset", 0)); $id = intval(Request::input("offset", 0));
$now_dt = trim(Request::input("date")) ? Carbon::parse(Request::input("date")) : Carbon::now(); $now_dt = trim(Request::input("date")) ? Carbon::parse(Request::input("date")) : Carbon::now();
// 获取开始时间 // 获取开始时间
if ($type === Report::DAILY) { if ($type === Report::DAILY) {
$start_time = Carbon::today(); $start_time = Carbon::today();
@ -339,18 +281,9 @@ class ReportController extends AbstractController
$start_time->startOfWeek(); $start_time->startOfWeek();
$end_time = Carbon::instance($start_time)->endOfWeek(); $end_time = Carbon::instance($start_time)->endOfWeek();
} }
// 周报时预计算下一周期时间范围(下周)
$next_start_time = null;
$next_end_time = null;
if ($type === Report::WEEKLY) {
$next_start_time = Carbon::instance($start_time)->copy()->addWeek();
$next_end_time = Carbon::instance($end_time)->copy()->addWeek();
}
// 生成唯一标识 // 生成唯一标识
$sign = Report::generateSign($type, 0, Carbon::instance($start_time)); $sign = Report::generateSign($type, 0, Carbon::instance($start_time));
$one = Report::whereSign($sign)->whereType($type)->first(); $one = Report::whereSign($sign)->whereType($type)->first();
// 如果已经提交了相关汇报 // 如果已经提交了相关汇报
if ($one && $id > 0) { if ($one && $id > 0) {
return Base::retSuccess('success', [ return Base::retSuccess('success', [
@ -361,16 +294,8 @@ class ReportController extends AbstractController
]); ]);
} }
// 表格头部
$labels = [
Doo::translate('项目'),
Doo::translate('任务'),
Doo::translate('负责人'),
Doo::translate('备注'),
];
// 已完成的任务 // 已完成的任务
$completeDatas = []; $completeContent = "";
$complete_task = ProjectTask::query() $complete_task = ProjectTask::query()
->whereNotNull("complete_at") ->whereNotNull("complete_at")
->whereBetween("complete_at", [$start_time->toDateTimeString(), $end_time->toDateTimeString()]) ->whereBetween("complete_at", [$start_time->toDateTimeString(), $end_time->toDateTimeString()])
@ -381,109 +306,57 @@ class ReportController extends AbstractController
->get(); ->get();
if ($complete_task->isNotEmpty()) { if ($complete_task->isNotEmpty()) {
foreach ($complete_task as $task) { foreach ($complete_task as $task) {
// 排除取消态任务:不将已取消任务计入“已完成工作”
if (ProjectTask::isCanceledFlowName($task->flow_item_name)) {
continue;
}
$complete_at = Carbon::parse($task->complete_at); $complete_at = Carbon::parse($task->complete_at);
$remark = $type == Report::WEEKLY ? ('<div style="text-align:center">[' . Doo::translate('周' . ['日', '一', '二', '三', '四', '五', '六'][$complete_at->dayOfWeek]) . ']</div>') : '&nbsp;'; $pre = $type == Report::WEEKLY ? ('<span>[' . Doo::translate('周' . ['日', '一', '二', '三', '四', '五', '六'][$complete_at->dayOfWeek]) . ']</span>&nbsp;') : '';
$completeDatas[] = [ $completeContent .= "<li>{$pre}[{$task->project->name}] {$task->name}</li>";
$task->project->name,
$task->name,
$task->taskUser->where("owner", 1)->map(function ($item) {
return User::userid2nickname($item->userid);
})->implode(", "),
$remark,
];
} }
} else {
$completeContent = '<li>&nbsp;</li>';
} }
// 未完成的任务 // 未完成的任务
$unfinishedDatas = []; $unfinishedContent = "";
$unfinished_task = ProjectTask::buildUnfinishedTaskQuery($user->userid, $start_time, $end_time, true)->get(); $unfinished_task = ProjectTask::query()
->whereNull("complete_at")
->whereNotNull("start_at")
->where("end_at", "<", $end_time->toDateTimeString())
->whereHas("taskUser", function ($query) use ($user) {
$query->where("userid", $user->userid);
})
->orderByDesc("id")
->get();
if ($unfinished_task->isNotEmpty()) { if ($unfinished_task->isNotEmpty()) {
foreach ($unfinished_task as $task) { foreach ($unfinished_task as $task) {
empty($task->end_at) || $end_at = Carbon::parse($task->end_at); empty($task->end_at) || $end_at = Carbon::parse($task->end_at);
$remark = (!empty($end_at) && $end_at->lt($now_dt)) ? '<div style="color:#ff0000;text-align:center">[' . Doo::translate('超期') . ']</div>' : '&nbsp;'; $pre = (!empty($end_at) && $end_at->lt($now_dt)) ? '<span style="color:#ff0000;">[' . Doo::translate('超期') . ']</span>&nbsp;' : '';
$unfinishedDatas[] = [ $unfinishedContent .= "<li>{$pre}[{$task->project->name}] {$task->name}</li>";
$task->project->name,
$task->name,
$task->taskUser->where("owner", 1)->map(function ($item) {
return User::userid2nickname($item->userid);
})->implode(", "),
$remark,
];
} }
} else {
$unfinishedContent = '<li>&nbsp;</li>';
} }
// 生成标题 // 生成标题
if ($type === Report::WEEKLY) { if ($type === Report::WEEKLY) {
$title = $user->nickname . "的周报[" . $start_time->format("m/d") . "-" . $end_time->format("m/d") . "]"; $title = $user->nickname . "的周报[" . $start_time->format("m/d") . "-" . $end_time->format("m/d") . "]";
$title .= "[" . $start_time->month . "月第" . $start_time->weekOfMonth . "周]"; $title .= "[" . $start_time->month . "月第" . $start_time->weekOfMonth . "周]";
$unfinishedTitle = '本周未完成的工作';
} else { } else {
$title = $user->nickname . "的日报[" . $start_time->format("Y/m/d") . "]"; $title = $user->nickname . "的日报[" . $start_time->format("Y/m/d") . "]";
$unfinishedTitle = '今日未完成的工作';
} }
$title = Doo::translate($title);
// 生成内容 // 生成内容
$contents = []; $content = '<h2>' . Doo::translate('已完成工作') . '</h2><ol>' .
$contents[] = '<h2>' . Doo::translate('已完成工作') . '</h2>'; $completeContent . '</ol><h2>' .
$contents[] = view('report', [ Doo::translate('未完成的工作') . '</h2><ol>' .
'labels' => $labels, $unfinishedContent . '</ol>';
'datas' => $completeDatas,
])->render();
$contents[] = '<p>&nbsp;</p>';
$contents[] = '<h2>' . Doo::translate($unfinishedTitle) . '</h2>';
$contents[] = view('report', [
'labels' => $labels,
'datas' => $unfinishedDatas,
])->render();
if ($type === Report::WEEKLY) { if ($type === Report::WEEKLY) {
// 下周拟定计划:基于下周时间范围预生成候选任务 $content .= "<h2>" . Doo::translate("下周拟定计划") . "[" . $start_time->addWeek()->format("m/d") . "-" . $end_time->addWeek()->format("m/d") . "]</h2><ol><li>&nbsp;</li></ol>";
$nextPlanDatas = [];
if ($next_start_time && $next_end_time) {
$next_tasks = ProjectTask::buildUnfinishedTaskQuery($user->userid, $next_start_time, $next_end_time, false)->get();
if ($next_tasks->isNotEmpty()) {
foreach ($next_tasks as $task) {
$planTime = '-';
if ($task->start_at || $task->end_at) {
$startText = $task->start_at ? Carbon::parse($task->start_at)->format('Y-m-d H:i') : '';
$endText = $task->end_at ? Carbon::parse($task->end_at)->format('Y-m-d H:i') : '';
$planTime = trim($startText . ($endText ? (' ~ ' . $endText) : ''));
}
$nextPlanDatas[] = [
'[' . $task->project->name . '] ' . $task->name,
$planTime,
$task->taskUser->where("owner", 1)->map(function ($item) {
return User::userid2nickname($item->userid);
})->implode(", "),
];
}
}
}
$contents[] = '<p>&nbsp;</p>';
$contents[] = "<h2>" . Doo::translate("下周拟定计划") . "[" . $next_start_time->format("m/d") . "-" . $next_end_time->format("m/d") . "]</h2>";
$contents[] = view('report', [
'labels' => [
Doo::translate('计划描述'),
Doo::translate('计划时间'),
Doo::translate('负责人'),
],
'datas' => $nextPlanDatas,
])->render();
} }
$data = [ $data = [
"time" => $start_time->toDateTimeString(), "time" => $start_time->toDateTimeString(),
"sign" => $sign, "sign" => $sign,
"title" => $title, "title" => $title,
"content" => implode("", $contents), "content" => $content,
"complete_task" => $complete_task,
"unfinished_task" => $unfinished_task,
]; ];
if ($one) { if ($one) {
$data['id'] = $one->id; $data['id'] = $one->id;
} }
@ -491,15 +364,13 @@ class ReportController extends AbstractController
} }
/** /**
* @api {get} api/report/detail 报告详情 * @api {get} api/report/detail 05. 报告详情
* *
* @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup report * @apiGroup report
* @apiName detail * @apiName detail
* *
* @apiParam {Number} [id] 报告ID * @apiParam {Number} [id] 报告id
* @apiParam {String} [code] 报告分享代码与ID二选一优先ID
* *
* @apiSuccess {Number} ret 返回状态码1正确、0错误 * @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述) * @apiSuccess {String} msg 返回信息(错误描述)
@ -508,145 +379,30 @@ class ReportController extends AbstractController
public function detail(): array public function detail(): array
{ {
$user = User::auth(); $user = User::auth();
//
$id = intval(trim(Request::input("id"))); $id = intval(trim(Request::input("id")));
$code = trim(Request::input("code")); if (empty($id))
//
if (empty($id) && empty($code)) {
return Base::retError("缺少ID参数"); return Base::retError("缺少ID参数");
}
// $one = Report::getOne($id);
if (!empty($id)) { $one->type_val = $one->getRawOriginal("type");
$one = Report::getOne($id);
$one->type_val = $one->getRawOriginal("type"); // 标记为已读
// 标记为已读 if (!empty($one->receivesUser)) {
if (!empty($one->receivesUser)) { foreach ($one->receivesUser as $item) {
foreach ($one->receivesUser as $item) { if ($item->userid === $user->userid && $item->pivot->read === 0) {
if ($item->userid === $user->userid && $item->pivot->read === 0) { $one->receivesUser()->updateExistingPivot($user->userid, [
$one->receivesUser()->updateExistingPivot($user->userid, [ "read" => 1,
"read" => 1, ]);
]);
}
} }
} }
} else {
$link = ReportLink::whereCode($code)->first();
if (empty($link)) {
return Base::retError("报告不存在或已被删除");
}
$one = Report::getOne($link->rid);
$one->report_link = $link;
$link->increment("num");
}
$analysis = ReportAnalysis::query()
->whereRid($one->id)
->whereUserid($user->userid)
->first();
if ($analysis) {
$updatedAt = $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null;
$one->setAttribute('ai_analysis', [
'id' => $analysis->id,
'text' => $analysis->analysis_text,
'model' => $analysis->model,
'updated_at' => $updatedAt,
]);
} else {
$one->setAttribute('ai_analysis', null);
} }
return Base::retSuccess("success", $one); return Base::retSuccess("success", $one);
} }
/** /**
* @api {post} api/report/analysave 保存工作汇报 AI 分析 * @api {get} api/report/mark 06. 标记已读/未读
* *
* @apiDescription 需要token身份仅支持报告提交人或接收人保存分析
* @apiVersion 1.0.0
* @apiGroup report
* @apiName analysave
*
* @apiParam {Number} id 报告ID
* @apiParam {String} text 分析内容Markdown
* @apiParam {String} [model] 分析使用的模型标识(可选)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {Number} data.id 分析记录ID
* @apiSuccess {String} data.text 分析内容Markdown
* @apiSuccess {String} data.updated_at 最近更新时间
*/
public function analysave(): array
{
$user = User::auth();
$id = intval(Request::input("id"));
if ($id <= 0) {
return Base::retError("缺少ID参数");
}
$text = trim((string)Request::input('text', ''));
if ($text === '') {
return Base::retError("分析内容不能为空");
}
$model = trim((string)Request::input('model', ''));
$report = Report::getOne($id);
if (!$this->userCanAccessReport($report, $user)) {
return Base::retError("无权访问该工作汇报");
}
$analysis = ReportAnalysis::query()
->whereRid($report->id)
->whereUserid($user->userid)
->first();
if (!$analysis) {
$analysis = ReportAnalysis::fillInstance([
'rid' => $report->id,
'userid' => $user->userid,
]);
}
$viewerRole = $user->profession ?: (is_array($user->identity) && !empty($user->identity) ? implode('/', $user->identity) : null);
$focusMeta = null;
$focus = Request::input('focus');
if (is_array($focus)) {
$focusMeta = array_filter(array_map('trim', $focus));
} elseif (is_string($focus) && trim($focus) !== '') {
$focusMeta = [trim($focus)];
}
$meta = array_filter([
'viewer_role' => $viewerRole,
'viewer_name' => $user->nickname ?? null,
'focus' => $focusMeta,
], function ($value) {
if (is_array($value)) {
return !empty($value);
}
return $value !== null && $value !== '';
});
$analysis->updateInstance([
'model' => $model,
'analysis_text' => $text,
'meta' => $meta,
]);
$analysis->save();
$analysis->refresh();
return Base::retSuccess("success", [
'id' => $analysis->id,
'text' => $analysis->analysis_text,
'model' => $analysis->model,
'updated_at' => $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null,
]);
}
/**
* @api {get} api/report/mark 标记已读/未读
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup report * @apiGroup report
* @apiName mark * @apiName mark
@ -687,71 +443,8 @@ class ReportController extends AbstractController
} }
/** /**
* @api {get} api/report/share 分享报告到消息 * @api {get} api/report/last_submitter 07. 获取最后一次提交的接收人
* *
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName share
*
* @apiParam {Number} id 报告id
* @apiParam {Array} dialogids 转发给的对话ID
* @apiParam {Array} userids 转发给的成员ID
* @apiParam {String} leave_message 转发留言
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function share()
{
$user = User::auth();
//
$id = Request::input('id');
$dialogids = Request::input('dialogids');
$userids = Request::input('userids');
$leave_message = Request::input('leave_message');
//
if (is_array($id)) {
if (count(Base::arrayRetainInt($id)) > 20) {
return Base::retError("最多只能操作20条数据");
}
$builder = Report::whereIn("id", Base::arrayRetainInt($id));
} else {
$builder = Report::whereId(intval($id));
}
$reportMsgs = [];
$builder ->chunkById(100, function ($list) use (&$reportMsgs, $user) {
/** @var Report $item */
foreach ($list as $item) {
$reportLink = ReportLink::generateLink($item->id, $user->userid);
$reportMsgs[] = "<a class=\"mention report\" href=\"{{RemoteURL}}single/report/detail/{$reportLink['code']}\" target=\"_blank\">%{$item->title}</a>";
}
});
if (empty($reportMsgs)) {
return Base::retError("报告不存在或已被删除");
}
$reportTag = count($reportMsgs) > 1 ? 'li' : 'p';
$reportAttr = $reportTag === 'li' ? ' data-list="ordered"' : '';
$reportMsgs = array_map(function ($item) use ($reportAttr, $reportTag) {
return "<{$reportTag}{$reportAttr}>{$item}</{$reportTag}>";
}, $reportMsgs);
if ($reportTag === 'li') {
array_unshift($reportMsgs, "<ol>");
$reportMsgs[] = "</ol>";
}
if ($leave_message) {
$reportMsgs[] = "<p>{$leave_message}</p>";
}
$msgText = implode("", $reportMsgs);
//
return WebSocketDialogMsg::sendMsgBatch($user, $userids, $dialogids, $msgText);
}
/**
* @api {get} api/report/last_submitter 获取最后一次提交的接收人
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup report * @apiGroup report
* @apiName last_submitter * @apiName last_submitter
@ -767,9 +460,8 @@ class ReportController extends AbstractController
} }
/** /**
* @api {get} api/report/unread 获取未读 * @api {get} api/report/unread 08. 获取未读
* *
* @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup report * @apiGroup report
* @apiName unread * @apiName unread
@ -782,19 +474,15 @@ class ReportController extends AbstractController
{ {
$user = User::auth(); $user = User::auth();
// //
$total = Report::select('reports.id') $data = Report::whereHas("Receives", function (Builder $query) use ($user) {
->join('report_receives', 'report_receives.rid', '=', 'reports.id') $query->where("userid", $user->userid)->where("read", 0);
->where('report_receives.userid', $user->userid) })->orderByDesc('created_at')->paginate(Base::getPaginate(50, 20));
->where('report_receives.read', 0) return Base::retSuccess("success", $data);
->count();
//
return Base::retSuccess("success", compact("total"));
} }
/** /**
* @api {get} api/report/read 标记汇报已读,可批量 * @api {get} api/report/read 09. 标记汇报已读,可批量
* *
* @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
* @apiGroup report * @apiGroup report
* @apiName read * @apiName read
@ -830,22 +518,4 @@ class ReportController extends AbstractController
} }
return Base::retSuccess("success", $data); return Base::retSuccess("success", $data);
} }
/**
* 判断当前用户是否有权限查看/分析指定工作汇报
* @param Report $report
* @param User $user
* @return bool
*/
protected function userCanAccessReport(Report $report, User $user): bool
{
if ($report->userid === $user->userid) {
return true;
}
return ReportReceive::query()
->whereRid($report->id)
->whereUserid($user->userid)
->exists();
}
} }

View File

@ -1,619 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use Request;
use App\Models\File;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\User;
use App\Models\UserTag;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Module\Base;
use App\Module\Apps;
use App\Module\Manticore\ManticoreFile;
use App\Module\Manticore\ManticoreUser;
use App\Module\Manticore\ManticoreProject;
use App\Module\Manticore\ManticoreTask;
use App\Module\Manticore\ManticoreMsg;
/**
* @apiDefine search
*
* 智能搜索
*/
class SearchController extends AbstractController
{
/**
* @api {get} api/search/contact 搜索联系人
*
* @apiDescription 需要token身份优先使用 Manticore Search未安装则使用 MySQL 搜索
* @apiVersion 1.0.0
* @apiGroup search
* @apiName contact
*
* @apiParam {String} key 搜索关键词
* @apiParam {String} [search_type] 搜索类型text/vector/hybrid默认hybrid Manticore 有效)
* @apiParam {Number} [take] 获取数量默认20最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function contact()
{
User::auth();
$key = trim(Request::input('key'));
$searchType = Request::input('search_type', 'hybrid');
$take = Base::getPaginate(50, 20, 'take');
if (empty($key)) {
return Base::retSuccess('success', []);
}
// 优先使用 Manticore 搜索
if (Apps::isInstalled('search')) {
$results = ManticoreUser::search($key, $searchType, $take);
// 补充用户完整信息
$userids = array_column($results, 'userid');
if (!empty($userids)) {
$users = User::whereIn('userid', $userids)
->select(User::$basicField)
->get()
->keyBy('userid');
foreach ($results as &$item) {
$userData = $users->get($item['userid']);
if ($userData) {
// 标签直接从 Manticore 搜索结果获取(空格分隔的字符串转数组)
$tagsStr = $item['tags'] ?? '';
$searchTags = !empty($tagsStr) ? preg_split('/\s+/', trim($tagsStr)) : [];
$item = array_merge($userData->toArray(), [
'relevance' => $item['relevance'] ?? 0,
'introduction_preview' => $item['introduction_preview'] ?? null,
'search_tags' => $searchTags,
]);
}
}
}
} else {
// MySQL 回退搜索
$results = $this->searchContactByMysql($key, $take);
}
return Base::retSuccess('success', $results);
}
/**
* MySQL 回退搜索联系人
*
* @param string $key 搜索关键词
* @param int $take 获取数量
* @return array
*/
private function searchContactByMysql(string $key, int $take): array
{
$users = User::select(User::$basicField)
->where('bot', 0)
->whereNull('disable_at')
->searchByKeyword($key)
->orderByDesc('line_at')
->take($take)
->get();
// 获取用户标签
$userids = $users->pluck('userid')->toArray();
$userTags = $this->getUserTagsMap($userids);
return $users->map(function ($user) use ($userTags) {
return array_merge($user->toArray(), [
'relevance' => 0,
'introduction_preview' => null,
'search_tags' => $userTags[$user->userid] ?? [],
]);
})->toArray();
}
/**
* @api {get} api/search/project 搜索项目
*
* @apiDescription 需要token身份优先使用 Manticore Search未安装则使用 MySQL 搜索
* @apiVersion 1.0.0
* @apiGroup search
* @apiName project
*
* @apiParam {String} key 搜索关键词
* @apiParam {String} [search_type] 搜索类型text/vector/hybrid默认hybrid Manticore 有效)
* @apiParam {Number} [take] 获取数量默认20最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function project()
{
$user = User::auth();
$key = trim(Request::input('key'));
$searchType = Request::input('search_type', 'hybrid');
$take = Base::getPaginate(50, 20, 'take');
if (empty($key)) {
return Base::retSuccess('success', []);
}
// 优先使用 Manticore 搜索
if (Apps::isInstalled('search')) {
$results = ManticoreProject::search($user->userid, $key, $searchType, $take);
// 补充项目完整信息
$projectIds = array_column($results, 'project_id');
if (!empty($projectIds)) {
$projects = Project::whereIn('id', $projectIds)
->get()
->keyBy('id');
foreach ($results as &$item) {
$projectData = $projects->get($item['project_id']);
if ($projectData) {
$item = array_merge($projectData->toArray(), [
'relevance' => $item['relevance'] ?? 0,
'desc_preview' => $item['desc_preview'] ?? null,
]);
}
}
}
} else {
// MySQL 回退搜索
$results = $this->searchProjectByMysql($user->userid, $key, $take);
}
return Base::retSuccess('success', $results);
}
/**
* MySQL 回退搜索项目
*
* @param int $userid 用户ID
* @param string $key 搜索关键词
* @param int $take 获取数量
* @return array
*/
private function searchProjectByMysql(int $userid, string $key, int $take): array
{
$projects = Project::authData()
->whereNull('projects.archived_at')
->searchByKeyword($key)
->orderByDesc('projects.id')
->take($take)
->get();
return $projects->map(function ($project) {
$array = $project->toArray();
$array['relevance'] = 0;
$array['desc_preview'] = null;
return $array;
})->toArray();
}
/**
* @api {get} api/search/task 搜索任务
*
* @apiDescription 需要token身份优先使用 Manticore Search未安装则使用 MySQL 搜索
* @apiVersion 1.0.0
* @apiGroup search
* @apiName task
*
* @apiParam {String} key 搜索关键词
* @apiParam {String} [search_type] 搜索类型text/vector/hybrid默认hybrid Manticore 有效)
* @apiParam {Number} [take] 获取数量默认20最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function task()
{
$user = User::auth();
$key = trim(Request::input('key'));
$searchType = Request::input('search_type', 'hybrid');
$take = Base::getPaginate(50, 20, 'take');
if (empty($key)) {
return Base::retSuccess('success', []);
}
// 优先使用 Manticore 搜索
if (Apps::isInstalled('search')) {
$results = ManticoreTask::search($user->userid, $key, $searchType, $take);
// 补充任务完整信息
$taskIds = array_column($results, 'task_id');
if (!empty($taskIds)) {
$tasks = ProjectTask::with(['taskUser', 'taskTag'])
->whereIn('id', $taskIds)
->get()
->keyBy('id');
foreach ($results as &$item) {
$taskData = $tasks->get($item['task_id']);
if ($taskData) {
$item = array_merge($taskData->toArray(), [
'relevance' => $item['relevance'] ?? 0,
'desc_preview' => $item['desc_preview'] ?? null,
'content_preview' => $item['content_preview'] ?? null,
]);
}
}
}
} else {
// MySQL 回退搜索
$results = $this->searchTaskByMysql($user->userid, $key, $take);
}
return Base::retSuccess('success', $results);
}
/**
* MySQL 回退搜索任务
*
* @param int $userid 用户ID
* @param string $key 搜索关键词
* @param int $take 获取数量
* @return array
*/
private function searchTaskByMysql(int $userid, string $key, int $take): array
{
$tasks = ProjectTask::with(['taskUser', 'taskTag'])
->whereIn('project_tasks.project_id', function ($query) use ($userid) {
$query->select('project_id')
->from('project_users')
->where('userid', $userid);
})
->whereNull('project_tasks.archived_at')
->whereNull('project_tasks.deleted_at')
->searchByKeyword($key)
->orderByDesc('project_tasks.id')
->take($take)
->get();
return $tasks->map(function ($task) {
$array = $task->toArray();
$array['relevance'] = 0;
$array['desc_preview'] = null;
$array['content_preview'] = null;
return $array;
})->toArray();
}
/**
* @api {get} api/search/file 搜索文件
*
* @apiDescription 需要token身份优先使用 Manticore Search未安装则使用 MySQL 搜索
* @apiVersion 1.0.0
* @apiGroup search
* @apiName file
*
* @apiParam {String} key 搜索关键词
* @apiParam {String} [search_type] 搜索类型text/vector/hybrid默认hybrid Manticore 有效)
* @apiParam {Number} [take] 获取数量默认20最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function file()
{
$user = User::auth();
$key = trim(Request::input('key'));
$searchType = Request::input('search_type', 'hybrid');
$take = Base::getPaginate(50, 20, 'take');
if (empty($key)) {
return Base::retSuccess('success', []);
}
// 优先使用 Manticore 搜索
if (Apps::isInstalled('search')) {
$results = ManticoreFile::search($user->userid, $key, $searchType, 0, $take);
// 补充文件完整信息
$fileIds = array_column($results, 'file_id');
if (!empty($fileIds)) {
$files = File::whereIn('id', $fileIds)
->get()
->keyBy('id');
$formattedResults = [];
foreach ($results as $item) {
$fileData = $files->get($item['file_id']);
if ($fileData) {
$formattedResults[] = array_merge($fileData->toArray(), [
'relevance' => $item['relevance'] ?? 0,
'content_preview' => $item['content_preview'] ?? null,
]);
}
}
return Base::retSuccess('success', $formattedResults);
}
return Base::retSuccess('success', []);
} else {
// MySQL 回退搜索
$results = $this->searchFileByMysql($user->userid, $key, $take);
return Base::retSuccess('success', $results);
}
}
/**
* MySQL 回退搜索文件
*
* @param int $userid 用户ID
* @param string $key 搜索关键词
* @param int $take 获取数量
* @return array
*/
private function searchFileByMysql(int $userid, string $key, int $take): array
{
$results = [];
// 搜索用户自己的文件
$ownFiles = File::where('userid', $userid)
->searchByKeyword($key)
->take($take)
->get();
foreach ($ownFiles as $file) {
$results[] = array_merge($file->toArray(), [
'relevance' => 0,
'content_preview' => null,
]);
}
// 搜索共享给用户的文件
$remaining = $take - count($results);
if ($remaining > 0) {
$sharedFiles = File::sharedToUser($userid)
->searchByKeyword($key)
->take($remaining)
->get();
foreach ($sharedFiles as $file) {
$temp = $file->toArray();
if ($file->pshare === $file->id) {
$temp['pid'] = 0;
}
$temp['relevance'] = 0;
$temp['content_preview'] = null;
$results[] = $temp;
}
}
return $results;
}
/**
* @api {get} api/search/message 搜索消息
*
* @apiDescription 需要token身份优先使用 Manticore Search未安装则使用 MySQL 搜索
* @apiVersion 1.0.0
* @apiGroup search
* @apiName message
*
* @apiParam {String} key 搜索关键词
* @apiParam {String} [search_type] 搜索类型text/vector/hybrid默认hybrid Manticore 有效)
* @apiParam {Number} [take] 获取数量默认20最大50
* @apiParam {String} [mode] 返回模式message/position/dialog默认message
* - message: 返回消息详细信息
* - position: 只返回消息ID
* - dialog: 返回对话级数据
* @apiParam {Number} [dialog_id] 对话ID筛选指定对话内的消息
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function message()
{
$user = User::auth();
$key = trim(Request::input('key'));
$searchType = Request::input('search_type', 'hybrid');
$take = Base::getPaginate(50, 20, 'take');
$mode = Request::input('mode', 'message');
$dialogId = intval(Request::input('dialog_id', 0));
// 验证 mode 参数
if (!in_array($mode, ['message', 'position', 'dialog'])) {
$mode = 'message';
}
if (empty($key)) {
return Base::retSuccess('success', []);
}
// 如果指定了 dialog_id需要验证用户有权限访问该对话
if ($dialogId > 0) {
WebSocketDialog::checkDialog($dialogId);
}
// 优先使用 Manticore 搜索
if (Apps::isInstalled('search')) {
$results = ManticoreMsg::search($user->userid, $key, $searchType, 0, $take, $dialogId);
} else {
// MySQL 回退搜索
$results = $this->searchMessageByMysql($user->userid, $key, $take, $dialogId);
}
// 根据 mode 返回不同格式的数据
return $this->formatMessageResults($results, $mode, $user->userid);
}
/**
* MySQL 回退搜索消息
*
* @param int $userid 用户ID
* @param string $key 搜索关键词
* @param int $take 获取数量
* @param int $dialogId 对话ID0表示不限制
* @return array
*/
private function searchMessageByMysql(int $userid, string $key, int $take, int $dialogId = 0): array
{
$builder = WebSocketDialogMsg::select([
'id as msg_id',
'dialog_id',
'userid',
'type',
'msg',
'created_at',
])
->accessibleByUser($userid)
->where('bot', 0)
->searchByKeyword($key);
if ($dialogId > 0) {
$builder->where('dialog_id', $dialogId);
}
$items = $builder->orderByDesc('id')
->limit($take)
->get();
return $items->map(function ($item) {
return [
'msg_id' => $item->msg_id,
'dialog_id' => $item->dialog_id,
'userid' => $item->userid,
'type' => $item->type,
'msg' => $item->msg,
'created_at' => $item->created_at,
'relevance' => 0,
'content_preview' => null,
];
})->toArray();
}
/**
* 格式化消息搜索结果
*
* @param array $results 搜索结果
* @param string $mode 返回模式
* @param int $userid 用户ID
* @return \Illuminate\Http\JsonResponse
*/
private function formatMessageResults(array $results, string $mode, int $userid)
{
switch ($mode) {
case 'position':
// 只返回消息ID
$data = array_column($results, 'msg_id');
return Base::retSuccess('success', compact('data'));
case 'dialog':
// 返回对话级数据
$list = [];
$seenDialogs = [];
foreach ($results as $item) {
$dialogIdFromResult = $item['dialog_id'];
// 每个对话只返回一次
if (isset($seenDialogs[$dialogIdFromResult])) {
continue;
}
$seenDialogs[$dialogIdFromResult] = true;
if ($dialog = WebSocketDialog::find($dialogIdFromResult)) {
$dialogData = array_merge($dialog->toArray(), [
'search_msg_id' => $item['msg_id'],
]);
$list[] = WebSocketDialog::synthesizeData($dialogData, $userid);
}
}
return Base::retSuccess('success', ['data' => $list]);
case 'message':
default:
// 返回消息详细信息(默认行为)
$msgIds = array_column($results, 'msg_id');
if (!empty($msgIds)) {
$msgs = WebSocketDialogMsg::whereIn('id', $msgIds)
->with(['user' => function ($query) {
$query->select(User::$basicField);
}])
->get()
->keyBy('id');
// 创建结果映射以保持原始顺序和额外字段
$resultsMap = [];
foreach ($results as $item) {
$resultsMap[$item['msg_id']] = $item;
}
$formattedResults = [];
foreach ($msgIds as $msgId) {
$msgData = $msgs->get($msgId);
$originalItem = $resultsMap[$msgId] ?? [];
if ($msgData) {
$formattedResults[] = [
'id' => $msgData->id,
'msg_id' => $msgData->id,
'dialog_id' => $msgData->dialog_id,
'userid' => $msgData->userid,
'type' => $msgData->type,
'msg' => $msgData->msg,
'created_at' => $msgData->created_at,
'user' => $msgData->user,
'relevance' => $originalItem['relevance'] ?? 0,
'content_preview' => $originalItem['content_preview'] ?? null,
];
}
}
return Base::retSuccess('success', $formattedResults);
}
return Base::retSuccess('success', []);
}
}
/**
* 批量获取用户标签映射
*
* @param array $userids 用户ID数组
* @return array 用户ID => 标签名称数组的映射
*/
private function getUserTagsMap(array $userids): array
{
if (empty($userids)) {
return [];
}
// 获取所有用户的标签(带认可数)
$tags = UserTag::whereIn('user_id', $userids)
->withCount('recognitions')
->get();
// 按用户分组,每个用户取 Top 10 标签
$result = [];
foreach ($userids as $userid) {
$result[$userid] = [];
}
$userTags = $tags->groupBy('user_id');
foreach ($userTags as $userid => $tagCollection) {
$result[$userid] = $tagCollection
->sortByDesc('recognitions_count')
->take(10)
->pluck('name')
->values()
->toArray();
}
return $result;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
/**
* 测试
*/
class TestController extends AbstractController
{
}

File diff suppressed because it is too large Load Diff

View File

@ -1,378 +0,0 @@
# apiDoc 参数标签说明(完整速查)
apiDoc 使用内联注释为 RESTful API 自动生成文档。
以下为所有官方支持的参数与其说明。
---
## @api
**定义 API 方法的基本信息**
```js
@api {method} path title
```
- **method**:请求方法,如 `GET``POST``PUT``DELETE`
- **path**:请求路径,例如 `/user/:id`
- **title**:简短标题(显示在文档中)
📘 示例:
```js
@api {get} /user/:id Get user info
```
---
## @apiBody
**定义请求体参数**
```js
@apiBody [{type}] [field=defaultValue] [description]
```
- `{type}` 参数类型(如 String, Number, Object, String[]
- `[field]` 可选字段(方括号表示可选)
- `=defaultValue` 默认值
- `description` 参数说明
📘 示例:
```js
@apiBody {String} lastname Mandatory Lastname.
@apiBody {Object} [address] Optional address object.
@apiBody {String} [address[city]] Optional city.
```
---
## @apiDefine
**定义可复用的文档块**
```js
@apiDefine name [title] [description]
```
- `name`:唯一标识
- `title`:简短标题
- `description`:多行描述
📘 示例:
```js
@apiDefine MyError
@apiError UserNotFound The <code>id</code> of the User was not found.
```
---
## @apiDeprecated
**标记接口为弃用状态**
```js
@apiDeprecated [text]
```
- `text`:提示文本,可带链接到新方法
📘 示例:
```js
@apiDeprecated use now (#User:GetDetails)
```
---
## @apiDescription
**描述接口详细说明**
```js
@apiDescription text
```
📘 示例:
```js
@apiDescription This is the Description.
It is multiline capable.
```
---
## @apiError
**定义错误返回参数**
```js
@apiError [(group)] [{type}] field [description]
```
📘 示例:
```js
@apiError UserNotFound The id of the User was not found.
```
---
## @apiErrorExample
**定义错误返回示例**
```js
@apiErrorExample [{type}] [title]
example
```
📘 示例:
```js
@apiErrorExample {json} Error-Response:
HTTP/1.1 404 Not Found
{ "error": "UserNotFound" }
```
---
## @apiExample
**定义接口使用示例**
```js
@apiExample [{type}] title
example
```
📘 示例:
```js
@apiExample {curl} Example usage:
curl -i http://localhost/user/4711
```
---
## @apiGroup
**定义所属分组**
```js
@apiGroup name
```
📘 示例:
```js
@apiGroup User
```
---
## @apiHeader
**定义请求头参数**
```js
@apiHeader [(group)] [{type}] [field=defaultValue] [description]
```
📘 示例:
```js
@apiHeader {String} access-key Users unique access-key.
```
---
## @apiHeaderExample
**定义请求头示例**
```js
@apiHeaderExample [{type}] [title]
example
```
📘 示例:
```js
@apiHeaderExample {json} Header-Example:
{
"Accept-Encoding": "gzip, deflate"
}
```
---
## @apiIgnore
**忽略当前文档块**
```js
@apiIgnore [hint]
```
📘 示例:
```js
@apiIgnore Not finished method
```
---
## @apiName
**定义接口唯一名称**
```js
@apiName name
```
📘 示例:
```js
@apiName GetUser
```
---
## @apiParam
**定义请求参数**
```js
@apiParam [(group)] [{type}] [field=defaultValue] [description]
```
📘 示例:
```js
@apiParam {Number} id Users unique ID.
@apiParam {String} [firstname] Optional firstname.
@apiParam {String} country="DE" Mandatory with default.
```
---
## @apiParamExample
**定义参数请求示例**
```js
@apiParamExample [{type}] [title]
example
```
📘 示例:
```js
@apiParamExample {json} Request-Example:
{ "id": 4711 }
```
---
## @apiPermission
**定义权限要求**
```js
@apiPermission name
```
📘 示例:
```js
@apiPermission admin
```
---
## @apiPrivate
**标记接口为私有(可过滤)**
```js
@apiPrivate
```
---
## @apiQuery
**定义查询参数(?query**
```js
@apiQuery [{type}] [field=defaultValue] [description]
```
📘 示例:
```js
@apiQuery {Number} id Users unique ID.
@apiQuery {String} [sort="asc"] Sort order.
```
---
## @apiSampleRequest
**定义接口测试请求 URL**
```js
@apiSampleRequest url
```
📘 示例:
```js
@apiSampleRequest http://test.github.com/some_path/
```
---
## @apiSuccess
**定义成功返回参数**
```js
@apiSuccess [(group)] [{type}] field [description]
```
📘 示例:
```js
@apiSuccess {String} firstname Firstname of the User.
@apiSuccess {String} lastname Lastname of the User.
```
---
## @apiSuccessExample
**定义成功返回示例**
```js
@apiSuccessExample [{type}] [title]
example
```
📘 示例:
```js
@apiSuccessExample {json} Success-Response:
HTTP/1.1 200 OK
{ "firstname": "John", "lastname": "Doe" }
```
---
## @apiUse
**引用定义块(@apiDefine**
```js
@apiUse name
```
📘 示例:
```js
@apiDefine MySuccess
@apiSuccess {String} firstname User firstname.
@apiUse MySuccess
```
---
## @apiVersion
**定义接口版本**
```js
@apiVersion version
```
📘 示例:
```js
@apiVersion 1.6.2
```
---
# 附录:常用标签速查表
| 标签 | 作用 | 示例 |
|------|------|------|
| `@api` | 定义接口 | `@api {get} /user/:id` |
| `@apiName` | 唯一名称 | `@apiName GetUser` |
| `@apiGroup` | 所属分组 | `@apiGroup User` |
| `@apiParam` | 请求参数 | `@apiParam {Number} id Users unique ID.` |
| `@apiBody` | 请求体参数 | `@apiBody {String} name Username.` |
| `@apiQuery` | 查询参数 | `@apiQuery {String} keyword Search term.` |
| `@apiHeader` | Header 参数 | `@apiHeader {String} token Auth token.` |
| `@apiSuccess` | 成功返回字段 | `@apiSuccess {String} name Username.` |
| `@apiError` | 错误返回字段 | `@apiError NotFound User not found.` |
| `@apiVersion` | 版本号 | `@apiVersion 1.0.0` |

View File

@ -1,137 +1,89 @@
<?php <?php
/** /**
* 给apidoc项目增加顺序编号 / 支持恢复 * 给apidoc项目增加顺序编号
*/ */
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING); @error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
const NUMBER_WIDTH = 2; $path = dirname(__FILE__). '/';
$lists = scandir($path);
$isRestore = isset($argv[1]) && strtolower($argv[1]) === 'restore'; //
foreach ($lists AS $item) {
$basePath = dirname(__FILE__) . '/'; $fillPath = $path . $item;
$controllerFiles = glob($basePath . '*Controller.php'); if (str_ends_with($fillPath, 'Controller.php')) {
$content = file_get_contents($fillPath);
if (!$controllerFiles) { preg_match_all("/\* @api \{(.+?)\} (.*?)\n/i", $content, $matchs);
echo "No Controller.php files found\n"; $i = 1;
exit(0); foreach ($matchs[2] AS $key=>$text) {
} if (in_array(strtolower($matchs[1][$key]), array('get', 'post'))) {
$expl = explode(" ", __sRemove($text));
foreach ($controllerFiles as $filePath) { $end = $expl[1];
$original = file_get_contents($filePath); if ($expl[2]) {
[$updated, $linesChanged] = processFile($original, $isRestore); $end = '';
foreach ($expl AS $k=>$v) { if ($k >= 2) { $end.= " ".$v; } }
if (count($linesChanged) === 0) {
continue;
}
file_put_contents($filePath, $updated);
foreach ($linesChanged as $line) {
echo $line . "\n";
}
}
echo $isRestore ? "Restore Success \n" : "Success \n";
/**
* 处理单个文件内容
*
* @param string $content
* @param bool $restore
* @return array{string, array<int, string>}
*/
function processFile(string $content, bool $restore): array
{
$lineChanges = [];
$counter = 1;
$pattern = '/\* @api \{([^\}]+)\}\s+([^\s]+)([^\r\n]*)(\r?\n)/';
$updated = preg_replace_callback(
$pattern,
function (array $matches) use ($restore, &$counter, &$lineChanges) {
$method = trim($matches[1]);
if (!in_array(strtolower($method), ['get', 'post'], true)) {
return $matches[0];
}
$endpoint = trim($matches[2]);
$suffix = normalizeDescription(stripExistingNumbering($matches[3]));
if (!$restore) {
$numberedSuffix = formatNumber($counter) . '.';
if ($suffix !== '') {
$numberedSuffix .= ' ' . $suffix;
} }
$counter++; $newtext = "* @api {".$matchs[1][$key]."} ".$expl[0]." ".__zeroFill($i, 2).". ".trim($end);
} else { $content = str_replace("* @api {".$matchs[1][$key]."} ".$text, $newtext, $content);
$numberedSuffix = $suffix; $i++;
//
echo $newtext;
echo "\r\n";
} }
$newLine = renderAnnotation($method, $endpoint, $numberedSuffix);
if ($newLine !== rtrim($matches[0], "\r\n")) {
$lineChanges[] = $newLine;
}
return $newLine . $matches[4];
},
$content
);
if ($updated === null) {
return [$content, []];
}
return [$updated, $lineChanges];
}
/**
* 生成格式化后的注释行
*/
function renderAnnotation(string $method, string $endpoint, string $suffix = ''): string
{
$line = "* @api {" . $method . "} " . $endpoint;
if ($suffix !== '') {
if ($suffix[0] !== ' ') {
$line .= ' ';
} }
$line .= $suffix; if ($i > 1) {
file_put_contents($fillPath, $content);
}
} }
return $line;
} }
echo "Success \n";
/** ************************************************************** */
/** ************************************************************** */
/** ************************************************************** */
/** /**
* 移除已有编号部分 * 替换所有空格
* @param $str
* @return mixed
*/ */
function stripExistingNumbering(string $text): string function __sRemove($str) {
{ $str = str_replace(" ", " ", $str);
$trimmed = ltrim($text); if (__strExists($str, " ")) {
$pattern = '/^\d+\.\s*/'; return __sRemove($str);
return preg_replace($pattern, '', $trimmed) ?? $trimmed;
}
/**
* 压缩多余空格
*/
function normalizeDescription(string $text): string
{
$text = trim($text);
if ($text === '') {
return '';
} }
return $str;
return preg_replace('/\s+/', ' ', $text) ?? $text;
} }
/** /**
* 生成固定宽度的数字 * 是否包含字符
* @param $string
* @param $find
* @return bool
*/ */
function formatNumber(int $number): string function __strExists($string, $find)
{ {
return str_pad((string) $number, NUMBER_WIDTH, '0', STR_PAD_LEFT); return str_contains($string, $find);
}
/**
* @param string $str 补零
* @param int $length
* @param int $after
* @return bool|string
*/
function __zeroFill($str, $length = 0, $after = 1) {
if (strlen($str) >= $length) {
return $str;
}
$_str = '';
for ($i = 0; $i < $length; $i++) {
$_str .= '0';
}
if ($after) {
$_ret = substr($_str . $str, $length * -1);
} else {
$_ret = substr($str . $_str, 0, $length);
}
return $_ret;
} }

View File

@ -2,31 +2,25 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Arr;
use Cache;
use Request;
use Redirect;
use Response;
use App\Models\File; use App\Models\File;
use App\Module\Doo;
use App\Module\Base; use App\Module\Base;
use App\Module\Extranet; use App\Module\Extranet;
use App\Module\RandomColor; use App\Module\RandomColor;
use App\Tasks\LoopTask;
use App\Tasks\AppPushTask; use App\Tasks\AppPushTask;
use App\Tasks\JokeSoupTask; use App\Tasks\AutoArchivedTask;
use App\Tasks\CheckinRemindTask;
use App\Tasks\DeleteBotMsgTask;
use App\Tasks\DeleteTmpTask; use App\Tasks\DeleteTmpTask;
use App\Tasks\EmailNoticeTask; use App\Tasks\EmailNoticeTask;
use App\Tasks\AutoArchivedTask; use App\Tasks\JokeSoupTask;
use App\Tasks\DeleteBotMsgTask; use App\Tasks\LoopTask;
use App\Tasks\CheckinRemindTask; use Arr;
use App\Tasks\CloseMeetingRoomTask; use Cache;
use App\Tasks\ManticoreSyncTask;
use App\Tasks\UnclaimedTaskRemindTask;
use App\Tasks\TodoRemindTask;
use App\Tasks\AiTaskLoopTask;
use Hhxsv5\LaravelS\Swoole\Task\Task; use Hhxsv5\LaravelS\Swoole\Task\Task;
use Laravolt\Avatar\Avatar; use LasseRafn\InitialAvatarGenerator\InitialAvatar;
use Redirect;
use Request;
use Response;
/** /**
@ -42,8 +36,9 @@ class IndexController extends InvokeController
if ($action) { if ($action) {
$app .= "__" . $action; $app .= "__" . $action;
} }
if ($app == 'default') { if ($app === 'manifest.txt') {
return ''; $app = 'manifest';
$child = 'txt';
} }
if (!method_exists($this, $app)) { if (!method_exists($this, $app)) {
$app = method_exists($this, $method) ? $method : 'main'; $app = method_exists($this, $method) ? $method : 'main';
@ -63,21 +58,58 @@ class IndexController extends InvokeController
$array = Base::json2array(file_get_contents($hotFile)); $array = Base::json2array(file_get_contents($hotFile));
$style = null; $style = null;
$script = preg_replace("/^(\/\/(.*?))(:\d+)?\//i", "$1:" . $array['APP_DEV_PORT'] . "/", asset_main("resources/assets/js/app.js")); $script = preg_replace("/^(\/\/(.*?))(:\d+)?\//i", "$1:" . $array['APP_DEV_PORT'] . "/", asset_main("resources/assets/js/app.js"));
$proxyUri = Base::liveEnv('VSCODE_PROXY_URI');
if (is_string($proxyUri) && preg_match('/^https?:\/\//i', $proxyUri)) {
$script = preg_replace('/^(https?:\/\/|\/\/)[^\/]+/', rtrim($proxyUri, '/'), $script, 1);
}
} else { } else {
$array = Base::json2array(file_get_contents($manifestFile)); $array = Base::json2array(file_get_contents($manifestFile));
$style = asset_main($array['resources/assets/js/app.js']['css'][0]); $style = asset_main($array['resources/assets/js/app.js']['css'][0]);
$script = asset_main($array['resources/assets/js/app.js']['file']); $script = asset_main($array['resources/assets/js/app.js']['file']);
} }
return response()->view('main', [ return response()->view('main', [
'system_alias' => Base::settingFind('system', 'system_alias', 'WebPage'),
'version' => Base::getVersion(), 'version' => Base::getVersion(),
'style' => $style, 'style' => $style,
'script' => $script, 'script' => $script,
]); ])->header('Link', "<" . url('manifest.txt') . ">; rel=\"prefetch\"");
}
/**
* Manifest
* @param $child
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response|string
*/
public function manifest($child = '')
{
if (empty($child)) {
$murl = url('manifest.txt');
return response($murl)->header('Link', "<{$murl}>; rel=\"prefetch\"");
}
$array = [
"office/web-apps/apps/api/documents/api.js?hash=" . Base::getVersion(),
"office/7.5.1-23/web-apps/vendor/requirejs/require.js",
"office/7.5.1-23/web-apps/apps/api/documents/api.js",
"office/7.5.1-23/sdkjs/common/AllFonts.js",
"office/7.5.1-23/web-apps/vendor/xregexp/xregexp-all-min.js",
"office/7.5.1-23/web-apps/vendor/sockjs/sockjs.min.js",
"office/7.5.1-23/web-apps/vendor/jszip/jszip.min.js",
"office/7.5.1-23/web-apps/vendor/jszip-utils/jszip-utils.min.js",
"office/7.5.1-23/sdkjs/common/libfont/wasm/fonts.js",
"office/7.5.1-23/sdkjs/common/Charts/ChartStyles.js",
"office/7.5.1-23/sdkjs/slide/themes//themes.js",
"office/7.5.1-23/web-apps/apps/presentationeditor/main/app.js",
"office/7.5.1-23/sdkjs/slide/sdk-all-min.js",
"office/7.5.1-23/sdkjs/slide/sdk-all.js",
"office/7.5.1-23/web-apps/apps/documenteditor/main/app.js",
"office/7.5.1-23/sdkjs/word/sdk-all-min.js",
"office/7.5.1-23/sdkjs/word/sdk-all.js",
"office/7.5.1-23/web-apps/apps/spreadsheeteditor/main/app.js",
"office/7.5.1-23/sdkjs/cell/sdk-all-min.js",
"office/7.5.1-23/sdkjs/cell/sdk-all.js",
];
foreach ($array as &$item) {
$item = url($item);
}
return implode(PHP_EOL, $array);
} }
/** /**
@ -89,18 +121,9 @@ class IndexController extends InvokeController
return Redirect::to(Base::fillUrl('api/system/version'), 301); return Redirect::to(Base::fillUrl('api/system/version'), 301);
} }
/**
* 健康检查
* @return string
*/
public function health()
{
return "ok";
}
/** /**
* 头像 * 头像
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response|\Symfony\Component\HttpFoundation\BinaryFileResponse * @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response
*/ */
public function avatar() public function avatar()
{ {
@ -108,120 +131,34 @@ class IndexController extends InvokeController
if ($segment && preg_match('/.*?\.png$/i', $segment)) { if ($segment && preg_match('/.*?\.png$/i', $segment)) {
$name = substr($segment, 0, -4); $name = substr($segment, 0, -4);
} else { } else {
$name = Request::input('name', 'D'); $name = Request::input('name', 'H');
} }
$size = Request::input('size', 128); $size = Request::input('size', 128);
$color = Request::input('color'); $color = Request::input('color');
$background = Request::input('background'); $background = Request::input('background');
// 移除各种括号及其内容
$pattern = '/[(\[【{<<『「](.*?)[)\]】}>>』」]/u';
$name = preg_replace($pattern, '', $name) ?: preg_replace($pattern, '$1', $name);
// 移除常见标识词(不区分大小写)
$filterWords = [
// 测试相关
'测试', '测试号', '测试账号', '内测', '体验', '试用', 'test', 'testing', 'beta',
// 账号相关
'账号', '帐号', '账户', '帐户', 'account', 'acc', 'id', 'uid',
// 临时标识
'临时', '暂用', '备用', '主号', '副号', '小号', '大号', 'temp', 'temporary', 'backup',
// 系统相关
'系统', '管理员', 'admin', 'administrator', 'system', 'sys', 'root',
// 用户相关
'用户', 'user', '会员', 'member', 'vip', 'svip', 'mvip', 'premium',
// 官方相关
'官方', '正式', '认证', 'official', 'verified', 'auth',
// 客服相关
'客服', '售后', '服务', 'service', 'support', 'helper', 'assistant',
// 游戏相关
'game', 'gaming', 'player', 'gamer',
// 社交媒体相关
'ins', 'instagram', 'fb', 'facebook', 'tiktok', 'tweet', 'weibo', 'wechat',
// 常见后缀
'official', 'real', 'fake', 'copy', 'channel', 'studio', 'team', 'group',
// 职业相关
'dev', 'developer', 'designer', 'artist', 'writer', 'editor',
// 其他
'bot', 'robot', 'auto', 'anonymous', 'guest', 'default', 'new', 'old'
];
$filterWords = array_map(function ($word) {
return preg_quote($word, '/');
}, $filterWords);
$name = preg_replace('/' . implode('|', $filterWords) . '/iu', '', $name) ?: $name;
// 移除分隔符和特殊字符
$filterSymbols = [
// 常见分隔符
'-', '_', '=', '+', '/', '\\', '|',
'~', '@', '#', '$', '%', '^', '&', '*',
// 空格类字符
' ', ' ', "\t", "\n", "\r",
// 标点符号(中英文)
'。', '', '、', '', '', '', '',
'', '…', '‥', '', '″', '℃',
'.', ',', ';', ':', '?', '!',
// 引号类(修正版)
'"', "'", '', '', '“', '”', '`',
// 特殊符号
'★', '☆', '○', '●', '◎', '◇', '◆',
'□', '■', '△', '▲', '▽', '▼',
'♀', '♂', '♪', '♫', '♯', '♭', '♬',
'→', '←', '↑', '↓', '↖', '↗', '↙', '↘',
'√', '×', '÷', '±', '∵', '∴',
'♠', '♥', '♣', '♦',
// emoji 表情符号范围
'\x{1F300}-\x{1F9FF}',
'\x{2600}-\x{26FF}',
'\x{2700}-\x{27BF}',
'\x{1F900}-\x{1F9FF}',
'\x{1F600}-\x{1F64F}'
];
$filterSymbols = array_map(function ($symbol) {
return preg_quote($symbol, '/');
}, $filterSymbols);
$name = preg_replace('/[' . implode('', $filterSymbols) . ']/u', '', $name) ?: $name;
// //
if (preg_match('/^[\x{4e00}-\x{9fa5}]+$/u', $name)) { if (preg_match('/^[\x{4e00}-\x{9fa5}]+$/u', $name)) {
$name = mb_substr($name, mb_strlen($name) - 2); $name = mb_substr($name, mb_strlen($name) - 2);
} }
if (empty($name)) {
$name = 'D';
}
if (empty($color)) { if (empty($color)) {
$color = '#ffffff'; $color = '#ffffff';
$cacheKey = "avatarBackgroundColor::" . md5($name); $cacheKey = "avatarBackgroundColor::" . md5($name);
$background = Cache::rememberForever($cacheKey, function () { $background = Cache::rememberForever($cacheKey, function() {
return RandomColor::one(['luminosity' => 'dark']); return RandomColor::one(['luminosity' => 'dark']);
}); });
} }
// //
$path = public_path('uploads/tmp/avatar/' . substr(md5($name), 0, 2)); $avatar = new InitialAvatar();
$file = Base::joinPath($path, md5($name) . '.png'); $content = $avatar->name($name)
if (file_exists($file)) { ->size($size)
return response()->file($file, [ ->color($color)
'Pragma' => 'public', ->background($background)
'Cache-Control' => 'max-age=1814400', ->fontSize(0.35)
'Content-type' => 'image/png', ->autoFont()
'Expires' => gmdate('D, d M Y H:i:s \G\M\T', time() + 1814400), ->generate()
]); ->stream('png', 100);
}
Base::makeDir($path);
// //
$avatar = new Avatar([ return response($content)
'shape' => 'square',
'width' => $size,
'height' => $size,
'chars' => 2,
'fontSize' => $size / 2.9,
'uppercase' => true,
'fonts' => [resource_path('assets/statics/fonts/Source_Han_Sans_SC_Regular.otf')],
'foregrounds' => [$color],
'backgrounds' => [$background],
'border' => [
'size' => 0,
'color' => 'foreground',
'radius' => 0,
],
]);
return response($avatar->create($name)->save($file))
->header('Pragma', 'public') ->header('Pragma', 'public')
->header('Cache-Control', 'max-age=1814400') ->header('Cache-Control', 'max-age=1814400')
->header('Content-type', 'image/png') ->header('Content-type', 'image/png')
@ -254,13 +191,10 @@ class IndexController extends InvokeController
// App推送 // App推送
Task::deliver(new AppPushTask()); Task::deliver(new AppPushTask());
// 删除过期的临时表数据 // 删除过期的临时表数据
Task::deliver(new DeleteTmpTask('tmp_msgs', 1)); Task::deliver(new DeleteTmpTask('wg_tmp_msgs', 1));
Task::deliver(new DeleteTmpTask('tmp'));
Task::deliver(new DeleteTmpTask('task_worker', 12)); Task::deliver(new DeleteTmpTask('task_worker', 12));
Task::deliver(new DeleteTmpTask('tmp'));
Task::deliver(new DeleteTmpTask('file')); Task::deliver(new DeleteTmpTask('file'));
Task::deliver(new DeleteTmpTask('tmp_file', 24));
Task::deliver(new DeleteTmpTask('user_device', 24));
Task::deliver(new DeleteTmpTask('umeng_log', 24 * 3));
// 删除机器人消息 // 删除机器人消息
Task::deliver(new DeleteBotMsgTask()); Task::deliver(new DeleteBotMsgTask());
// 周期任务 // 周期任务
@ -269,16 +203,6 @@ class IndexController extends InvokeController
Task::deliver(new CheckinRemindTask()); Task::deliver(new CheckinRemindTask());
// 获取笑话/心灵鸡汤 // 获取笑话/心灵鸡汤
Task::deliver(new JokeSoupTask()); 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"; return "success";
} }
@ -294,127 +218,80 @@ class IndexController extends InvokeController
if (strtolower($name) === 'latest') { if (strtolower($name) === 'latest') {
$name = $latestVersion; $name = $latestVersion;
} }
// 上传
// 上传header 中包含 publish-version
if (preg_match("/^\d+\.\d+\.\d+$/", $publishVersion)) { if (preg_match("/^\d+\.\d+\.\d+$/", $publishVersion)) {
// 判断密钥
$publishKey = Request::header('publish-key'); $publishKey = Request::header('publish-key');
if ($publishKey !== env('APP_KEY')) { if ($publishKey !== env('APP_KEY')) {
return Base::retError("key error"); return Base::retError("key error");
} }
// 判断版本 if (version_compare($publishVersion, $latestVersion) > -1) { // 限制上传版本必须 ≥ 当前版本
$action = Request::get('action'); $publishPath = "uploads/desktop/{$publishVersion}/";
$draftPath = "uploads/desktop-draft/{$publishVersion}/"; $res = Base::upload([
if ($action === 'release') { "file" => Request::file('file'),
// 将草稿版本发布为正式版本 "type" => 'desktop',
$draftPath = public_path($draftPath); "path" => $publishPath,
$releasePath = public_path("uploads/desktop/{$publishVersion}/"); "fileName" => true
if (!file_exists($draftPath)) { ]);
return Base::retError("draft version not exists"); if (Base::isSuccess($res)) {
file_put_contents($latestFile, $publishVersion);
} }
if (file_exists($releasePath)) { return $res;
Base::deleteDirAndFile($releasePath);
}
Base::copyDirectory($draftPath, $releasePath);
file_put_contents($latestFile, $publishVersion);
// 删除旧版本
Base::deleteDirAndFile(public_path("uploads/desktop-draft"));
$dirs = Base::recursiveDirs(public_path("uploads/desktop"), false);
sort($dirs);
$num = 0;
foreach ($dirs as $dir) {
if (!preg_match("/\/\d+\.\d+\.\d+$/", $dir)) {
continue;
}
$num++;
if ($num < 5) {
continue; // 保留最新的5个版本
}
if (filemtime($dir) > time() - 3600 * 24 * 30) {
continue; // 保留最近30天的版本
}
Base::deleteDirAndFile($dir);
}
return Base::retSuccess('success');
} }
// 上传草稿版本
return Base::upload([
"file" => Request::file('file'),
"type" => 'publish',
"path" => $draftPath,
"saveName" => true,
]);
} }
// 列表
// 列表(访问路径 desktop/publish/{version} if (preg_match("/^\d+\.\d+\.\d+$/", $name)) {
if (preg_match("/^v*(\d+\.\d+\.\d+)$/", $name, $match)) { $path = "uploads/desktop/{$name}";
$paths = [ $dirPath = public_path($path);
"uploads/desktop/{$match[1]}/", $lists = Base::readDir($dirPath);
"uploads/desktop/v{$match[1]}/",
"uploads/desktop-draft/{$match[1]}/",
"uploads/desktop-draft/v{$match[1]}/",
];
$avaiPath = null;
foreach ($paths as $path) {
$dirPath = public_path($path);
$isDraft = str_contains($path, 'draft');
if (is_dir($dirPath)) {
$avaiPath = $path;
break;
}
}
abort_if(empty($avaiPath), 404);
$lists = Base::recursiveFiles($dirPath, false);
$files = []; $files = [];
foreach ($lists as $file) { foreach ($lists as $file) {
if (preg_match('/\.(zip|yml|yaml|blockmap)$/i', $file) || str_ends_with($file, '-win.exe')) { if (str_ends_with($file, '.yml') || str_ends_with($file, '.yaml')) {
continue; continue;
} }
$fileName = basename($file, $dirPath); $fileName = Base::leftDelete($file, $dirPath);
$fileSize = filesize($file);
$files[] = [ $files[] = [
'name' => $fileName, 'name' => substr($fileName, 1),
'time' => date("Y-m-d H:i:s", filemtime($file)), 'time' => date("Y-m-d H:i:s", filemtime($file)),
'size' => $fileSize > 0 ? Base::readableBytes($fileSize) : 0, 'size' => Base::readableBytes(filesize($file)),
'url' => Base::fillUrl(Base::joinPath($avaiPath, $fileName)), 'url' => Base::fillUrl($path . $fileName),
];
}
$otherVersion = [];
$dirs = Base::recursiveDirs(public_path("uploads/desktop"), false);
foreach ($dirs as $dir) {
if (!preg_match("/\/\d+\.\d+\.\d+$/", $dir)) {
continue;
}
$version = basename($dir);
if ($version === $match[1]) {
continue;
}
$otherVersion[] = [
'version' => $version,
'url' => Base::fillUrl("desktop/publish/{$version}"),
]; ];
} }
// //
return view('desktop', [ $path = "uploads/android";
'system_alias' => Base::settingFind('system', 'system_alias', 'WebPage'), $dirPath = public_path($path);
'version' => $match[1], $lists = Base::readDir($dirPath);
'files' => $files, $apkFile = null;
'is_draft' => $isDraft, foreach ($lists as $file) {
'latest_version' => $latestVersion, if (!str_ends_with($file, '.apk')) {
'other_version' => array_reverse($otherVersion), continue;
]); }
if ($apkFile && strtotime($apkFile['time']) > filemtime($file)) {
continue;
}
$fileName = Base::leftDelete($file, $dirPath);
$apkFile = [
'name' => substr($fileName, 1),
'time' => date("Y-m-d H:i:s", filemtime($file)),
'size' => Base::readableBytes(filesize($file)),
'url' => Base::fillUrl($path . $fileName),
];
}
if ($apkFile) {
$files = array_merge([$apkFile], $files);
}
return view('desktop', ['version' => $name, 'files' => $files]);
} }
// 下载
// 下载Latest 版本内的文件,访问路径 desktop/publish/{fileName} if ($name && file_exists($latestFile)) {
if ($name) { $publishVersion = file_get_contents($latestFile);
$filePath = public_path("uploads/desktop/{$latestVersion}/{$name}"); if (preg_match("/^\d+\.\d+\.\d+$/", $publishVersion)) {
if (file_exists($filePath)) { $filePath = public_path("uploads/desktop/{$publishVersion}/{$name}");
return Response::download($filePath); if (file_exists($filePath)) {
return Response::download($filePath);
}
} }
} }
return abort(404);
// 404
abort(404);
} }
/** /**
@ -440,77 +317,125 @@ class IndexController extends InvokeController
$data = parse_url($key); $data = parse_url($key);
$path = Arr::get($data, 'path'); $path = Arr::get($data, 'path');
$file = public_path($path); $file = public_path($path);
// 防止 ../ 穿越获取到系统文件
abort_if(!str_starts_with(realpath($file), public_path()), 404);
// 如果文件不存在,直接返回 404
abort_if(!file_exists($file), 404);
// //
parse_str($data['query'], $query); if (file_exists($file)) {
$name = Arr::get($query, 'name'); parse_str($data['query'], $query);
$ext = strtolower(Arr::get($query, 'ext')); $name = Arr::get($query, 'name');
$userAgent = strtolower(Request::server('HTTP_USER_AGENT')); $ext = strtolower(Arr::get($query, 'ext'));
if ($ext === 'pdf') { $userAgent = strtolower(Request::server('HTTP_USER_AGENT'));
// 文件超过 10m 不支持在线预览,提示下载 if ($ext === 'pdf'
if (filesize($file) > 10 * 1024 * 1024) { && (str_contains($userAgent, 'electron') || str_contains($userAgent, 'chrome'))) {
return view('download', [
'system_alias' => Base::settingFind('system', 'system_alias', 'WebPage'),
'name' => $name,
'size' => Base::readableBytes(filesize($file)),
'url' => Base::fillUrl($path),
'button' => Doo::translate('点击下载'),
]);
}
// 浏览器类型
$browser = 'none';
if (str_contains($userAgent, 'chrome') || str_contains($userAgent, 'android_kuaifan_eeui')) {
$browser = str_contains($userAgent, 'android_kuaifan_eeui') ? 'android-mobile' : 'chrome-desktop';
} elseif (str_contains($userAgent, 'safari') || str_contains($userAgent, 'ios_kuaifan_eeui')) {
$browser = str_contains($userAgent, 'ios_kuaifan_eeui') ? 'safari-mobile' : 'safari-desktop';
}
// electron 直接在线预览查看
if (str_contains($userAgent, 'electron') || str_contains($browser, 'desktop')) {
return Response::download($file, $name, [ return Response::download($file, $name, [
'Content-Type' => 'application/pdf' 'Content-Type' => 'application/pdf'
], 'inline'); ], 'inline');
} }
// EEUI App 直接在线预览查看 //
if (Base::isEEUIApp() && Base::judgeClientVersion("0.34.47")) { if (in_array($ext, File::localExt)) {
if ($browser === 'safari-mobile') { $url = Base::fillUrl($path);
$redirectUrl = Base::fillUrl($path); } else {
return <<<EOF $url = 'http://' . env('APP_IPPR') . '.3/' . $path;
<script> }
window.top.postMessage({ if ($ext !== 'pdf') {
action: "eeuiAppSendMessage", $url = Base::urlAddparameter($url, [
data: [ 'fullfilename' => $name . '.' . $ext
{ ]);
action: 'setPageData', // 设置页面数据 }
data: { $toUrl = Base::fillUrl("fileview/onlinePreview?url=" . urlencode(base64_encode($url)));
showProgress: true, return Redirect::to($toUrl, 301);
titleFixed: true, }
urlFixed: true, return abort(404);
} }
},
{ /**
action: 'createTarget', // 创建目标(访问新地址) * 设置语言和皮肤
url: "{$redirectUrl}", * @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
} */
] public function setting__theme_language()
}, "*") {
</script> return view('setting', [
EOF; 'theme' => Request::input('theme'),
'language' => Request::input('language')
]);
}
/**
* 提取所有中文
* @return array|string
*/
public function allcn()
{
if (!Base::is_internal_ip(Base::getIp())) {
// 限制内网访问
return "Forbidden Access";
}
$list = Base::readDir(resource_path());
$array = [];
foreach ($list as $item) {
$content = file_get_contents($item);
preg_match_all("/\\\$L\((.*?)\)/", $content, $matchs);
if ($matchs) {
foreach ($matchs[1] as $text) {
$array[trim(trim($text, '"'), "'")] = trim(trim($text, '"'), "'");
} }
} }
} }
// return array_values($array);
if (in_array($ext, File::localExt)) { }
$url = Base::fillUrl($path);
} else { /**
$url = 'http://nginx/' . $path; * 提取所有中文
* @return array|string
*/
public function allcn__php()
{
if (!Base::is_internal_ip(Base::getIp())) {
// 限制内网访问
return "Forbidden Access";
} }
$url = Base::urlAddparameter($url, [ $list = Base::readDir(app_path());
'fullfilename' => Base::rightDelete($name, '.' . $ext) . '_' . filemtime($file) . '.' . $ext $array = [];
]); foreach ($list as $item) {
$redirectUrl = Base::fillUrl("fileview/onlinePreview?url=" . urlencode(base64_encode($url))); $content = file_get_contents($item);
return Redirect::to($redirectUrl, 301); preg_match_all("/(retSuccess|retError|ApiException)\((.*?)[,|)]/", $content, $matchs);
if ($matchs) {
foreach ($matchs[2] as $text) {
$array[trim(trim($text, '"'), "'")] = trim(trim($text, '"'), "'");
}
}
}
return array_values($array);
}
/**
* 提取所有中文
* @return array|string
*/
public function allcn__all()
{
if (!Base::is_internal_ip(Base::getIp())) {
// 限制内网访问
return "Forbidden Access";
}
$list = array_merge(Base::readDir(app_path()), Base::readDir(resource_path()));
$array = [];
foreach ($list as $item) {
if (Base::rightExists($item, ".php") || Base::rightExists($item, ".vue") || Base::rightExists($item, ".js")) {
$content = file_get_contents($item);
preg_match_all("/(['\"])(.*?)[\u{4e00}-\u{9fa5}\u{FE30}-\u{FFA0}]+([\s\S]((?!\n).)*)\\1/u", $content, $matchs);
if ($matchs) {
foreach ($matchs[0] as $text) {
$tmp = preg_replace("/\/\/(.*?)$/", "", $text);
$tmp = preg_replace("/\/\/(.*?)\n/", "", $tmp);
$tmp = str_replace("", "", $tmp);
if (!preg_match("/[\u{4e00}-\u{9fa5}\u{FE30}-\u{FFA0}]/u", $tmp)){
continue; // 没有中文
}
$val = trim(trim($text, '"'), "'");
$array[md5($val)] = $val;
}
}
}
}
return implode("\n", array_values($array));
} }
} }

View File

@ -2,7 +2,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\User;
use App\Module\Base; use App\Module\Base;
use App\Tasks\IhttpTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Foundation\Validation\ValidatesRequests;
@ -29,7 +32,24 @@ class InvokeController extends BaseController
$msg = "404 not found (" . str_replace("__", "/", $app) . ")."; $msg = "404 not found (" . str_replace("__", "/", $app) . ").";
return Base::ajaxError($msg); return Base::ajaxError($msg);
} }
// // 使用websocket请求
$apiWebsocket = Request::header('Api-Websocket');
if ($apiWebsocket) {
$userid = User::userid();
if ($userid > 0) {
$url = 'http://127.0.0.1:' . env('LARAVELS_LISTEN_PORT') . Request::getRequestUri();
$task = new IhttpTask($url, Request::post(), [
'Content-Type' => Request::header('Content-Type'),
'language' => Request::header('language'),
'token' => Request::header('token'),
]);
$task->setApiWebsocket($apiWebsocket);
$task->setApiUserid($userid);
Task::deliver($task);
return Base::retSuccess('wait');
}
}
// 正常请求
$res = $this->__before($method, $action); $res = $this->__before($method, $action);
if ($res === true || Base::isSuccess($res)) { if ($res === true || Base::isSuccess($res)) {
return $this->$app(); return $this->$app();

View File

@ -10,19 +10,14 @@ class TrustProxies extends Middleware
/** /**
* The trusted proxies for this application. * The trusted proxies for this application.
* *
* PHPSwoole只在内网被 nginx 访问,外部无法直连,故信任内网代理。
*
* @var array|string|null * @var array|string|null
*/ */
protected $proxies = '*'; protected $proxies;
/** /**
* The headers that should be used to detect proxies. * The headers that should be used to detect proxies.
* *
* 只采信 X-Forwarded-Protonginx 已用 $the_scheme 覆盖该头(值由 nginx 控制),
* 据此让 url() 实时跟随 httpshost/for 一律不信,避免 Host 注入与 IP 伪造。
*
* @var int * @var int
*/ */
protected $headers = Request::HEADER_X_FORWARDED_PROTO; 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;
} }

View File

@ -4,10 +4,7 @@ namespace App\Http\Middleware;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING); @error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Module\Base;
use App\Module\Doo; use App\Module\Doo;
use App\Services\RequestContext;
use Cache;
use Closure; use Closure;
class WebApi class WebApi
@ -21,23 +18,11 @@ class WebApi
*/ */
public function handle($request, Closure $next) public function handle($request, Closure $next)
{ {
// 记录请求信息 global $_A;
RequestContext::set('start_time', microtime(true)); $_A = [];
RequestContext::set('header_language', $request->header('language'));
// 更新请求的基本URL
RequestContext::updateBaseUrl($request);
// 加载Doo类
Doo::load(); Doo::load();
// 记录 PC 端活跃时间
$userid = Doo::userId();
if ($userid > 0 && Base::isPc()) {
Cache::put("user_pc_active:{$userid}", time(), 60);
}
// 解密请求内容
$encrypt = Doo::pgpParseStr($request->header('encrypt')); $encrypt = Doo::pgpParseStr($request->header('encrypt'));
if ($request->isMethod('post')) { if ($request->isMethod('post')) {
$version = $request->header('version'); $version = $request->header('version');
@ -56,7 +41,12 @@ 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); $response = $next($request);
// 加密返回内容 // 加密返回内容
@ -67,16 +57,6 @@ class WebApi
} }
} }
// 返回响应
return $response; return $response;
} }
/**
* @return void
*/
public function terminate()
{
// 请求结束后清理上下文
RequestContext::clean();
}
} }

View File

@ -2,10 +2,8 @@
namespace App\Ldap; namespace App\Ldap;
use App\Exceptions\ApiException;
use App\Models\User; use App\Models\User;
use App\Module\Base; use App\Module\Base;
use App\Services\RequestContext;
use LdapRecord\Configuration\ConfigurationException; use LdapRecord\Configuration\ConfigurationException;
use LdapRecord\Container; use LdapRecord\Container;
use LdapRecord\LdapRecordException; use LdapRecord\LdapRecordException;
@ -13,18 +11,20 @@ use LdapRecord\Models\Model;
class LdapUser extends Model class LdapUser extends Model
{ {
protected static $init = null;
/** /**
* The object classes of the LDAP model. * The object classes of the LDAP model.
* *
* @var array * @var array
*/ */
public static $objectClasses = [ public static $objectClasses = [
'inetOrgPerson',
'organizationalPerson',
'person', 'person',
'top', 'top',
'posixAccount',
]; ];
private static $emailAttrs = ['mail', 'cn', 'uid', 'userPrincipalName'];
/** /**
* @return mixed|null * @return mixed|null
*/ */
@ -68,29 +68,19 @@ class LdapUser extends Model
return Base::settingFind('thirdAccessSetting', 'ldap_sync_local') === 'open'; 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 * @return bool
*/ */
public static function initConfig() public static function initConfig()
{ {
if (RequestContext::has('ldap_init')) { if (is_bool(self::$init)) {
return RequestContext::get('ldap_init'); return self::$init;
} }
// //
$setting = Base::setting('thirdAccessSetting'); $setting = Base::setting('thirdAccessSetting');
if ($setting['ldap_open'] !== 'open') { if ($setting['ldap_open'] !== 'open') {
return RequestContext::save('ldap_init', false); return self::$init = false;
} }
// //
$connection = Container::getDefaultConnection(); $connection = Container::getDefaultConnection();
@ -102,15 +92,15 @@ class LdapUser extends Model
"username" => $setting['ldap_user_dn'], "username" => $setting['ldap_user_dn'],
"password" => $setting['ldap_password'], "password" => $setting['ldap_password'],
]); ]);
return RequestContext::save('ldap_init', true); return self::$init = true;
} catch (ConfigurationException $e) { } catch (ConfigurationException $e) {
info($e->getMessage()); info($e->getMessage());
return RequestContext::save('ldap_init', false); return self::$init = false;
} }
} }
/** /**
* 通过管理员绑定搜索用户,然后用用户 DN Bind 认证 * 获取
* @param $username * @param $username
* @param $password * @param $password
* @return Model|null * @return Model|null
@ -121,68 +111,16 @@ class LdapUser extends Model
return null; return null;
} }
try { try {
$loginAttr = self::getLoginAttr(); return self::static()
$row = self::static() ->where([
->whereRaw($loginAttr, '=', $username) 'cn' => $username,
->first(); 'userPassword' => $password
if (!$row) { ])->first();
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) { } catch (\Exception) {
return null; 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 * @param $username
@ -200,18 +138,7 @@ class LdapUser extends Model
return null; return null;
} }
if (empty($user)) { if (empty($user)) {
$email = self::getUserEmail($row); $user = User::reg($username, $password);
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) { if ($user) {
$userimg = $row->getPhoto(); $userimg = $row->getPhoto();
@ -246,7 +173,7 @@ class LdapUser extends Model
} }
// //
if (self::isSyncLocal()) { if (self::isSyncLocal()) {
$row = self::findByEmail($user->email); $row = self::userFirst($user->email, $password);
if ($row) { if ($row) {
return; return;
} }
@ -257,18 +184,17 @@ class LdapUser extends Model
} else { } else {
$userimg = ''; $userimg = '';
} }
$attrs = [ self::static()->create([
'cn' => $user->email, 'cn' => $user->email,
'gidNumber' => 0,
'homeDirectory' => '/home/ldap/dootask/' . env("APP_NAME"),
'sn' => $user->email, 'sn' => $user->email,
'uid' => $user->email, 'uid' => $user->email,
'uidNumber' => $user->userid,
'userPassword' => $password, 'userPassword' => $password,
'displayName' => $user->nickname, 'displayName' => $user->nickname,
'mail' => $user->email, 'jpegPhoto' => $userimg,
]; ]);
if ($userimg) {
$attrs['jpegPhoto'] = $userimg;
}
self::static()->create($attrs);
$user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['ldap']), ['ldap'])); $user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['ldap']), ['ldap']));
$user->save(); $user->save();
} catch (LdapRecordException $e) { } catch (LdapRecordException $e) {
@ -279,11 +205,11 @@ class LdapUser extends Model
/** /**
* 更新 * 更新
* @param $email * @param $username
* @param $array * @param $array
* @return void * @return void
*/ */
public static function userUpdate($email, $array) public static function userUpdate($username, $array)
{ {
if (empty($array)) { if (empty($array)) {
return; return;
@ -292,7 +218,10 @@ class LdapUser extends Model
return; return;
} }
try { try {
$row = self::findByEmail($email); $row = self::static()
->where([
'cn' => $username,
])->first();
$row?->update($array); $row?->update($array);
} catch (\Exception $e) { } catch (\Exception $e) {
info("[LDAP] update fail: " . $e->getMessage()); info("[LDAP] update fail: " . $e->getMessage());
@ -301,16 +230,19 @@ class LdapUser extends Model
/** /**
* 删除 * 删除
* @param $email * @param $username
* @return void * @return void
*/ */
public static function userDelete($email) public static function userDelete($username)
{ {
if (!self::initConfig()) { if (!self::initConfig()) {
return; return;
} }
try { try {
$row = self::findByEmail($email); $row = self::static()
->where([
'cn' => $username,
])->first();
$row?->delete(); $row?->delete();
} catch (\Exception $e) { } catch (\Exception $e) {
info("[LDAP] delete fail: " . $e->getMessage()); info("[LDAP] delete fail: " . $e->getMessage());

View File

@ -20,7 +20,9 @@ use Illuminate\Support\Facades\DB;
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelAppend() * @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\Model|object|static|null cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|static with($relations) * @method static \Illuminate\Database\Eloquent\Builder|static with($relations)
* @method static \Illuminate\Pagination\LengthAwarePaginator paginate(callable $callback) * @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 int change(array $array) * @method int change(array $array)
* @method int remove() * @method int remove()
* @mixin \Eloquent * @mixin \Eloquent
@ -32,28 +34,6 @@ class AbstractModel extends Model
const ID = 'id'; const ID = 'id';
protected $dates = [ protected $dates = [
'top_at',
'last_at',
'start_at',
'end_at',
'archived_at',
'complete_at',
'loop_at',
'receive_at',
'line_at',
'disable_at',
'clear_at',
'read_at',
'done_at',
'remind_at',
'reminded_at',
'created_at', 'created_at',
'updated_at', 'updated_at',
'deleted_at', 'deleted_at',
@ -151,25 +131,6 @@ class AbstractModel extends Model
return $date->format($this->dateFormat ?: 'Y-m-d H:i:s'); return $date->format($this->dateFormat ?: 'Y-m-d H:i:s');
} }
/**
* 通过模型创建实例
* @param array $param
* @param bool $force
* @return static
*/
public static function fillInstance(array $param = [], bool $force = true)
{
$instance = new static;
if ($param) {
if ($force) {
$instance->forceFill($param);
} else {
$instance->fill($param);
}
}
return $instance;
}
/** /**
* 创建/更新数据 * 创建/更新数据
* @param array $param * @param array $param
@ -228,44 +189,24 @@ class AbstractModel extends Model
/** /**
* 数据库更新或插入 * 数据库更新或插入
* @param array $where 查询条件 * @param $where
* @param array|\Closure $update 存在时更新的内容 * @param array $update 存在时更新的内容
* @param array|\Closure $insert 不存在时插入的内容,如果没有则插入更新内容 * @param array $insert 不存在时插入的内容,如果没有则插入更新内容
* @param bool $isInsert 是否是插入数据 * @param bool $isInsert 是否是插入数据
* @param bool|null $lockForUpdate 是否加锁true:加锁false:不加锁null:在事务中会自动加锁)
* @return AbstractModel|\Illuminate\Database\Eloquent\Builder|Model|object|static|null * @return AbstractModel|\Illuminate\Database\Eloquent\Builder|Model|object|static|null
*/ */
public static function updateInsert($where, $update = [], $insert = [], &$isInsert = true, $lockForUpdate = null) public static function updateInsert($where, $update = [], $insert = [], &$isInsert = true)
{ {
$query = static::where($where); $row = static::where($where)->first();
if ($lockForUpdate === null) {
$lockForUpdate = \DB::transactionLevel() > 0;
}
if ($lockForUpdate) {
$query->lockForUpdate();
}
$row = $query->first();
if (empty($row)) { if (empty($row)) {
$row = new static; $row = new static;
if ($insert instanceof \Closure) { $array = array_merge($where, $insert ?: $update);
$insert = $insert();
}
if (empty($insert)) {
if ($update instanceof \Closure) {
$update = $update();
}
$insert = $update;
}
$array = array_merge($where, $insert);
if (isset($array[$row->primaryKey])) { if (isset($array[$row->primaryKey])) {
unset($array[$row->primaryKey]); unset($array[$row->primaryKey]);
} }
$row->updateInstance($array); $row->updateInstance($array);
$isInsert = true; $isInsert = true;
} elseif ($update) { } elseif ($update) {
if ($update instanceof \Closure) {
$update = $update();
}
$row->updateInstance($update); $row->updateInstance($update);
$isInsert = false; $isInsert = false;
} }

View File

@ -1,22 +0,0 @@
<?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';
}

View File

@ -1,99 +0,0 @@
<?php
namespace App\Models;
use Cache;
use Carbon\Carbon;
use DB;
/**
* App\Models\ApproveProcInstHistory
*
* @property int $id
* @property int $proc_def_id 流程定义ID
* @property string|null $proc_def_name 流程定义名
* @property string|null $title 标题
* @property int|null $department_id 用户部门ID
* @property string|null $department 用户部门
* @property string|null $company 用户公司
* @property string|null $node_id 当前节点
* @property string|null $candidate 审批人
* @property int|null $task_id 当前任务
* @property string|null $start_time 开始时间
* @property string|null $end_time 结束时间
* @property int|null $duration 持续时间
* @property string|null $start_user_id 开始用户ID
* @property string|null $start_user_name 开始用户名
* @property int|null $is_finished 是否完成
* @property string|null $var
* @property int $state 当前状态: 0待审批1审批中2通过3拒绝4撤回
* @property string|null $latest_comment
* @property string|null $global_comment
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereCandidate($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereCompany($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDepartment($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDepartmentId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDuration($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereEndTime($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereGlobalComment($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereIsFinished($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereLatestComment($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereNodeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereProcDefId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereProcDefName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartTime($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartUserName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereState($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereVar($value)
* @mixin \Eloquent
*/
class ApproveProcInstHistory extends AbstractModel
{
protected $table = 'approve_proc_inst_history';
/**
* 获取用户审批状态(请假、外出)
* @param $userid
* @return mixed|null
*/
public static function getUserApprovalStatus($userid)
{
if (empty($userid)) {
return null;
}
return Cache::remember('user_is_leave_' . $userid, Carbon::now()->addMinute(), function () use ($userid) {
return self::where([
['start_user_id', '=', $userid],
[DB::raw("JSON_UNQUOTE(JSON_EXTRACT(var, '$.startTime'))"), '<=', Carbon::now()->toDateTimeString()],
[DB::raw("JSON_UNQUOTE(JSON_EXTRACT(var, '$.endTime'))"), '>=', Carbon::now()->toDateTimeString()],
['state', '=', 2]
])->where(function ($query) {
$query->where('proc_def_name', 'like', '%请假%')
->orWhere('proc_def_name', 'like', '%外出%');
})->orderByDesc('id')->value('proc_def_name');
});
}
/**
* 判断用户是否请假(包含:请假、外出)
* @param $userid
* @return bool
*/
public static function userIsLeave($userid)
{
return (bool)self::getUserApprovalStatus($userid);
}
}

View File

@ -11,15 +11,9 @@ namespace App\Models;
* @property int|null $msg_id 消息ID * @property int|null $msg_id 消息ID
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg query() * @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereMsgId($value) * @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereMsgId($value)

View File

@ -1,41 +0,0 @@
<?php
namespace App\Models;
/**
* App\Models\Complaint
*
* @property int $id
* @property int|null $dialog_id 对话ID
* @property int|null $userid 举报人id
* @property int|null $type 举报类型
* @property string|null $reason 举报原因
* @property string|null $imgs 举报图片
* @property int|null $status 状态 0待处理、1已处理、2已删除
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|Complaint newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Complaint newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Complaint query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|Complaint whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Complaint whereDialogId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Complaint whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Complaint whereImgs($value)
* @method static \Illuminate\Database\Eloquent\Builder|Complaint whereReason($value)
* @method static \Illuminate\Database\Eloquent\Builder|Complaint whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|Complaint whereType($value)
* @method static \Illuminate\Database\Eloquent\Builder|Complaint whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Complaint whereUserid($value)
* @mixin \Eloquent
*/
class Complaint extends AbstractModel
{
}

View File

@ -12,15 +12,9 @@ use Carbon\Carbon;
* @property int|null $did 删除的数据ID * @property int|null $did 删除的数据ID
* @property int|null $userid 关系会员ID * @property int|null $userid 关系会员ID
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|Deleted newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Deleted newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Deleted newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Deleted newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Deleted query() * @method static \Illuminate\Database\Eloquent\Builder|Deleted query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|Deleted whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Deleted whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Deleted whereDid($value) * @method static \Illuminate\Database\Eloquent\Builder|Deleted whereDid($value)
* @method static \Illuminate\Database\Eloquent\Builder|Deleted whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|Deleted whereId($value)

View File

@ -3,11 +3,8 @@
namespace App\Models; namespace App\Models;
use Request; use Request;
use App\Module\Apps;
use App\Module\Base; use App\Module\Base;
use App\Tasks\PushTask; use App\Tasks\PushTask;
use App\Tasks\ManticoreSyncTask;
use App\Observers\AbstractObserver;
use App\Exceptions\ApiException; use App\Exceptions\ApiException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Hhxsv5\LaravelS\Swoole\Task\Task; use Hhxsv5\LaravelS\Swoole\Task\Task;
@ -26,30 +23,20 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int|null $size 大小(B) * @property int|null $size 大小(B)
* @property int|null $userid 拥有者ID * @property int|null $userid 拥有者ID
* @property int|null $share 是否共享 * @property int|null $share 是否共享
* @property int|null $guest_access 是否允许游客访问
* @property int|null $pshare 所属分享ID * @property int|null $pshare 所属分享ID
* @property int|null $created_id 创建者 * @property int|null $created_id 创建者
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at * @property \Illuminate\Support\Carbon|null $deleted_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|File newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|File newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|File newQuery() * @method static \Illuminate\Database\Eloquent\Builder|File newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|File onlyTrashed() * @method static \Illuminate\Database\Eloquent\Builder|File onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|File query() * @method static \Illuminate\Database\Eloquent\Builder|File query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|File searchByKeyword(string $keyword)
* @method static \Illuminate\Database\Eloquent\Builder|File sharedToUser(int $userid)
* @method static \Illuminate\Database\Eloquent\Builder|File whereCid($value) * @method static \Illuminate\Database\Eloquent\Builder|File whereCid($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedId($value) * @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedId($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereDeletedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|File whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereExt($value) * @method static \Illuminate\Database\Eloquent\Builder|File whereExt($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereGuestAccess($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|File whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereName($value) * @method static \Illuminate\Database\Eloquent\Builder|File whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|File wherePid($value) * @method static \Illuminate\Database\Eloquent\Builder|File wherePid($value)
@ -86,28 +73,9 @@ class File extends AbstractModel
* office文件 * office文件
*/ */
const officeExt = [ const officeExt = [
// 文本文件 'doc', 'docx',
'doc', 'docx', // Microsoft Word 文档 'xls', 'xlsx',
'dot', 'dotx', // Word 模板 'ppt', 'pptx',
'odt', // OpenDocument 文本格式
'ott', // OpenDocument 文本模板
'rtf', // 富文本格式
// 电子表格
'xls', 'xlsx', // Microsoft Excel 电子表格
'xlsm', // Excel 含宏的工作簿
'xlt', 'xltx', // Excel 模板
'ods', // OpenDocument 电子表格格式
'ots', // OpenDocument 电子表格模板
'csv', // 逗号分隔值
'tsv', // 制表符分隔值
// 演示文稿
'ppt', 'pptx', // Microsoft PowerPoint 演示文稿
'pps', 'ppsx', // PowerPoint 幻灯片放映
'pot', 'potx', // PowerPoint 模板
'odp', // OpenDocument 演示文稿格式
'otp', // OpenDocument 演示文稿模板
]; ];
/** /**
@ -124,60 +92,14 @@ class File extends AbstractModel
'jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'ico', 'raw', 'jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'ico', 'raw',
'tif', 'tiff', 'tif', 'tiff',
'mp3', 'wav', 'mp4', 'flv', 'mp3', 'wav', 'mp4', 'flv',
// 'avi', 'mov', 'wmv', 'mkv', '3gp', 'rm', // 这一排是要转换的,无法使用本地播放 'avi', 'mov', 'wmv', 'mkv', '3gp', 'rm',
]; ];
/**
* 压缩包下载大小限制
*/
const zipMaxSize = 1024 * 1024 * 1024; // 1G
/**
* 按关键词搜索文件Scope
* 支持文件ID纯数字、文件名
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $keyword 搜索关键词
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeSearchByKeyword($query, string $keyword)
{
if (is_numeric($keyword)) {
return $query->where(function ($q) use ($keyword) {
$q->where("id", intval($keyword))
->orWhere("name", "like", "%{$keyword}%");
});
}
return $query->where("name", "like", "%{$keyword}%");
}
/**
* 筛选用户可访问的共享文件Scope
* 不包括用户自己的文件,仅返回他人共享给该用户的文件
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $userid 用户ID
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeSharedToUser($query, int $userid)
{
return $query->whereIn('pshare', function ($subQuery) use ($userid) {
$subQuery->select('files.id')
->from('files')
->join('file_users', 'files.id', '=', 'file_users.file_id')
->where('files.userid', '!=', $userid)
->where(function ($q) use ($userid) {
$q->whereIn('file_users.userid', [0, $userid]);
});
});
}
/** /**
* 获取文件列表 * 获取文件列表
* @param user $user * @param user $user
* @param int $pid * @param int $pid
* @param string $type
* @param bool $isGetparent
* @return array * @return array
*/ */
public function getFileList($user, int $pid, $type = "all", $isGetparent = true) public function getFileList($user, int $pid, $type = "all", $isGetparent = true)
@ -185,7 +107,7 @@ class File extends AbstractModel
$permission = 1000; $permission = 1000;
$userids = $user->isTemp() ? [$user->userid] : [0, $user->userid]; $userids = $user->isTemp() ? [$user->userid] : [0, $user->userid];
$builder = File::wherePid($pid) $builder = File::wherePid($pid)
->when($type == 'dir', function ($q) { ->when($type=='dir',function($q){
$q->whereType('folder'); $q->whereType('folder');
}); });
if ($pid > 0) { if ($pid > 0) {
@ -201,7 +123,7 @@ class File extends AbstractModel
// //
if ($pid > 0) { if ($pid > 0) {
// 遍历获取父级 // 遍历获取父级
if ($isGetparent) { if($isGetparent){
while ($pid > 0) { while ($pid > 0) {
$file = File::whereId($pid)->first(); $file = File::whereId($pid)->first();
if (empty($file)) { if (empty($file)) {
@ -239,8 +161,8 @@ class File extends AbstractModel
->whereIn('file_users.userid', $userids) ->whereIn('file_users.userid', $userids)
->groupBy('files.id') ->groupBy('files.id')
->take(100) ->take(100)
->when($type == 'dir', function ($q) { ->when($type=='dir',function($q){
$q->where('files.type', 'folder'); $q->where('files.type','folder');
}) })
->get(); ->get();
if ($list->isNotEmpty()) { if ($list->isNotEmpty()) {
@ -263,10 +185,9 @@ class File extends AbstractModel
* @param user $user * @param user $user
* @param int $pid * @param int $pid
* @param string $webkitRelativePath * @param string $webkitRelativePath
* @param bool $overwrite
* @return array * @return array
*/ */
public function contentUpload($user, int $pid, $webkitRelativePath, $overwrite = false) public function contentUpload($user, int $pid, $webkitRelativePath)
{ {
$userid = $user->userid; $userid = $user->userid;
if ($pid > 0) { if ($pid > 0) {
@ -312,16 +233,15 @@ class File extends AbstractModel
} }
} }
// //
$path = 'uploads/tmp/file/' . date("Ym") . '/'; $path = 'uploads/tmp/' . date("Ym") . '/';
$data = Base::upload([ $data = Base::upload([
"file" => Request::file('files'), "file" => Request::file('files'),
"type" => 'more', "type" => 'more',
"autoThumb" => false, "autoThumb" => false,
"path" => $path, "path" => $path,
"quality" => true
]); ]);
if (Base::isError($data)) { if (Base::isError($data)) {
throw new ApiException($data['msg']); return $data;
} }
$data = $data['data']; $data = $data['data'];
// //
@ -329,9 +249,9 @@ class File extends AbstractModel
'text', 'md', 'markdown' => 'document', 'text', 'md', 'markdown' => 'document',
'drawio' => 'drawio', 'drawio' => 'drawio',
'mind' => 'mind', 'mind' => 'mind',
'doc', 'docx', 'dot', 'dotx', 'odt', 'ott', 'rtf' => "word", 'doc', 'docx' => "word",
'xls', 'xlsx', 'xlsm', 'xlt', 'xltx', 'ods', 'ots', 'csv', 'tsv' => "excel", 'xls', 'xlsx' => "excel",
'ppt', 'pptx', 'pps', 'ppsx', 'pot', 'potx', 'odp', 'otp' => "ppt", 'ppt', 'pptx' => "ppt",
'wps' => "wps", 'wps' => "wps",
'jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'ico', 'raw', 'svg' => "picture", 'jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'ico', 'raw', 'svg' => "picture",
'rar', 'zip', 'jar', '7-zip', 'tar', 'gzip', '7z', 'gz', 'apk', 'dmg' => "archive", 'rar', 'zip', 'jar', '7-zip', 'tar', 'gzip', '7z', 'gz', 'apk', 'dmg' => "archive",
@ -356,25 +276,17 @@ class File extends AbstractModel
if ($data['ext'] == 'markdown') { if ($data['ext'] == 'markdown') {
$data['ext'] = 'md'; $data['ext'] = 'md';
} }
$file = null; $file = File::createInstance([
$params = [
'pid' => $pid, 'pid' => $pid,
'name' => Base::rightDelete($data['name'], '.' . $data['ext']), 'name' => Base::rightDelete($data['name'], '.' . $data['ext']),
'type' => $type, 'type' => $type,
'ext' => $data['ext'], 'ext' => $data['ext'],
'userid' => $userid, 'userid' => $userid,
'created_id' => $user->userid, 'created_id' => $user->userid,
]; ]);
if ($overwrite) { $file->handleDuplicateName();
$file = self::wherePid($params['pid'])->whereExt($params['ext'])->whereName($params['name'])->first();
}
if (!$file) {
$overwrite = false;
$file = File::createInstance($params);
$file->handleDuplicateName();
}
// 开始创建 // 开始创建
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file) { return AbstractModel::transaction(function () use ($addItem, $webkitRelativePath, $type, $user, $data, $file) {
$file->size = $data['size'] * 1024; $file->size = $data['size'] * 1024;
$file->saveBeforePP(); $file->saveBeforePP();
// //
@ -402,12 +314,11 @@ class File extends AbstractModel
$tmpRow->pushMsg('add', $tmpRow); $tmpRow->pushMsg('add', $tmpRow);
// //
$data = File::handleImageUrl($tmpRow->toArray()); $data = File::handleImageUrl($tmpRow->toArray());
$data['full_name'] = $webkitRelativePath ?: ($data['name'] . '.' . $data['ext']); $data['full_name'] = $webkitRelativePath ?: $data['name'];
$data['overwrite'] = $overwrite ? 1 : 0;
// //
$addItem[] = $data; $addItem[] = $data;
return ['data' => $data, 'addItem' => $addItem]; return ['data'=>$data,'addItem'=>$addItem];
}); });
} }
@ -418,8 +329,7 @@ class File extends AbstractModel
*/ */
public function getPermission(array $userids) public function getPermission(array $userids)
{ {
$validUserIds = array_filter($userids); if (in_array($this->userid, $userids) || in_array($this->created_id, $userids)) {
if (in_array($this->userid, $validUserIds) || in_array($this->created_id, $validUserIds)) {
// ① 自己的文件夹 或 自己创建的文件夹 // ① 自己的文件夹 或 自己创建的文件夹
return 1000; return 1000;
} }
@ -627,26 +537,6 @@ class File extends AbstractModel
return true; return true;
} }
/**
* 批量更新子文件的 userid 并同步到 Manticore
* @param int $userid 新的 userid
* @return int 更新的文件数量
*/
public function updateChildFilesUserid(int $userid): int
{
self::where('pids', 'like', "%,{$this->id},%")->update(['userid' => $userid]);
// 批量 update 绕过 Observer手动触发 Manticore 同步
$childFileIds = self::where('pids', 'like', "%,{$this->id},%")
->where('type', '!=', 'folder')
->pluck('id')
->toArray();
foreach ($childFileIds as $childFileId) {
AbstractObserver::taskDeliver(new ManticoreSyncTask('file_sync', ['id' => $childFileId]));
}
return count($childFileIds);
}
/** /**
* 获取文件分享链接 * 获取文件分享链接
* @param $userid * @param $userid
@ -707,29 +597,6 @@ class File extends AbstractModel
Task::deliver($task); Task::deliver($task);
} }
/**
* 文件推送消息
* @param $action
* @param array|null $data 发送内容
* @param int $userid 会员ID
*/
public static function pushMsgSimple($action, $data, $userid)
{
if (empty($data) || empty($userid)) {
return;
}
$msg = [
'type' => 'file',
'action' => $action,
'data' => $data,
];
$params = [
'userid' => $userid,
'msg' => $msg
];
Task::deliver(new PushTask($params));
}
/** /**
* 获取推送会员 * 获取推送会员
* @param $action * @param $action
@ -773,7 +640,7 @@ class File extends AbstractModel
/** /**
* code获取文件ID、名称 * code获取文件ID、名称
* @param $code * @param $code
* @return File|null * @return File
*/ */
public static function code2IdName($code) { public static function code2IdName($code) {
$arr = explode(",", base64_decode($code)); $arr = explode(",", base64_decode($code));
@ -814,9 +681,9 @@ class File extends AbstractModel
* @param int $permission * @param int $permission
* @return File * @return File
*/ */
public static function permissionFind($id, $user, int $limit = 0, int &$permission = -1) public static function permissionFind(int $id, $user, int $limit = 0, int &$permission = -1)
{ {
$file = File::find(intval($id)); $file = File::find($id);
if (empty($file)) { if (empty($file)) {
throw new ApiException('文件不存在或已被删除'); throw new ApiException('文件不存在或已被删除');
} }
@ -965,119 +832,4 @@ class File extends AbstractModel
} }
}); });
} }
/**
* 获取文件树并计算文件总大小
*
* @param int $fileId
* @param User $user
* @param int $permission 0-访问权限、1-读写权限、1000-所有者或创建者
* @param string $path
* @param int $totalSize
* @return object
*/
public static function getFilesTree(int $fileId, User $user, $permission = 1, $path = '', &$totalSize = 0) {
$file = File::permissionFind($fileId, $user, $permission);
$file->path = ltrim($path . '/' . $file->name, '/');
$file->children = [];
if ($file->type == 'folder') {
$files = $file->getFileList($user, $fileId, 'all', false);
foreach ($files as &$childFile) {
$childFile['path'] = $file->path . '/' . $childFile['name'];
if ($childFile['type'] == 'folder') {
$childFile['children'] = self::getFilesTree($childFile['id'], $user, $permission, $file->path, $totalSize);
} else {
$totalSize += $childFile['size'];
}
}
$file->children = $files;
} else {
$totalSize += $file->size;
}
$file->totalSize = $totalSize;
return $file;
}
/**
* 文件夹文件添加到压缩文件
*
* @param \ZipArchive $zip
* @param object $file
* @return void
*/
public static function addFileTreeToZip($zip, $file)
{
if ($file->type != 'folder' && $file->name != '') {
$content = FileContent::whereFid($file->id)->orderByDesc('id')->first();
$content = Base::json2array($content?->content ?: []);
$typeExtensions = [
'word' => 'docx',
'excel' => 'xlsx',
'ppt' => 'pptx',
];
if (array_key_exists($file->type, $typeExtensions)) {
$filePath = empty($content) ? public_path('assets/office/empty.' . $typeExtensions[$file->type]) : public_path($content['url']);
}
//
$relativePath = $file->path . '.' . $file->ext;
if (file_exists($filePath)) {
$zip->addFile($filePath, $relativePath);
} else {
if (empty($content['url'])) {
$zip->addFromString($relativePath, $content['content']);
} else {
$filePath = public_path($content['url']);
$zip->addFile($filePath, $relativePath);
}
}
} else {
if (isset($file->children)) {
foreach ($file->children as $childFile) {
try {
self::addFileTreeToZip($zip, (object)$childFile);
} catch (\Exception $e) {
}
}
}
// 在压缩包中创建文件夹
$zip->addEmptyDir($file->path);
}
}
/**
* 根据文件类型判断是否需要安装应用
* @param $type
* @return void
*/
public static function isNeedInstallApp($type): void
{
// 文件类型与应用的映射配置
$fileTypeAppMapping = [
// Office 应用映射
[
'types' => ['word', 'excel', 'ppt', 'docx', 'xlsx', 'pptx'],
'app_id' => 'office',
'app_name' => 'OnlyOffice'
],
// Drawio 应用映射
[
'types' => ['drawio'],
'app_id' => 'drawio',
'app_name' => 'Drawio'
],
// Minder 应用映射
[
'types' => ['mind'],
'app_id' => 'minder',
'app_name' => 'Minder'
]
];
// 遍历配置检查是否需要安装应用
foreach ($fileTypeAppMapping as $config) {
if (in_array($type, $config['types'])) {
Apps::isInstalledThrow($config['app_id']);
}
}
}
} }

View File

@ -2,10 +2,9 @@
namespace App\Models; namespace App\Models;
use App\Module\Base; use App\Module\Base;
use App\Module\Timer;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Symfony\Component\HttpFoundation\StreamedResponse;
/** /**
* App\Models\FileContent * App\Models\FileContent
@ -19,16 +18,10 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at * @property \Illuminate\Support\Carbon|null $deleted_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|FileContent newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|FileContent newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FileContent newQuery() * @method static \Illuminate\Database\Eloquent\Builder|FileContent newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FileContent onlyTrashed() * @method static \Illuminate\Database\Eloquent\Builder|FileContent onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|FileContent query() * @method static \Illuminate\Database\Eloquent\Builder|FileContent query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|FileContent whereContent($value) * @method static \Illuminate\Database\Eloquent\Builder|FileContent whereContent($value)
* @method static \Illuminate\Database\Eloquent\Builder|FileContent whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|FileContent whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FileContent whereDeletedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|FileContent whereDeletedAt($value)
@ -77,7 +70,7 @@ class FileContent extends AbstractModel
'name' => $name, 'name' => $name,
'ext' => $fileExt 'ext' => $fileExt
])); ]));
return Base::fillUrl("online/preview/{$name}?key={$key}&version=" . Base::getVersion() . "&__=" . Timer::msecTime()); return Base::fillUrl("online/preview/{$name}?key={$key}");
} }
/** /**
@ -104,10 +97,10 @@ class FileContent extends AbstractModel
/** /**
* 获取格式内容(或下载) * 获取格式内容(或下载)
* @param $file * @param File $file
* @param $content * @param $content
* @param $download * @param $download
* @return array|StreamedResponse * @return array|\Symfony\Component\HttpFoundation\StreamedResponse
*/ */
public static function formatContent($file, $content, $download = false) public static function formatContent($file, $content, $download = false)
{ {
@ -119,7 +112,7 @@ class FileContent extends AbstractModel
} else { } else {
$filePath = public_path($content['url']); $filePath = public_path($content['url']);
} }
return Base::DownloadFileResponse($filePath, $name); return Base::streamDownload($filePath, $name);
} }
if (empty($content)) { if (empty($content)) {
$content = match ($file->type) { $content = match ($file->type) {
@ -129,7 +122,9 @@ class FileContent extends AbstractModel
], ],
default => json_decode('{}'), default => json_decode('{}'),
}; };
abort_if($download, 403, "This file is empty."); if ($download) {
abort(403, "This file is empty.");
}
} else { } else {
$path = $content['url']; $path = $content['url'];
if ($file->ext) { if ($file->ext) {
@ -145,51 +140,13 @@ class FileContent extends AbstractModel
} }
if ($download) { if ($download) {
$filePath = public_path($path); $filePath = public_path($path);
abort_if(!isset($filePath),403, "This file not support download."); if (isset($filePath)) {
return Base::DownloadFileResponse($filePath, $name); return Base::streamDownload($filePath, $name);
} else {
abort(403, "This file not support download.");
}
} }
} }
return Base::retSuccess('success', [ 'content' => $content ]); return Base::retSuccess('success', [ 'content' => $content ]);
} }
/**
* 获取文件访问URL
* @param int $fileId 文件ID
* @return string|null 返回完整的文件URL如果文件无内容则返回null
*/
public static function getFileUrl($fileId)
{
$content = self::whereFid($fileId)->orderByDesc('id')->first();
if ($content) {
$contentData = Base::json2array($content->content ?: []);
if (!empty($contentData['url'])) {
return Base::fillUrl($contentData['url']);
}
}
return null;
}
/**
* 获取文件内容
* @param $id
* @return self|null
*/
public static function idOrCodeToContent($id)
{
$builder = null;
if (Base::isNumber($id)) {
$builder = FileContent::whereFid($id);
} elseif ($id) {
$fileLink = FileLink::whereCode($id)->first();
if ($fileLink) {
$builder = FileContent::whereFid($fileLink->file_id);
}
}
/** @var self $fileContent */
$fileContent = $builder?->orderByDesc('id')->first();
if ($fileContent) {
$fileContent->content = Base::json2array($fileContent->content ?: []);
}
return $fileContent;
}
} }

View File

@ -15,15 +15,9 @@ use App\Module\Base;
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\File|null $file * @property-read \App\Models\File|null $file
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|FileLink newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|FileLink newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FileLink newQuery() * @method static \Illuminate\Database\Eloquent\Builder|FileLink newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FileLink query() * @method static \Illuminate\Database\Eloquent\Builder|FileLink query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|FileLink whereCode($value) * @method static \Illuminate\Database\Eloquent\Builder|FileLink whereCode($value)
* @method static \Illuminate\Database\Eloquent\Builder|FileLink whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|FileLink whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FileLink whereFileId($value) * @method static \Illuminate\Database\Eloquent\Builder|FileLink whereFileId($value)

View File

@ -12,15 +12,9 @@ namespace App\Models;
* @property int|null $permission 权限0只读1读写 * @property int|null $permission 权限0只读1读写
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|FileUser newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|FileUser newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FileUser newQuery() * @method static \Illuminate\Database\Eloquent\Builder|FileUser newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FileUser query() * @method static \Illuminate\Database\Eloquent\Builder|FileUser query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|FileUser whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|FileUser whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FileUser whereFileId($value) * @method static \Illuminate\Database\Eloquent\Builder|FileUser whereFileId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FileUser whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|FileUser whereId($value)
@ -45,7 +39,7 @@ class FileUser extends AbstractModel
} else { } else {
FileLink::whereFileId($file_id)->delete(); FileLink::whereFileId($file_id)->delete();
} }
FileUser::whereFileId($file_id)->remove(); FileUser::whereFileId($file_id)->delete();
}); });
} }
/** /**
@ -58,7 +52,7 @@ class FileUser extends AbstractModel
{ {
return AbstractModel::transaction(function() use ($userid, $file_id) { return AbstractModel::transaction(function() use ($userid, $file_id) {
FileLink::whereFileId($file_id)->whereUserid($userid)->delete(); FileLink::whereFileId($file_id)->whereUserid($userid)->delete();
return self::whereFileId($file_id)->whereUserid($userid)->remove(); return self::whereFileId($file_id)->whereUserid($userid)->delete();
}); });
} }
} }

View File

@ -1,132 +0,0 @@
<?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(),
];
}
}

View File

@ -16,17 +16,11 @@ use Illuminate\Support\Carbon;
* @property int|null $userid 创建人 * @property int|null $userid 创建人
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property Carbon|null $end_at * @property string|null $end_at
* @property Carbon|null $deleted_at * @property Carbon|null $deleted_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|Meeting newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Meeting newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Meeting newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Meeting newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Meeting query() * @method static \Illuminate\Database\Eloquent\Builder|Meeting query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|Meeting whereChannel($value) * @method static \Illuminate\Database\Eloquent\Builder|Meeting whereChannel($value)
* @method static \Illuminate\Database\Eloquent\Builder|Meeting whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Meeting whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Meeting whereDeletedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Meeting whereDeletedAt($value)
@ -50,12 +44,12 @@ class Meeting extends AbstractModel
public function getShareLink() public function getShareLink()
{ {
$code = base64_encode("{$this->meetingid}" . Base::generatePassword()); $code = base64_encode("{$this->meetingid}" . Base::generatePassword());
Cache::put(self::CACHE_KEY . '_' . $code, [ Cache::put(self::CACHE_KEY.'_'.$code, [
'id' => $this->id, 'id' => $this->id,
'meetingid' => $this->meetingid, 'meetingid' => $this->meetingid,
'channel' => $this->channel, 'channel' => $this->channel,
], Carbon::now()->addHours(self::CACHE_EXPIRED_TIME)); ], Carbon::now()->addHours(self::CACHE_EXPIRED_TIME));
return Base::fillUrl("meeting/{$this->meetingid}/" . $code); return Base::fillUrl("meeting/{$this->meetingid}/".$code);
} }
/** /**
@ -64,19 +58,19 @@ class Meeting extends AbstractModel
*/ */
public static function getShareInfo($code) public static function getShareInfo($code)
{ {
if (Cache::has(self::CACHE_KEY . '_' . $code)) { if(Cache::has(self::CACHE_KEY.'_'.$code)){
return Cache::get(self::CACHE_KEY . '_' . $code); return Cache::get(self::CACHE_KEY.'_'.$code);
} }
return null; return null;
} }
/** /**
* 保存访客信息 * 保存访客信息
* @return void * @return mixed
*/ */
public static function setTouristInfo($data) public static function setTouristInfo($data)
{ {
Cache::put(Meeting::CACHE_KEY . '_' . $data['uid'], [ Cache::put(Meeting::CACHE_KEY.'_'.$data['uid'], [
'uid' => $data['uid'], 'uid' => $data['uid'],
'userimg' => $data['userimg'], 'userimg' => $data['userimg'],
'nickname' => $data['nickname'], 'nickname' => $data['nickname'],
@ -89,8 +83,8 @@ class Meeting extends AbstractModel
*/ */
public static function getTouristInfo($touristId) public static function getTouristInfo($touristId)
{ {
if (Cache::has(Meeting::CACHE_KEY . '_' . $touristId)) { if(Cache::has(Meeting::CACHE_KEY.'_'.$touristId)){
return Cache::get(Meeting::CACHE_KEY . '_' . $touristId); return Cache::get(Meeting::CACHE_KEY.'_'.$touristId);
} }
return null; return null;
} }

View File

@ -1,34 +0,0 @@
<?php
namespace App\Models;
/**
* App\Models\MeetingMsg
*
* @property int $id
* @property string|null $meetingid 会议ID
* @property int|null $dialog_id 对话ID
* @property int|null $msg_id 消息ID
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|MeetingMsg newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|MeetingMsg newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|MeetingMsg query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|MeetingMsg whereDialogId($value)
* @method static \Illuminate\Database\Eloquent\Builder|MeetingMsg whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|MeetingMsg whereMeetingid($value)
* @method static \Illuminate\Database\Eloquent\Builder|MeetingMsg whereMsgId($value)
* @mixin \Eloquent
*/
class MeetingMsg extends AbstractModel
{
function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->timestamps = false;
}
}

View File

@ -20,14 +20,9 @@ use Request;
* @property string|null $desc 描述、备注 * @property string|null $desc 描述、备注
* @property int|null $userid 创建人 * @property int|null $userid 创建人
* @property int|null $personal 是否个人项目 * @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 string|null $user_simple 成员总数|1,2,3
* @property int|null $dialog_id 聊天会话ID * @property int|null $dialog_id 聊天会话ID
* @property \Illuminate\Support\Carbon|null $archived_at 归档时间 * @property string|null $archived_at 归档时间
* @property int|null $archived_userid 归档会员 * @property int|null $archived_userid 归档会员
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
@ -41,19 +36,10 @@ use Request;
* @property-read int|null $project_user_count * @property-read int|null $project_user_count
* @method static \Illuminate\Database\Eloquent\Builder|Project allData($userid = null) * @method static \Illuminate\Database\Eloquent\Builder|Project allData($userid = null)
* @method static \Illuminate\Database\Eloquent\Builder|Project authData($userid = null, $owner = null) * @method static \Illuminate\Database\Eloquent\Builder|Project authData($userid = null, $owner = null)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|Project newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Project newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Project newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Project newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Project onlyTrashed() * @method static \Illuminate\Database\Eloquent\Builder|Project onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|Project query() * @method static \Illuminate\Database\Eloquent\Builder|Project query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|Project searchByKeyword(string $keyword)
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchiveDays($value)
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchiveMethod($value)
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchivedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Project whereArchivedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchivedUserid($value) * @method static \Illuminate\Database\Eloquent\Builder|Project whereArchivedUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|Project whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Project whereCreatedAt($value)
@ -80,7 +66,6 @@ class Project extends AbstractModel
protected $appends = [ protected $appends = [
'owner_userid', 'owner_userid',
'deputy_userids',
]; ];
/** /**
@ -96,58 +81,6 @@ class Project extends AbstractModel
return $this->appendattrs['owner_userid']; 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 * @return \Illuminate\Database\Eloquent\Relations\HasMany
*/ */
@ -186,7 +119,6 @@ class Project extends AbstractModel
'projects.*', 'projects.*',
'project_users.owner', 'project_users.owner',
'project_users.top_at', 'project_users.top_at',
'project_users.sort',
]) ])
->leftJoin('project_users', function ($leftJoin) use ($userid) { ->leftJoin('project_users', function ($leftJoin) use ($userid) {
$leftJoin $leftJoin
@ -211,7 +143,6 @@ class Project extends AbstractModel
'projects.*', 'projects.*',
'project_users.owner', 'project_users.owner',
'project_users.top_at', 'project_users.top_at',
'project_users.sort',
]) ])
->join('project_users', 'projects.id', '=', 'project_users.project_id') ->join('project_users', 'projects.id', '=', 'project_users.project_id')
->where('project_users.userid', $userid); ->where('project_users.userid', $userid);
@ -221,18 +152,6 @@ class Project extends AbstractModel
return $query; return $query;
} }
/**
* 按关键词搜索项目Scope
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $keyword 搜索关键词
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeSearchByKeyword($query, string $keyword)
{
return $query->where("projects.name", "like", "%{$keyword}%");
}
/** /**
* 获取任务统计数据 * 获取任务统计数据
* @param $userid * @param $userid
@ -283,40 +202,16 @@ class Project extends AbstractModel
return; return;
} }
AbstractModel::transaction(function() { AbstractModel::transaction(function() {
// 拉所有项目成员 + 各自 owner 值 $userids = $this->relationUserids();
$userOwnerMap = ProjectUser::whereProjectId($this->id)
->pluck('owner', 'userid');
$userids = $userOwnerMap->keys()->map(fn($v) => (int)$v)->toArray();
foreach ($userids as $userid) { foreach ($userids as $userid) {
$owner = (int)$userOwnerMap[$userid];
// 巧合:编码完全一致 owner 0/1/2 → role 0/1/2
$role = $owner;
WebSocketDialogUser::updateInsert([ WebSocketDialogUser::updateInsert([
'dialog_id' => $this->dialog_id, 'dialog_id' => $this->dialog_id,
'userid' => $userid, 'userid' => $userid,
], [ ], [
'important' => 1, '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();
// 同步 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]);
} }
WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove();
}); });
} }
@ -424,65 +319,44 @@ class Project extends AbstractModel
/** /**
* 推送消息 * 推送消息
* @param string $action * @param string $action
* @param array|self $data 推送内容 * @param array|self $data 发送内容,默认为[id=>项目ID]
* @param array $userid 指定会员,默认为项目所有成员 * @param array $userid 指定会员,默认为项目所有成员
*/ */
public function pushMsg($action, $data = null, $userid = null) public function pushMsg($action, $data = null, $userid = null)
{ {
// 处理数据 if ($data === null) {
if ($data instanceof self) { $data = ['id' => $this->id];
} elseif ($data instanceof self) {
$data = $data->toArray(); $data = $data->toArray();
} }
//
$data = is_array($data) ? $data : []; $array = [$userid, []];
$data['id'] = $this->id;
$data['name'] = $this->name;
$data['desc'] = $this->desc;
// 处理接收用户
$recipients = [$userid, []];
if ($userid === null) { if ($userid === null) {
$recipients[0] = $this->relationUserids(); $array[0] = $this->relationUserids();
} elseif (!is_array($userid)) { } elseif (!is_array($userid)) {
$recipients[0] = [$userid]; $array[0] = [$userid];
} }
//
// 移除不需要的字段
unset($data['top_at']);
// 处理所有者权限
if (isset($data['owner'])) { if (isset($data['owner'])) {
$owners = ProjectUser::whereProjectId($data['id']) $owners = ProjectUser::whereProjectId($data['id'])->whereOwner(1)->pluck('userid')->toArray();
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]) $array = [array_intersect($array[0], $owners), array_diff($array[0], $owners)];
->pluck('userid')
->toArray();
$recipients = [
array_intersect($recipients[0], $owners),
array_diff($recipients[0], $owners)
];
} }
//
// 发送推送 foreach ($array as $index => $item) {
foreach ($recipients as $index => $userids) {
if (empty($userids)) {
continue;
}
if ($index > 0) { if ($index > 0) {
$data['owner'] = 0; $data['owner'] = 0;
} }
$params = [ $params = [
'ignoreFd' => Request::header('fd'), 'ignoreFd' => Request::header('fd'),
'userid' => array_values($userids), 'userid' => array_values($item),
'msg' => [ 'msg' => [
'type' => 'project', 'type' => 'project',
'action' => $action, 'action' => $action,
'data' => $data, 'data' => $data,
] ]
]; ];
$task = new PushTask($params, false);
Task::deliver(new PushTask($params, false)); Task::deliver($task);
} }
} }
@ -510,38 +384,29 @@ class Project extends AbstractModel
$hasStart = false; $hasStart = false;
$hasEnd = false; $hasEnd = false;
$upTaskList = []; $upTaskList = [];
$projectUserids = $this->relationUserids();
foreach ($flows as $item) { foreach ($flows as $item) {
$id = intval($item['id']); $id = intval($item['id']);
$name = trim(str_replace('|', '·', $item['name']));
$turns = Base::arrayRetainInt($item['turns'] ?: [], true); $turns = Base::arrayRetainInt($item['turns'] ?: [], true);
$userids = Base::arrayRetainInt($item['userids'] ?: [], true); $userids = Base::arrayRetainInt($item['userids'] ?: [], true);
$usertype = trim($item['usertype']); $usertype = trim($item['usertype']);
$userlimit = intval($item['userlimit']); $userlimit = intval($item['userlimit']);
$columnid = intval($item['columnid']); $columnid = intval($item['columnid']);
if ($usertype == 'replace' && empty($userids)) { if ($usertype == 'replace' && empty($userids)) {
throw new ApiException("状态[{$name}]设置错误,设置流转模式时必须填写状态负责人"); throw new ApiException("状态[{$item['name']}]设置错误,设置流转模式时必须填写状态负责人");
} }
if ($usertype == 'merge' && empty($userids)) { if ($usertype == 'merge' && empty($userids)) {
throw new ApiException("状态[{$name}]设置错误,设置剔除模式时必须填写状态负责人"); throw new ApiException("状态[{$item['name']}]设置错误,设置剔除模式时必须填写状态负责人");
} }
if ($userlimit && empty($userids)) { if ($userlimit && empty($userids)) {
throw new ApiException("状态[{$name}]设置错误,设置限制负责人时必须填写状态负责人"); throw new ApiException("状态[{$item['name']}]设置错误,设置限制负责人时必须填写状态负责人");
}
foreach ($userids as $userid) {
if (!in_array($userid, $projectUserids)) {
$nickname = User::userid2nickname($userid);
throw new ApiException("状态[{$name}]设置错误,状态负责人[{$nickname}]不在项目成员内");
}
} }
$flow = ProjectFlowItem::updateInsert([ $flow = ProjectFlowItem::updateInsert([
'id' => $id, 'id' => $id,
'project_id' => $this->id, 'project_id' => $this->id,
'flow_id' => $projectFlow->id, 'flow_id' => $projectFlow->id,
], [ ], [
'name' => $name, 'name' => trim($item['name']),
'status' => trim($item['status']), 'status' => trim($item['status']),
'color' => trim($item['color']),
'sort' => intval($item['sort']), 'sort' => intval($item['sort']),
'turns' => $turns, 'turns' => $turns,
'userids' => $userids, 'userids' => $userids,
@ -561,7 +426,7 @@ class Project extends AbstractModel
$hasEnd = true; $hasEnd = true;
} }
if (!$isInsert) { if (!$isInsert) {
$upTaskList[$flow->id] = $flow->status . "|" . $flow->name . "|" . $flow->color; $upTaskList[$flow->id] = $flow->status . "|" . $flow->name;
} }
} }
} }
@ -605,38 +470,6 @@ 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 * @param $params
@ -653,10 +486,6 @@ class Project extends AbstractModel
$desc = trim(Arr::get($params, 'desc', '')); $desc = trim(Arr::get($params, 'desc', ''));
$flow = trim(Arr::get($params, 'flow', 'close')); $flow = trim(Arr::get($params, 'flow', 'close'));
$isPersonal = intval(Arr::get($params, 'personal')); $isPersonal = intval(Arr::get($params, 'personal'));
// 个人项目为系统自动创建,不受创建权限限制
if (!$isPersonal && !self::userCanCreate($userid)) {
return Base::retError('当前仅指定人员可以创建项目');
}
if (mb_strlen($name) < 2) { if (mb_strlen($name) < 2) {
return Base::retError('项目名称不可以少于2个字'); return Base::retError('项目名称不可以少于2个字');
} elseif (mb_strlen($name) > 32) { } elseif (mb_strlen($name) > 32) {
@ -710,7 +539,7 @@ class Project extends AbstractModel
$column['project_id'] = $project->id; $column['project_id'] = $project->id;
ProjectColumn::createInstance($column)->save(); ProjectColumn::createInstance($column)->save();
} }
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project', $project->userid); $dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project');
if (empty($dialog)) { if (empty($dialog)) {
throw new ApiException('创建项目聊天室失败'); throw new ApiException('创建项目聊天室失败');
} }
@ -718,7 +547,7 @@ class Project extends AbstractModel
$project->save(); $project->save();
// //
if ($flow == 'open') { if ($flow == 'open') {
$project->addFlow(Base::json2array('[{"id":-10,"name":"待处理","status":"start","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-11,"name":"进行中","status":"progress","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-12,"name":"待测试","status":"test","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-13,"name":"已完成","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-14,"name":"已取消","status":"end","color":"#999999","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0}]')); $project->addFlow(Base::json2array('[{"id":-10,"name":"待处理","status":"start","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-11,"name":"进行中","status":"progress","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-12,"name":"待测试","status":"test","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-13,"name":"已完成","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-14,"name":"已取消","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0}]'));
} }
}); });
// //
@ -732,9 +561,7 @@ class Project extends AbstractModel
* 获取项目信息(用于判断会员是否存在项目内) * 获取项目信息(用于判断会员是否存在项目内)
* @param int $project_id * @param int $project_id
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制 * @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
* @param null|bool|string $mustOwner true:负责人或项目管理员都可(共享操作); * @param null|bool $mustOwner true:仅限项目负责人, false:仅限非项目负责人, null:不限制
* 'primary':仅负责人(转让/删除/任命项目管理员等独占操作);
* false:仅限非负责人null:不限制
* @return self * @return self
*/ */
public static function userProject($project_id, $archived = true, $mustOwner = null) public static function userProject($project_id, $archived = true, $mustOwner = null)
@ -752,39 +579,9 @@ class Project extends AbstractModel
if ($mustOwner === true && !$project->owner) { if ($mustOwner === true && !$project->owner) {
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]); 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) { if ($mustOwner === false && $project->owner) {
throw new ApiException('禁止项目负责人操作', [ 'project_id' => $project_id ]); throw new ApiException('禁止项目负责人操作', [ 'project_id' => $project_id ]);
} }
return $project; 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);
}
} }

View File

@ -22,16 +22,10 @@ use Request;
* @property-read \App\Models\Project|null $project * @property-read \App\Models\Project|null $project
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ProjectTask> $projectTask * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ProjectTask> $projectTask
* @property-read int|null $project_task_count * @property-read int|null $project_task_count
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn onlyTrashed() * @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn whereColor($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn whereDeletedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn whereDeletedAt($value)
@ -74,9 +68,7 @@ class ProjectColumn extends AbstractModel
AbstractModel::transaction(function () use ($pushMsg) { AbstractModel::transaction(function () use ($pushMsg) {
$tasks = ProjectTask::whereColumnId($this->id)->get(); $tasks = ProjectTask::whereColumnId($this->id)->get();
foreach ($tasks as $task) { foreach ($tasks as $task) {
if(!$task->archived_at){ $task->deleteTask($pushMsg);
$task->deleteTask($pushMsg);
}
} }
$this->delete(); $this->delete();
$this->addLog("删除列表:" . $this->name); $this->addLog("删除列表:" . $this->name);
@ -127,7 +119,7 @@ class ProjectColumn extends AbstractModel
$userid = $this->project->relationUserids(); $userid = $this->project->relationUserids();
} }
$params = [ $params = [
'ignoreFd' => $action == 'recovery' ? 0 : Request::header('fd'), 'ignoreFd' => Request::header('fd'),
'userid' => $userid, 'userid' => $userid,
'msg' => [ 'msg' => [
'type' => 'projectColumn', 'type' => 'projectColumn',

View File

@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Module\Base;
/** /**
* App\Models\ProjectFlow * App\Models\ProjectFlow
* *
@ -12,15 +14,9 @@ namespace App\Models;
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ProjectFlowItem> $projectFlowItem * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ProjectFlowItem> $projectFlowItem
* @property-read int|null $project_flow_item_count * @property-read int|null $project_flow_item_count
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow whereName($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow whereName($value)

View File

@ -12,7 +12,6 @@ use App\Module\Base;
* @property int|null $flow_id 流程ID * @property int|null $flow_id 流程ID
* @property string|null $name 名称 * @property string|null $name 名称
* @property string|null $status 状态 * @property string|null $status 状态
* @property string|null $color 自定义颜色
* @property array $turns 可流转 * @property array $turns 可流转
* @property array $userids 状态负责人ID * @property array $userids 状态负责人ID
* @property string|null $usertype 流转模式 * @property string|null $usertype 流转模式
@ -22,16 +21,9 @@ use App\Module\Base;
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ProjectFlow|null $projectFlow * @property-read \App\Models\ProjectFlow|null $projectFlow
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColumnid($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColumnid($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereFlowId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereFlowId($value)

View File

@ -13,15 +13,9 @@ namespace App\Models;
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read bool $already * @property-read bool $already
* @property-read \App\Models\Project|null $project * @property-read \App\Models\Project|null $project
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereCode($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereCode($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereId($value)

View File

@ -11,7 +11,6 @@ use App\Module\Base;
* @property int|null $project_id 项目ID * @property int|null $project_id 项目ID
* @property int|null $column_id 列表ID * @property int|null $column_id 列表ID
* @property int|null $task_id 任务ID * @property int|null $task_id 任务ID
* @property int|null $task_only 仅任务日志0否1是
* @property int|null $userid 会员ID * @property int|null $userid 会员ID
* @property string|null $detail 详细信息 * @property string|null $detail 详细信息
* @property array $record 记录数据 * @property array $record 记录数据
@ -19,15 +18,9 @@ use App\Module\Base;
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ProjectTask|null $projectTask * @property-read \App\Models\ProjectTask|null $projectTask
* @property-read \App\Models\User|null $user * @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereColumnId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereColumnId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereDetail($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereDetail($value)
@ -35,16 +28,12 @@ use App\Module\Base;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereProjectId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereRecord($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereRecord($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereTaskId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereTaskOnly($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereUserid($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectLog whereUserid($value)
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class ProjectLog extends AbstractModel class ProjectLog extends AbstractModel
{ {
protected $hidden = [
'task_only',
];
/** /**
* @param $value * @param $value

View File

@ -1,211 +0,0 @@
<?php
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use App\Module\Doo;
/**
* App\Models\ProjectPermission
*
* @property int $id
* @property int|null $project_id 项目ID
* @property array $permissions 权限
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectPermission newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectPermission newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectPermission query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectPermission whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectPermission whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectPermission wherePermissions($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectPermission whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectPermission whereUpdatedAt($value)
* @mixin \Eloquent
*/
class ProjectPermission extends AbstractModel
{
const TASK_LIST_ADD = 'task_list_add'; // 添加列
const TASK_LIST_UPDATE = 'task_list_update'; // 修改列
const TASK_LIST_REMOVE = 'task_list_remove'; // 删除列
const TASK_LIST_SORT = 'task_list_sort'; // 列表排序
const TASK_ADD = 'task_add'; // 任务添加
const TASK_UPDATE = 'task_update'; // 任务更新
const TASK_TIME = 'task_time'; // 任务时间
const TASK_STATUS = 'task_status'; // 任务状态
const TASK_REMOVE = 'task_remove'; // 任务删除
const TASK_ARCHIVED = 'task_archived'; // 任务归档
const TASK_MOVE = 'task_move'; // 任务移动
// 权限列表
const PERMISSIONS = [
'project_leader' => 1, // 项目负责人
'project_member' => 2, // 项目成员
'task_leader' => 3, // 任务负责人
'task_assist' => 4, // 任务协助人
];
// 权限描述
const PERMISSIONS_DESC = [
1 => "项目负责人",
2 => "项目成员",
3 => "任务负责人",
4 => "任务协助人",
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = ['project_id', 'permissions'];
/**
* 权限
* @param $value
* @return array
*/
public function getPermissionsAttribute($value)
{
return Base::json2array($value);
}
/**
* 获取权限值
*
* @param int $projectId
* @param string $key
* @return object|array
*/
public static function getPermission($projectId, $key = '')
{
$projectPermission = self::initPermissions($projectId);
$currentPermissions = $projectPermission->permissions;
if ($key) {
if (!isset($currentPermissions[$key])) {
throw new ApiException('项目权限设置不存在');
}
return $currentPermissions[$key];
}
return $projectPermission;
}
/**
* 初始化项目权限
*
* @param int $projectId
* @return ProjectPermission
*/
public static function initPermissions($projectId)
{
$permissions = [
self::TASK_LIST_ADD => $projectTaskList = [self::PERMISSIONS['project_leader'], self::PERMISSIONS['project_member']],
self::TASK_LIST_UPDATE => $projectTaskList,
self::TASK_LIST_REMOVE => [self::PERMISSIONS['project_leader']],
self::TASK_LIST_SORT => $projectTaskList,
self::TASK_ADD => $projectTaskList,
self::TASK_UPDATE => $taskUpdate = [self::PERMISSIONS['project_leader'], self::PERMISSIONS['task_leader'], self::PERMISSIONS['task_assist']],
self::TASK_TIME => $taskUpdate,
self::TASK_STATUS => $taskStatus = [self::PERMISSIONS['project_leader'], self::PERMISSIONS['task_leader']],
self::TASK_REMOVE => $taskStatus,
self::TASK_ARCHIVED => $taskStatus,
self::TASK_MOVE => $taskStatus
];
return self::firstOrCreate(
['project_id' => $projectId],
['permissions' => Base::array2json($permissions)]
);
}
/**
* 更新项目权限
*
* @param int $projectId
* @param $newPermissions
* @return ProjectPermission
*/
public static function updatePermissions($projectId, $newPermissions)
{
$projectPermission = self::initPermissions($projectId);
$currentPermissions = $projectPermission->permissions;
$mergedPermissions = empty($newPermissions) ? $currentPermissions : array_merge($currentPermissions, $newPermissions);
$projectPermission->permissions = Base::array2json($mergedPermissions);
$projectPermission->save();
return $projectPermission;
}
/**
* 检查用户是否有执行特定动作的权限
* @param Project $project 项目实例
* @param string $action 动作名称
* @param ProjectTask|null $task 任务实例
* @return bool
*/
public static function userTaskPermission(Project $project, $action, ProjectTask $task = null)
{
$userid = User::userid();
$permissions = self::getPermission($project->id, $action);
switch ($action) {
// 任务添加,任务更新, 任务状态, 任务删除, 任务完成, 任务归档, 任务移动
case self::TASK_LIST_ADD:
case self::TASK_LIST_UPDATE:
case self::TASK_LIST_REMOVE:
case self::TASK_LIST_SORT:
case self::TASK_ADD:
case self::TASK_UPDATE:
case self::TASK_TIME:
case self::TASK_STATUS:
case self::TASK_REMOVE:
case self::TASK_ARCHIVED:
case self::TASK_MOVE:
$verify = false;
// 项目负责人
if (in_array(self::PERMISSIONS['project_leader'], $permissions)) {
if ($project->owner) {
$verify = true;
}
}
// 项目成员
if (!$verify && in_array(self::PERMISSIONS['project_member'], $permissions)) {
$user = ProjectUser::whereProjectId($project->id)->whereUserid(intval($userid))->first();
if (!empty($user)) {
$verify = true;
}
}
// 任务负责人
if (!$verify && $task && in_array(self::PERMISSIONS['task_leader'], $permissions)) {
if ($task->isOwner()) {
$verify = true;
}
}
// 任务协助人
if (!$verify && $task && in_array(self::PERMISSIONS['task_assist'], $permissions)) {
if ($task->isAssister()) {
$verify = true;
}
}
//
if (!$verify) {
$desc = [];
rsort($permissions);
foreach ($permissions as $permission) {
$desc[] = Doo::translate(self::PERMISSIONS_DESC[$permission]);
}
$desc = array_reverse($desc);
throw new ApiException(sprintf("仅限%s操作", implode('、', $desc)));
}
break;
}
return true;
}
}

View File

@ -1,67 +0,0 @@
<?php
namespace App\Models;
/**
* App\Models\ProjectTag
*
* @property int $id
* @property int $project_id 项目ID
* @property string $name 标签名称
* @property string|null $desc 标签描述
* @property string|null $color 颜色
* @property int $sort 排序
* @property int $userid 创建人
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Project $project
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereDesc($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereSort($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereUserid($value)
* @mixin \Eloquent
*/
class ProjectTag extends AbstractModel
{
protected $hidden = [
'updated_at',
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'project_id',
'name',
'desc',
'color',
'sort',
'userid'
];
/**
* 关联项目
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function project()
{
return $this->belongsTo(Project::class);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,154 +0,0 @@
<?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,
]);
}
}

View File

@ -3,7 +3,6 @@
namespace App\Models; namespace App\Models;
use App\Module\Base; use App\Module\Base;
use App\Exceptions\ApiException;
/** /**
* App\Models\ProjectTaskContent * App\Models\ProjectTaskContent
@ -11,33 +10,24 @@ use App\Exceptions\ApiException;
* @property int $id * @property int $id
* @property int|null $project_id 项目ID * @property int|null $project_id 项目ID
* @property int|null $task_id 任务ID * @property int|null $task_id 任务ID
* @property int|null $userid 用户ID
* @property string|null $desc 内容描述
* @property string|null $content 内容 * @property string|null $content 内容
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereContent($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereContent($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereDesc($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereProjectId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereTaskId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent whereUserid($value)
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class ProjectTaskContent extends AbstractModel class ProjectTaskContent extends AbstractModel
{ {
protected $hidden = [ protected $hidden = [
'created_at',
'updated_at', 'updated_at',
]; ];
@ -53,8 +43,8 @@ class ProjectTaskContent extends AbstractModel
$array = $this->toArray(); $array = $this->toArray();
$array['content'] = file_get_contents($filePath) ?: ''; $array['content'] = file_get_contents($filePath) ?: '';
if ($array['content']) { if ($array['content']) {
$replace = Base::fillUrl('uploads'); $replace = Base::fillUrl('uploads/task');
$array['content'] = str_replace('{{RemoteURL}}uploads', $replace, $array['content']); $array['content'] = str_replace('{{RemoteURL}}uploads/task', $replace, $array['content']);
} }
return $array; return $array;
} }
@ -69,12 +59,9 @@ class ProjectTaskContent extends AbstractModel
*/ */
public static function saveContent($task_id, $content) public static function saveContent($task_id, $content)
{ {
@ini_set("pcre.backtrack_limit", 999999999);
//
$oldContent = $content;
$path = 'uploads/task/content/' . date("Ym") . '/' . $task_id . '/'; $path = 'uploads/task/content/' . date("Ym") . '/' . $task_id . '/';
// //
preg_match_all('/<img[^>]*?src=\\\\?["\']data:image\/(png|jpg|jpeg|webp);base64,(.*?)\\\\?["\']/s', $content, $matchs); preg_match_all("/<img\s+src=\"data:image\/(png|jpg|jpeg|webp);base64,(.*?)\"/s", $content, $matchs);
foreach ($matchs[2] as $key => $text) { foreach ($matchs[2] as $key => $text) {
$tmpPath = $path . 'attached/'; $tmpPath = $path . 'attached/';
Base::makeDir(public_path($tmpPath)); Base::makeDir(public_path($tmpPath));
@ -84,22 +71,13 @@ class ProjectTaskContent extends AbstractModel
$content = str_replace($matchs[0][$key], '<img src="{{RemoteURL}}' . $tmpPath . '" original-width="' . $paramet[0] . '" original-height="' . $paramet[1] . '"', $content); $content = str_replace($matchs[0][$key], '<img src="{{RemoteURL}}' . $tmpPath . '" original-width="' . $paramet[0] . '" original-height="' . $paramet[1] . '"', $content);
} }
} }
preg_match_all('/(<img[^>]*?src=\\\\?["\'])(https?:\/\/[^\/]+\/)(uploads\/[^\s"\'>]+)(\\\\?["\'][^>]*?>)/i', $content, $matches); $pattern = '/<img(.*?)src=("|\')https*:\/\/(.*?)\/(uploads\/task\/content\/(.*?))\2/is';
foreach ($matches[0] as $key => $fullMatch) { $content = preg_replace($pattern, '<img$1src=$2{{RemoteURL}}$4$2', $content);
$filePath = public_path($matches[3][$key]);
if (file_exists($filePath)) {
$replacement = $matches[1][$key] . '{{RemoteURL}}' . $matches[3][$key] . $matches[4][$key];
$content = str_replace($fullMatch, $replacement, $content);
}
}
// //
$filePath = $path . md5($content); $filePath = $path . md5($content);
$publicPath = public_path($filePath); $publicPath = public_path($filePath);
Base::makeDir(dirname($publicPath)); Base::makeDir(dirname($publicPath));
$result = file_put_contents($publicPath, $content); file_put_contents($publicPath, $content);
if(!$result && $oldContent){
throw new ApiException("保存任务详情至文件失败,请重试");
}
// //
return $filePath; return $filePath;
} }

View File

@ -22,15 +22,9 @@ use Cache;
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read int $height * @property-read int $height
* @property-read int $width * @property-read int $width
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile whereDownload($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile whereDownload($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile whereExt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile whereExt($value)

View File

@ -14,15 +14,9 @@ namespace App\Models;
* @property string|null $after_flow_item_name (变化后)工作流状态名称 * @property string|null $after_flow_item_name (变化后)工作流状态名称
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange whereAfterFlowItemId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange whereAfterFlowItemId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange whereAfterFlowItemName($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange whereAfterFlowItemName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange whereBeforeFlowItemId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange whereBeforeFlowItemId($value)

View File

@ -17,16 +17,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at * @property \Illuminate\Support\Carbon|null $deleted_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog onlyTrashed() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog whereDeletedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog whereId($value)

View File

@ -1,223 +0,0 @@
<?php
namespace App\Models;
use App\Module\Base;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ProjectTaskRelation
*
* @property int $id
* @property int $task_id 任务ID
* @property int $related_task_id 关联任务ID
* @property string $direction 关系方向: mention/mentioned_by
* @property int|null $dialog_id 来源会话ID
* @property int|null $msg_id 来源消息ID
* @property int|null $userid 提及人
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ProjectTask|null $relatedTask
* @property-read \App\Models\ProjectTask|null $task
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereDialogId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereDirection($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereMsgId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereRelatedTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereUserid($value)
* @mixin \Eloquent
*/
class ProjectTaskRelation extends AbstractModel
{
public const DIRECTION_MENTION = 'mention';
public const DIRECTION_MENTIONED_BY = 'mentioned_by';
protected $fillable = [
'task_id',
'related_task_id',
'direction',
'dialog_id',
'msg_id',
'userid',
];
public function task(): BelongsTo
{
return $this->belongsTo(ProjectTask::class, 'task_id');
}
public function relatedTask(): BelongsTo
{
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') {
return;
}
$payload = $msg->msg;
if (!is_array($payload)) {
$payload = Base::json2array($msg->getRawOriginal('msg'));
}
$text = $payload['text'] ?? '';
if (!$text || !preg_match_all('/<span class="mention task" data-id="(\d+)">#?(.*?)<\/span>/i', $text, $matches)) {
return;
}
$targetIds = array_values(array_unique(array_filter(array_map('intval', $matches[1] ?? []))));
if (empty($targetIds)) {
return;
}
$sourceTaskIds = ProjectTask::whereDialogId($msg->dialog_id)
->whereNull('deleted_at')
->pluck('id')
->toArray();
if (empty($sourceTaskIds)) {
return;
}
foreach ($sourceTaskIds as $sourceTaskId) {
foreach ($targetIds as $targetId) {
self::createRelation(
$sourceTaskId,
$targetId,
$msg->dialog_id,
$msg->id,
$msg->userid
);
}
}
}
}

View File

@ -12,15 +12,9 @@ namespace App\Models;
* @property string|null $color 颜色 * @property string|null $color 颜色
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereColor($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereId($value)

View File

@ -1,103 +0,0 @@
<?php
namespace App\Models;
/**
* App\Models\ProjectTaskTemplate
*
* @property int $id
* @property int $project_id 项目ID
* @property string $name 模板名称
* @property string|null $title 任务标题
* @property string|null $content 任务内容
* @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
* @property-read \App\Models\User $user
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereContent($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereIsDefault($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereSort($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereUserid($value)
* @mixin \Eloquent
*/
class ProjectTaskTemplate extends AbstractModel
{
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'project_id',
'name',
'title',
'content',
'sort',
'is_default',
'userid',
'use_count',
'last_used_at'
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'last_used_at' => 'datetime',
];
/**
* 关联项目
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function project()
{
return $this->belongsTo(Project::class);
}
/**
* 关联创建者
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
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(),
]);
}
}

View File

@ -14,15 +14,9 @@ namespace App\Models;
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ProjectTask|null $projectTask * @property-read \App\Models\ProjectTask|null $projectTask
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser whereOwner($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskUser whereOwner($value)
@ -52,7 +46,7 @@ class ProjectTaskUser extends AbstractModel
*/ */
public static function transfer($originalUserid, $newUserid) public static function transfer($originalUserid, $newUserid)
{ {
self::whereUserid($originalUserid)->chunkById(100, function ($list) use ($originalUserid, $newUserid) { self::whereUserid($originalUserid)->chunk(100, function ($list) use ($originalUserid, $newUserid) {
$tastIds = []; $tastIds = [];
/** @var self $item */ /** @var self $item */
foreach ($list as $item) { foreach ($list as $item) {
@ -68,18 +62,7 @@ class ProjectTaskUser extends AbstractModel
$item->save(); $item->save();
} }
if ($item->projectTask) { if ($item->projectTask) {
$item->projectTask->addLog("移交{任务}身份", [ $item->projectTask->addLog("移交{任务}身份", ['userid' => [$originalUserid, ' => ', $newUserid]]);
'change' => [
[
'type' => 'user',
'data' => $originalUserid,
],
[
'type' => 'user',
'data' => $newUserid,
]
],
], 0, 1);
if (!in_array($item->task_pid, $tastIds)) { if (!in_array($item->task_pid, $tastIds)) {
$tastIds[] = $item->task_pid; $tastIds[] = $item->task_pid;
$item->projectTask->syncDialogUser(); $item->projectTask->syncDialogUser();

View File

@ -1,43 +0,0 @@
<?php
namespace App\Models;
/**
* App\Models\ProjectTaskVisibilityUser
*
* @property int $id
* @property int|null $project_id 项目ID
* @property int|null $task_id 任务ID
* @property int|null $userid 成员ID
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ProjectTask|null $projectTask
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskVisibilityUser newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskVisibilityUser newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskVisibilityUser query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskVisibilityUser whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskVisibilityUser whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskVisibilityUser whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskVisibilityUser whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskVisibilityUser whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskVisibilityUser whereUserid($value)
* @mixin \Eloquent
*/
class ProjectTaskVisibilityUser extends AbstractModel
{
/**
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function projectTask(): \Illuminate\Database\Eloquent\Relations\HasOne
{
return $this->hasOne(ProjectTask::class, 'id', 'task_id');
}
}

View File

@ -2,8 +2,6 @@
namespace App\Models; namespace App\Models;
use App\Module\Base;
/** /**
* App\Models\ProjectUser * App\Models\ProjectUser
* *
@ -11,25 +9,17 @@ use App\Module\Base;
* @property int|null $project_id 项目ID * @property int|null $project_id 项目ID
* @property int|null $userid 成员ID * @property int|null $userid 成员ID
* @property int|null $owner 是否负责人 * @property int|null $owner 是否负责人
* @property \Illuminate\Support\Carbon|null $top_at 置顶时间 * @property string|null $top_at 置顶时间
* @property int|null $sort 排序(ASC)
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Project|null $project * @property-read \App\Models\Project|null $project
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectUser newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ProjectUser newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser query() * @method static \Illuminate\Database\Eloquent\Builder|ProjectUser query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereOwner($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereOwner($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereProjectId($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereSort($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereTopAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereTopAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUserid($value) * @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUserid($value)
@ -37,36 +27,6 @@ use App\Module\Base;
*/ */
class ProjectUser extends AbstractModel 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 * @return \Illuminate\Database\Eloquent\Relations\HasOne
@ -84,26 +44,17 @@ class ProjectUser extends AbstractModel
*/ */
public static function transfer($originalUserid, $newUserid) public static function transfer($originalUserid, $newUserid)
{ {
$projectIds = []; self::whereUserid($originalUserid)->chunkById(100, function ($list) use ($originalUserid, $newUserid) {
// 移交项目身份
self::whereUserid($originalUserid)->chunkById(100, function ($list) use ($originalUserid, $newUserid, &$projectIds) {
/** @var self $item */ /** @var self $item */
foreach ($list as $item) { foreach ($list as $item) {
$row = self::whereProjectId($item->project_id)->whereUserid($newUserid)->first(); $row = self::whereProjectId($item->project_id)->whereUserid($newUserid)->first();
if ($row) { if ($row) {
// 已存在仅当离职用户是项目负责人owner=1时把接收人升为项目负责人 // 已存在则删除原数据,判断改变已存在的数据
// 离职用户是项目管理员owner=2时不传项目管理员身份给接收人spec项目管理员不替补 $row->owner = max($row->owner, $item->owner);
if ((int)$item->owner === self::OWNER_PRIMARY) {
$row->owner = self::OWNER_PRIMARY;
}
// owner=2/0保留接收人原有 owner 值不变
$row->save(); $row->save();
$item->delete(); $item->delete();
} else { } else {
// 不存在:转移时如果离职用户是项目管理员,降级为普通成员(不带项目管理员身份过户给接收人) // 不存在则改变原数据
if ((int)$item->owner === self::OWNER_DEPUTY) {
$item->owner = self::OWNER_MEMBER;
}
$item->userid = $newUserid; $item->userid = $newUserid;
$item->save(); $item->save();
} }
@ -113,36 +64,11 @@ class ProjectUser extends AbstractModel
$item->project->name = "{$name}{$item->project->name}"; $item->project->name = "{$name}{$item->project->name}";
$item->project->save(); $item->project->save();
} }
$item->project->addLog("移交项目身份", [ $item->project->addLog("移交项目身份", ['userid' => [$originalUserid, ' => ', $newUserid]]);
'change' => [
[
'type' => 'user',
'data' => $originalUserid
],
[
'type' => 'user',
'data' => $newUserid
],
],
]);
$item->project->syncDialogUser(); $item->project->syncDialogUser();
$projectIds[] = $item->project_id;
} }
} }
}); });
// 移交工作流状态负责人
if ($projectIds) {
ProjectFlowItem::whereIn('project_id', $projectIds)->chunkById(100, function ($list) use ($originalUserid, $newUserid) {
/** @var ProjectFlowItem $item */
foreach ($list as $item) {
if (in_array($originalUserid, $item->userids)) {
$userids = array_values(array_diff($item->userids, [$originalUserid]));
$item->userids = Base::array2json(array_merge($userids, [$newUserid]));
$item->save();
}
}
});
}
} }
/** /**

View File

@ -3,7 +3,6 @@
namespace App\Models; namespace App\Models;
use App\Exceptions\ApiException; use App\Exceptions\ApiException;
use App\Module\Base;
use Carbon\Carbon; use Carbon\Carbon;
use Carbon\Traits\Creator; use Carbon\Traits\Creator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@ -11,7 +10,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use JetBrains\PhpStorm\Pure; use JetBrains\PhpStorm\Pure;
/** /**
@ -27,22 +25,13 @@ use JetBrains\PhpStorm\Pure;
* @property string $sign 汇报唯一标识 * @property string $sign 汇报唯一标识
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ReportReceive> $Receives * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ReportReceive> $Receives
* @property-read int|null $receives_count * @property-read int|null $receives_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ReportAnalysis> $aiAnalyses
* @property-read int|null $ai_analyses_count
* @property-read \App\Models\ReportAnalysis|null $aiAnalysis
* @property-read mixed $receives * @property-read mixed $receives
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $receivesUser * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $receivesUser
* @property-read int|null $receives_user_count * @property-read int|null $receives_user_count
* @property-read \App\Models\User|null $sendUser * @property-read \App\Models\User|null $sendUser
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static Builder|Report newModelQuery() * @method static Builder|Report newModelQuery()
* @method static Builder|Report newQuery() * @method static Builder|Report newQuery()
* @method static Builder|Report query() * @method static Builder|Report query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static Builder|Report whereContent($value) * @method static Builder|Report whereContent($value)
* @method static Builder|Report whereCreatedAt($value) * @method static Builder|Report whereCreatedAt($value)
* @method static Builder|Report whereId($value) * @method static Builder|Report whereId($value)
@ -59,15 +48,6 @@ class Report extends AbstractModel
const WEEKLY = "weekly"; const WEEKLY = "weekly";
const DAILY = "daily"; const DAILY = "daily";
public const LIST_FIELDS = [
'id',
'title',
'type',
'userid',
'sign',
'created_at',
'updated_at',
];
protected $fillable = [ protected $fillable = [
"title", "title",
@ -88,17 +68,7 @@ class Report extends AbstractModel
public function receivesUser(): BelongsToMany public function receivesUser(): BelongsToMany
{ {
return $this->belongsToMany(User::class, ReportReceive::class, "rid", "userid") return $this->belongsToMany(User::class, ReportReceive::class, "rid", "userid")
->withPivot("receive_at", "read"); ->withPivot("receive_time", "read");
}
public function aiAnalyses(): HasMany
{
return $this->hasMany(ReportAnalysis::class, 'rid');
}
public function aiAnalysis(): HasOne
{
return $this->hasOne(ReportAnalysis::class, 'rid');
} }
public function sendUser() public function sendUser()
@ -106,6 +76,15 @@ class Report extends AbstractModel
return $this->hasOne(User::class, "userid", "userid"); return $this->hasOne(User::class, "userid", "userid");
} }
public function getTypeAttribute($value): string
{
return match ($value) {
Report::WEEKLY => "周报",
Report::DAILY => "日报",
default => "",
};
}
public function getContentAttribute($value): string public function getContentAttribute($value): string
{ {
return htmlspecialchars_decode($value); return htmlspecialchars_decode($value);
@ -119,24 +98,6 @@ class Report extends AbstractModel
return $this->appendattrs['receives']; return $this->appendattrs['receives'];
} }
/**
* 获取汇报内容
* @param $id
* @return self|null
*/
public static function idOrCodeToContent($id)
{
if (Base::isNumber($id)) {
return self::find($id);
} elseif ($id) {
$reportLink = ReportLink::whereCode($id)->first();
if ($reportLink) {
return self::find($reportLink->rid);
}
}
return null;
}
/** /**
* 获取单条记录 * 获取单条记录
* @param $id * @param $id
@ -181,12 +142,12 @@ class Report extends AbstractModel
// 如果设置了周期偏移量 // 如果设置了周期偏移量
empty( $offset ) || $now_dt->subWeeks( abs( $offset ) ); empty( $offset ) || $now_dt->subWeeks( abs( $offset ) );
$now_dt->startOfWeek(); // 设置为当周第一天 $now_dt->startOfWeek(); // 设置为当周第一天
return now()->year . $now_dt->weekOfYear; return $now_dt->year . $now_dt->weekOfYear;
}, },
Report::DAILY => function() use ($now_dt, $offset) { Report::DAILY => function() use ($now_dt, $offset) {
// 如果设置了周期偏移量 // 如果设置了周期偏移量
empty( $offset ) || $now_dt->subDays( abs( $offset ) ); empty( $offset ) || $now_dt->subDays( abs( $offset ) );
return now()->format("Ymd"); return $now_dt->format("Ymd");
}, },
default => "", default => "",
}; };

View File

@ -1,58 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ReportAnalysis
*
* @property int $id
* @property int $rid 报告ID
* @property int $userid 生成分析的会员ID
* @property string $model 使用的模型名称
* @property string $analysis_text AI 分析的原始文本Markdown
* @property array|null $meta 额外的上下文信息
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Report|null $report
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereAnalysisText($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereMeta($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereModel($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereRid($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereUserid($value)
* @mixin \Eloquent
*/
class ReportAnalysis extends AbstractModel
{
protected $table = 'report_ai_analyses';
protected $fillable = [
'rid',
'userid',
'model',
'analysis_text',
'meta',
];
protected $casts = [
'meta' => 'array',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class, 'rid');
}
}

View File

@ -1,86 +0,0 @@
<?php
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
/**
* App\Models\ReportLink
*
* @property int $id
* @property int|null $rid 报告ID
* @property int|null $num 累计访问
* @property string|null $code 链接码
* @property int|null $userid 会员ID
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Report|null $report
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereCode($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereNum($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereRid($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereUserid($value)
* @mixin \Eloquent
*/
class ReportLink extends AbstractModel
{
/**
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function report(): \Illuminate\Database\Eloquent\Relations\HasOne
{
return $this->hasOne(Report::class, 'id', 'report_id');
}
/**
* 生成链接
* @param $rid
* @param $userid
* @param $refresh
* @return array
*/
public static function generateLink($rid, $userid, $refresh = false)
{
$report = Report::find($rid);
if (empty($report)) {
throw new ApiException('报告不存在或已被删除');
}
if ($report->userid != $userid) {
if (!ReportReceive::whereRid($rid)->whereUserid($userid)->exists()) {
throw new ApiException('您没有权限查看该报告');
}
}
$reportLink = ReportLink::whereRid($rid)->whereUserid($userid)->first();
if (empty($reportLink)) {
$reportLink = ReportLink::createInstance([
'rid' => $rid,
'userid' => $userid,
'code' => base64_encode("{$rid},{$userid}," . Base::generatePassword()),
]);
$reportLink->save();
} else {
if ($refresh == 'yes') {
$reportLink->code = base64_encode("{$rid},{$userid}," . Base::generatePassword());
$reportLink->save();
}
}
return [
'id' => $rid,
'url' => Base::fillUrl('single/report/detail/' . $reportLink->code),
'code' => $reportLink->code,
'num' => $reportLink->num
];
}
}

View File

@ -10,21 +10,15 @@ use Illuminate\Database\Eloquent\Model;
* *
* @property int $id * @property int $id
* @property int $rid * @property int $rid
* @property \Illuminate\Support\Carbon|null $receive_at 接收时间 * @property string|null $receive_time 接收时间
* @property int $userid 接收人 * @property int $userid 接收人
* @property int $read 是否已读 * @property int $read 是否已读
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ReportReceive newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive newQuery() * @method static \Illuminate\Database\Eloquent\Builder|ReportReceive newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive query() * @method static \Illuminate\Database\Eloquent\Builder|ReportReceive query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereRead($value) * @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereRead($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereReceiveAt($value) * @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereReceiveTime($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereRid($value) * @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereRid($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereUserid($value) * @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereUserid($value)
* @mixin \Eloquent * @mixin \Eloquent
@ -38,7 +32,7 @@ class ReportReceive extends AbstractModel
protected $fillable = [ protected $fillable = [
"rid", "rid",
"receive_at", "receive_time",
"userid", "userid",
"read", "read",
]; ];

View File

@ -2,12 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base; use App\Module\Base;
use App\Module\Doo;
use App\Module\Timer;
use App\Module\AI;
use Carbon\Carbon;
/** /**
* App\Models\Setting * App\Models\Setting
@ -15,18 +10,12 @@ use Carbon\Carbon;
* @property int $id * @property int $id
* @property string|null $name * @property string|null $name
* @property string|null $desc 参数描述、备注 * @property string|null $desc 参数描述、备注
* @property array $setting * @property string|null $setting
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|Setting newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Setting newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Setting newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Setting newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Setting query() * @method static \Illuminate\Database\Eloquent\Builder|Setting query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Setting whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereDesc($value) * @method static \Illuminate\Database\Eloquent\Builder|Setting whereDesc($value)
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|Setting whereId($value)
@ -37,412 +26,6 @@ use Carbon\Carbon;
*/ */
class Setting extends AbstractModel class Setting extends AbstractModel
{ {
/**
* 格式化设置参数
* @param $value
* @return array
*/
public function getSettingAttribute($value)
{
if (is_array($value)) {
return $value;
}
$value = Base::json2array($value);
switch ($this->name) {
// 系统设置
case 'system':
$value['system_alias'] = $value['system_alias'] ?: env('APP_NAME');
$value['image_compress'] = $value['image_compress'] ?: 'open';
$value['image_quality'] = min(100, max(0, intval($value['image_quality']) ?: 90));
$value['image_save_local'] = $value['image_save_local'] ?: 'open';
$value['task_user_limit'] = min(2000, max(1, intval($value['task_user_limit']) ?: 500));
if (!is_array($value['task_default_time']) || count($value['task_default_time']) != 2 || !Timer::isTime($value['task_default_time'][0]) || !Timer::isTime($value['task_default_time'][1])) {
$value['task_default_time'] = ['09:00', '18:00'];
}
// 项目创建权限范围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;
// 文件设置
case 'fileSetting':
$value['permission_pack_type'] = $value['permission_pack_type'] ?: 'all';
$value['permission_pack_userids'] = is_array($value['permission_pack_userids']) ? $value['permission_pack_userids'] : [];
break;
// AI 机器人设置
case 'aibotSetting':
if (!empty($value['claude_token']) && empty($value['claude_key'])) {
$value['claude_key'] = $value['claude_token'];
}
$array = [];
$aiList = ['openai', 'claude', 'deepseek', 'gemini', 'grok', 'ollama', 'zhipu', 'qianwen', 'wenxin'];
$fieldList = ['key', 'secret', 'models', 'model', 'base_url', 'agency', 'temperature', 'system'];
foreach ($aiList as $aiName) {
foreach ($fieldList as $fieldName) {
$key = $aiName . '_' . $fieldName;
$content = !empty($value[$key]) ? trim($value[$key]) : '';
switch ($fieldName) {
case 'models':
if ($content) {
$content = explode("\n", $content);
$content = array_filter($content);
}
$content = is_array($content) ? implode("\n", $content) : '';
break;
case 'model':
$models = Setting::AIBotModels2Array($array[$key . 's'], true);
$content = in_array($content, $models) ? $content : ($models[0] ?? '');
break;
case 'temperature':
if ($content) {
$content = floatval(min(1, max(0, floatval($content) ?: 0.7)));
}
break;
}
$array[$key] = $content;
}
}
$value = $array;
break;
}
return $value;
}
/**
* 规范任务优先级设置(确保字段完整且仅有一个默认项)
* @param mixed $list
* @return array<int, array{name:string,color:string,days:int,priority:int,is_default:int}>
*/
public static function normalizeTaskPriorityList($list)
{
if (!is_array($list)) {
return [];
}
$normalized = [];
$defaultIndex = null;
foreach ($list as $item) {
if (!is_array($item)) {
continue;
}
$name = trim((string)($item['name'] ?? ''));
$color = trim((string)($item['color'] ?? ''));
$priority = intval($item['priority'] ?? 0);
if ($name === '' || $color === '' || $priority <= 0) {
continue;
}
$days = intval($item['days'] ?? 0);
$isDefault = !empty($item['is_default']) || !empty($item['default']);
if ($defaultIndex === null && $isDefault) {
$defaultIndex = count($normalized);
}
$normalized[] = [
'name' => $name,
'color' => $color,
'days' => $days,
'priority' => $priority,
'is_default' => $isDefault ? 1 : 0,
];
}
if (!empty($normalized)) {
$defaultIndex = $defaultIndex ?? 0;
foreach ($normalized as $i => $row) {
$normalized[$i]['is_default'] = $i === $defaultIndex ? 1 : 0;
}
}
return array_values($normalized);
}
/**
* 获取默认任务优先级(来自 settings.priority
* @param array|null $list
* @return array|null
*/
public static function getDefaultTaskPriorityItem($list = null)
{
$list = $list ?? Base::setting('priority');
$list = self::normalizeTaskPriorityList($list);
if (empty($list)) {
return null;
}
foreach ($list as $item) {
if (!empty($item['is_default'])) {
return $item;
}
}
return $list[0];
}
/**
* 是否开启 AI 助手
* @return bool
*/
public static function AIOpen()
{
$setting = Base::setting('aibotSetting');
if (!is_array($setting) || empty($setting)) {
return false;
}
foreach (AI::TEXT_MODEL_PRIORITY as $vendor) {
if (self::isAIBotVendorEnabled($setting, $vendor)) {
return true;
}
}
return false;
}
/**
* 判断 AI 机器人厂商是否启用
* @param array $setting
* @param string $vendor
* @return bool
*/
protected static function isAIBotVendorEnabled(array $setting, string $vendor): bool
{
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
return match ($vendor) {
'ollama' => $key !== '' || !empty($setting['ollama_base_url']),
default => $key !== '',
};
}
/**
* AI 机器人模型转数组
* @param $models
* @param bool $retValue
* @return array
*/
public static function AIBotModels2Array($models, $retValue = false)
{
$list = is_array($models) ? $models : explode("\n", $models);
$array = [];
foreach ($list as $item) {
$arr = Base::newTrim(explode('|', $item . '|'));
if ($arr[0]) {
$array[] = [
'value' => $arr[0],
'label' => $arr[1] ?: $arr[0]
];
}
}
if ($retValue) {
return array_column($array, 'value');
}
return $array;
}
/**
* 规范自定义微应用配置
* @param array $list
* @return array
*/
public static function normalizeCustomMicroApps($list)
{
if (!is_array($list)) {
return [];
}
$apps = [];
foreach ($list as $item) {
$app = self::normalizeCustomMicroAppItem($item);
if ($app) {
$apps[] = $app;
}
}
return $apps;
}
/**
* 根据用户身份过滤可见的自定义微应用
* @param array $apps
* @param \App\Models\User|null $user
* @return array
*/
public static function filterCustomMicroAppsForUser(array $apps, $user)
{
if (empty($apps)) {
return [];
}
$isAdmin = $user ? $user->isAdmin() : false;
$userId = $user ? intval($user->userid) : 0;
$filtered = [];
foreach ($apps as $app) {
$visible = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']);
if (!self::isCustomMicroVisibleTo($visible, $isAdmin, $userId)) {
continue;
}
if (empty($app['menu_items']) || !is_array($app['menu_items'])) {
continue;
}
$menus = array_values(array_filter($app['menu_items'], function ($menu) use ($isAdmin, $userId) {
if (!isset($menu['visible_to'])) {
return true;
}
$visible = self::normalizeCustomMicroVisible($menu['visible_to']);
return self::isCustomMicroVisibleTo($visible, $isAdmin, $userId);
}));
if (empty($menus)) {
continue;
}
$app['menu_items'] = $menus;
$filtered[] = $app;
}
return $filtered;
}
/**
* 将存储结构转换成 appstore 接口同款格式
* @param array $apps
* @return array
*/
public static function formatCustomMicroAppsForResponse(array $apps)
{
return array_values(array_map(function ($app) {
unset($app['visible_to']);
if (!empty($app['menu_items']) && is_array($app['menu_items'])) {
$app['menu_items'] = array_values(array_map(function ($menu) {
$menu['keep_alive'] = isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true;
$menu['disable_scope_css'] = (bool)($menu['disable_scope_css'] ?? false);
$menu['auto_dark_theme'] = isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true;
$menu['transparent'] = (bool)($menu['transparent'] ?? false);
if (isset($menu['visible_to'])) {
unset($menu['visible_to']);
}
return $menu;
}, $app['menu_items']));
}
return $app;
}, $apps));
}
/**
* 规范自定义微应用
* @param array $item
* @return array|null
*/
protected static function normalizeCustomMicroAppItem($item)
{
if (!is_array($item)) {
return null;
}
$id = trim($item['id'] ?? '');
if ($id === '') {
return null;
}
$name = Base::newTrim($item['name'] ?? '');
$version = Base::newTrim($item['version'] ?? '') ?: 'custom';
$menuItems = [];
if (isset($item['menu_items']) && is_array($item['menu_items'])) {
$menuItems = $item['menu_items'];
} elseif (isset($item['menu']) && is_array($item['menu'])) {
$menuItems = [$item['menu']];
}
if (empty($menuItems)) {
return null;
}
$normalizedMenus = [];
foreach ($menuItems as $menu) {
$formattedMenu = self::normalizeCustomMicroMenuItem($menu, $name ?: $id);
if ($formattedMenu) {
$normalizedMenus[] = $formattedMenu;
}
}
if (empty($normalizedMenus)) {
return null;
}
return Base::newTrim([
'id' => $id,
'name' => $name,
'version' => $version,
'menu_items' => $normalizedMenus,
'visible_to' => self::normalizeCustomMicroVisible($item['visible_to'] ?? 'admin'),
]);
}
/**
* 规范自定义微应用菜单项
* @param array $menu
* @param string $fallbackLabel
* @return array|null
*/
protected static function normalizeCustomMicroMenuItem($menu, $fallbackLabel = '')
{
if (!is_array($menu)) {
return null;
}
$url = trim($menu['url'] ?? '');
if ($url === '') {
return null;
}
$location = trim($menu['location'] ?? 'application');
$label = trim($menu['label'] ?? $fallbackLabel);
$type = strtolower(trim($menu['type'] ?? 'iframe'));
$payload = [
'location' => $location,
'label' => $label,
'icon' => Base::newTrim($menu['icon'] ?? ''),
'url' => $url,
'type' => $type,
'keep_alive' => isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true,
'disable_scope_css' => (bool)($menu['disable_scope_css'] ?? false),
'auto_dark_theme' => isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true,
'transparent' => (bool)($menu['transparent'] ?? false),
];
if (!empty($menu['background'])) {
$payload['background'] = Base::newTrim($menu['background']);
}
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
$payload['capsule'] = Base::newTrim($menu['capsule']);
}
return $payload;
}
/**
* 规范自定义微应用可见范围
* @param mixed $value
* @return array
*/
protected static function normalizeCustomMicroVisible($value)
{
if (is_array($value)) {
$list = array_filter(array_map('trim', $value));
} else {
$list = array_filter(array_map('trim', explode(',', (string)$value)));
}
if (empty($list)) {
return ['admin'];
}
if (in_array('all', $list)) {
return ['all'];
}
return array_values($list);
}
/**
* 判断自定义微应用是否可见
* @param array $visible
* @param bool $isAdmin
* @param int $userId
* @return bool
*/
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
{
if (in_array('all', $visible)) {
return true;
}
if ($isAdmin && in_array('admin', $visible)) {
return true;
}
if ($userId > 0 && in_array((string)$userId, $visible, true)) {
return true;
}
return false;
}
/** /**
* 验证邮箱地址(过滤忽略地址) * 验证邮箱地址(过滤忽略地址)
* @param $array * @param $array
@ -479,36 +62,4 @@ class Setting extends AbstractModel
} }
return $array; return $array;
} }
/**
* 验证消息限制
* @param $type
* @param $msg
* @return void
*/
public static function validateMsgLimit($type, $msg)
{
$keyName = 'msg_edit_limit';
$error = '此消息不可修改';
if ($type == 'rev') {
$keyName = 'msg_rev_limit';
$error = '此消息不可撤回';
}
$limitNum = intval(Base::settingFind('system', $keyName, 0));
if ($limitNum <= 0) {
return;
}
if ($msg instanceof WebSocketDialogMsg) {
$dialogMsg = $msg;
} else {
$dialogMsg = WebSocketDialogMsg::find($msg);
}
if (!$dialogMsg) {
return;
}
$limitTime = Carbon::parse($dialogMsg->created_at)->addMinutes($limitNum);
if ($limitTime->lt(Carbon::now())) {
throw new ApiException('已超过' . Base::forumMinuteDay($limitNum) . '' . $error);
}
}
} }

View File

@ -10,21 +10,15 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int $id * @property int $id
* @property string|null $args * @property string|null $args
* @property string|null $error * @property string|null $error
* @property \Illuminate\Support\Carbon|null $start_at 开始时间 * @property string|null $start_at 开始时间
* @property \Illuminate\Support\Carbon|null $end_at 结束时间 * @property string|null $end_at 结束时间
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at * @property \Illuminate\Support\Carbon|null $deleted_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|TaskWorker newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|TaskWorker newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|TaskWorker newQuery() * @method static \Illuminate\Database\Eloquent\Builder|TaskWorker newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|TaskWorker onlyTrashed() * @method static \Illuminate\Database\Eloquent\Builder|TaskWorker onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|TaskWorker query() * @method static \Illuminate\Database\Eloquent\Builder|TaskWorker query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|TaskWorker whereArgs($value) * @method static \Illuminate\Database\Eloquent\Builder|TaskWorker whereArgs($value)
* @method static \Illuminate\Database\Eloquent\Builder|TaskWorker whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|TaskWorker whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|TaskWorker whereDeletedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|TaskWorker whereDeletedAt($value)

View File

@ -11,15 +11,9 @@ namespace App\Models;
* @property string|null $content * @property string|null $content
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|Tmp newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Tmp newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Tmp newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Tmp newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Tmp query() * @method static \Illuminate\Database\Eloquent\Builder|Tmp query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|Tmp whereContent($value) * @method static \Illuminate\Database\Eloquent\Builder|Tmp whereContent($value)
* @method static \Illuminate\Database\Eloquent\Builder|Tmp whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Tmp whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Tmp whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|Tmp whereId($value)

View File

@ -14,109 +14,23 @@ use Hedeqiang\UMeng\IOS;
* @property int|null $userid 会员ID * @property int|null $userid 会员ID
* @property string|null $alias 别名 * @property string|null $alias 别名
* @property string|null $platform 平台类型 * @property string|null $platform 平台类型
* @property string|null $device 设备类型
* @property string|null $device_hash 设备哈希值用于关联UserDevice表
* @property string|null $version 应用版本号
* @property string|null $ua userAgent
* @property int|null $is_notified 通知权限
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|UmengAlias newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias newQuery() * @method static \Illuminate\Database\Eloquent\Builder|UmengAlias newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias query() * @method static \Illuminate\Database\Eloquent\Builder|UmengAlias query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereAlias($value) * @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereAlias($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereDevice($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereDeviceHash($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereIsNotified($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias wherePlatform($value) * @method static \Illuminate\Database\Eloquent\Builder|UmengAlias wherePlatform($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUa($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUserid($value) * @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereVersion($value)
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class UmengAlias extends AbstractModel class UmengAlias extends AbstractModel
{ {
protected $table = 'umeng_alias'; protected $table = 'umeng_alias';
private static $waitSend = [];
/**
* 推送消息
* @param $push
* @return void
*/
private static function sendTask($push = null)
{
if ($push) {
self::$waitSend[] = $push;
}
if (!self::$waitSend) {
return;
}
$first = array_shift(self::$waitSend);
if (empty($first)) {
return;
}
$instance = null;
$responsePayload = null;
try {
switch ($first['platform']) {
case 'ios':
$instance = new IOS($first['config']);
break;
case 'android':
$instance = new Android($first['config']);
break;
default:
return;
}
$responsePayload = $instance->send($first['data']);
} catch (\Exception $e) {
$responsePayload = [
'error' => $e->getMessage(),
];
$first['retry'] = intval($first['retry'] ?? 0) + 1;
if ($first['retry'] > 3) {
info("[PushMsg] fail: " . $e->getMessage());
} else {
info("[PushMsg] retry ({$first['retry']}): " . $e->getMessage());
self::$waitSend[] = $first;
}
} finally {
if ($instance !== null) {
UmengLog::create([
'request' => Base::array2json($first['data']),
'response' => Base::array2json($responsePayload),
]);
}
self::sendTask();
}
}
/**
* 推送内容处理
* @param $string
* @return string
*/
private static function specialCharacters($string)
{
return str_replace(["\r\n", "\r", "\n"], '', $string);
}
/** /**
* 获取推送配置 * 获取推送配置
* @return array|false * @return array|false
@ -150,98 +64,79 @@ class UmengAlias extends AbstractModel
* @param string $alias * @param string $alias
* @param string $platform * @param string $platform
* @param array $array [title, subtitle, body, description, extra, seconds, badge] * @param array $array [title, subtitle, body, description, extra, seconds, badge]
* @return void * @return array|false
*/ */
private static function pushMsgToAlias($alias, $platform, $array) public static function pushMsgToAlias($alias, $platform, $array)
{ {
$config = self::getPushConfig(); $config = self::getPushConfig();
if ($config === false) { if ($config === false) {
return; return false;
} }
// //
$title = self::specialCharacters($array['title'] ?: ''); // 标题 $title = $array['title'] ?: ''; // 标题
$subtitle = self::specialCharacters($array['subtitle'] ?: ''); // 副标题iOS $subtitle = $array['subtitle'] ?: ''; // 副标题iOS
$body = self::specialCharacters($array['body'] ?: ''); // 通知内容 $body = $array['body'] ?: ''; // 通知内容
$description = $array['description'] ?: 'no description'; // 描述 $description = $array['description'] ?: 'no description'; // 描述
$extra = is_array($array['extra']) ? $array['extra'] : []; // 额外参数 $extra = is_array($array['extra']) ? $array['extra'] : []; // 额外参数
$seconds = intval($array['seconds']) ?: 86400; // 有效时间(单位:秒) $seconds = intval($array['seconds']) ?: 86400; // 有效时间(单位:秒)
$badge = intval($array['badge']) ?: 0; // 角标数 $badge = intval($array['badge']) ?: 0; // 角标数iOS
// //
switch ($platform) { switch ($platform) {
case 'ios': case 'ios':
if (!isset($config['iOS'])) { if (!isset($config['iOS'])) {
return; return false;
} }
self::sendTask([ $ios = new IOS($config);
'platform' => $platform, return $ios->send([
'config' => $config, 'description' => $description,
'data' => [ 'payload' => array_merge([
'description' => $description, 'aps' => [
'payload' => array_merge([ 'alert' => [
'aps' => [ 'title' => $title,
'alert' => [ 'subtitle' => $subtitle,
'title' => $title, 'body' => $body,
'subtitle' => $subtitle,
'body' => $body,
],
'sound' => 'default',
'badge' => $badge,
], ],
], $extra), 'sound' => 'default',
'type' => 'customizedcast', 'badge' => $badge,
'alias_type' => 'userid',
'alias' => $alias,
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
], ],
] ], $extra),
'type' => 'customizedcast',
'alias_type' => 'userid',
'alias' => $alias,
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
]); ]);
break;
case 'android': case 'android':
if (!isset($config['Android'])) { if (!isset($config['Android'])) {
return; return false;
} }
self::sendTask([ $android = new Android($config);
'platform' => $platform, return $android->send([
'config' => $config, 'description' => $description,
'data' => [ 'payload' => array_merge([
'description' => $description, 'display_type' => 'notification',
'payload' => array_merge([ 'body' => [
'display_type' => 'notification', 'ticker' => $title,
'body' => [ 'text' => $body,
'ticker' => $title, 'title' => $title,
'text' => $body, 'after_open' => 'go_app',
'title' => $title, 'play_sound' => true,
'after_open' => 'go_app',
'play_sound' => true,
'set_badge' => min(99, $badge),
],
], $extra),
'type' => 'customizedcast',
'alias_type' => 'userid',
'alias' => $alias,
'mipush' => true,
'mi_activity' => 'app.eeui.umeng.activity.MfrMessageActivity',
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
], ],
'category' => 1, ], $extra),
'channel_properties' => [ 'type' => 'customizedcast',
'main_activity' => 'com.dootask.task.WelcomeActivity', 'alias_type' => 'userid',
'oppo_channel_id' => 'dootask', 'alias' => $alias,
'vivo_category' => 'IM', 'mipush' => true,
'huawei_channel_importance' => 'NORMAL', 'mi_activity' => 'app.eeui.umeng.activity.MfrMessageActivity',
'huawei_channel_category' => 'IM', 'policy' => [
'channel_fcm' => 0, 'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
'local_properties' => [
'importance' => 'IMPORTANCE_DEFAULT',
'category' => 'CATEGORY_MESSAGE',
]
] ]
]); ]);
break;
default:
return false;
} }
} }
@ -268,11 +163,7 @@ class UmengAlias extends AbstractModel
$lists = $rows->take(5)->groupBy('platform'); // 每个会员最多推送5个别名 $lists = $rows->take(5)->groupBy('platform'); // 每个会员最多推送5个别名
foreach ($lists as $platform => $list) { foreach ($lists as $platform => $list) {
$alias = $list->pluck('alias')->implode(','); $alias = $list->pluck('alias')->implode(',');
try { self::pushMsgToAlias($alias, $platform, $array);
self::pushMsgToAlias($alias, $platform, $array);
} catch (\Exception $e) {
info("[PushMsg] fail: " . $e->getMessage());
}
} }
} }
}); });

View File

@ -1,32 +0,0 @@
<?php
namespace App\Models;
/**
* App\Models\UmengLog
*
* @property int $id
* @property string|null $request 请求参数
* @property string|null $response 推送返回
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereRequest($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereResponse($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereUpdatedAt($value)
* @mixin \Eloquent
*/
class UmengLog extends AbstractModel
{
protected $guarded = [];
}

View File

@ -2,14 +2,10 @@
namespace App\Models; namespace App\Models;
use App\Exceptions\ApiException; use App\Exceptions\ApiException;
use App\Module\Base; use App\Module\Base;
use App\Module\Doo; use App\Module\Doo;
use App\Module\Apps;
use App\Module\Table\OnlineData;
use App\Observers\AbstractObserver;
use App\Services\RequestContext;
use App\Tasks\ManticoreSyncTask;
use Cache; use Cache;
use Carbon\Carbon; use Carbon\Carbon;
@ -25,40 +21,27 @@ use Carbon\Carbon;
* @property string|null $tel 联系电话 * @property string|null $tel 联系电话
* @property string $nickname 昵称 * @property string $nickname 昵称
* @property string|null $profession 职位/职称 * @property string|null $profession 职位/职称
* @property string|null $birthday 生日
* @property string|null $address 地址
* @property string|null $introduction 个人简介
* @property string $userimg 头像 * @property string $userimg 头像
* @property string|null $encrypt * @property string|null $encrypt
* @property string|null $password 登录密码 * @property string|null $password 登录密码
* @property int|null $changepass 登录需要修改密码 * @property int|null $changepass 登录需要修改密码
* @property int|null $login_num 累计登录次数 * @property int|null $login_num 累计登录次数
* @property string|null $last_ip 最后登录IP * @property string|null $last_ip 最后登录IP
* @property \Illuminate\Support\Carbon|null $last_at 最后登录时间 * @property string|null $last_at 最后登录时间
* @property string|null $line_ip 最后在线IP接口 * @property string|null $line_ip 最后在线IP接口
* @property \Illuminate\Support\Carbon|null $line_at 最后在线时间(接口) * @property string|null $line_at 最后在线时间(接口)
* @property int|null $task_dialog_id 最后打开的任务会话ID * @property int|null $task_dialog_id 最后打开的任务会话ID
* @property string|null $created_ip 注册IP * @property string|null $created_ip 注册IP
* @property \Illuminate\Support\Carbon|null $disable_at 禁用时间(离职时间) * @property string|null $disable_at 禁用时间(离职时间)
* @property int|null $email_verity 邮箱是否已验证 * @property int|null $email_verity 邮箱是否已验证
* @property int|null $bot 是否机器人 * @property int|null $bot 是否机器人
* @property string|null $lang 语言首选项
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Database\Factories\UserFactory factory(...$parameters) * @method static \Database\Factories\UserFactory factory(...$parameters)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|User newQuery() * @method static \Illuminate\Database\Eloquent\Builder|User newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|User query() * @method static \Illuminate\Database\Eloquent\Builder|User query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|User searchByKeyword(string $keyword)
* @method static \Illuminate\Database\Eloquent\Builder|User whereAddress($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereAz($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereAz($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereBirthday($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereBot($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereBot($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereChangepass($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereChangepass($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value)
@ -69,8 +52,6 @@ use Carbon\Carbon;
* @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerity($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerity($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereEncrypt($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereEncrypt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereIdentity($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereIdentity($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereIntroduction($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereLang($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereLastAt($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereLastAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereLastIp($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereLastIp($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereLineAt($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereLineAt($value)
@ -89,8 +70,6 @@ use Carbon\Carbon;
*/ */
class User extends AbstractModel class User extends AbstractModel
{ {
const IMPORT_MAX = 500;
protected $primaryKey = 'userid'; protected $primaryKey = 'userid';
protected $hidden = [ protected $hidden = [
@ -110,13 +89,7 @@ class User extends AbstractModel
*/ */
public function getNicknameAttribute($value) public function getNicknameAttribute($value)
{ {
if ($value) { return $value ?: Base::cardFormat($this->email);
if (UserBot::isSystemBot($this->email)) {
return Doo::translate($value);
}
return $value;
}
return Base::formatName($this->email);
} }
/** /**
@ -171,7 +144,7 @@ class User extends AbstractModel
}); });
$array = []; $array = [];
foreach ($list as $item) { foreach ($list as $item) {
$array[] = $item['name'] . ($item['owner_userid'] === $this->userid ? ' (M)' : ''); $array[] = $item['name'] . ($item['owner_userid'] === $this->userid ? '(M)' : '');
} }
return implode(', ', $array); return implode(', ', $array);
} }
@ -184,9 +157,10 @@ class User extends AbstractModel
return UserDepartment::where('owner_userid', $this->userid)->exists(); return UserDepartment::where('owner_userid', $this->userid)->exists();
} }
/** /**
* 获取机器人所有者 * 获取机器人所有者
* @return int * @return int|mixed
*/ */
public function getBotOwner() public function getBotOwner()
{ {
@ -194,9 +168,9 @@ class User extends AbstractModel
return 0; return 0;
} }
$key = "userBotOwner::" . $this->userid; $key = "userBotOwner::" . $this->userid;
return intval(Cache::remember($key, now()->addMonth(), function() { return Cache::remember($key, now()->addMonth(), function() {
return intval(UserBot::whereBotId($this->userid)->value('userid')) ?: $this->userid; return intval(UserBot::whereBotId($this->userid)->value('userid')) ?: $this->userid;
})); });
} }
/** /**
@ -205,7 +179,7 @@ class User extends AbstractModel
*/ */
public function getOnlineStatus() public function getOnlineStatus()
{ {
$online = $this->bot || OnlineData::live($this->userid) > 0; $online = $this->bot || Cache::get("User::online:" . $this->userid) === "on";
if ($online) { if ($online) {
return true; return true;
} }
@ -232,14 +206,10 @@ class User extends AbstractModel
/** /**
* 返回是否禁用帐号(离职) * 返回是否禁用帐号(离职)
* @param bool $incAt 是否包含禁用时间
* @return bool * @return bool
*/ */
public function isDisable($incAt = false) public function isDisable()
{ {
if ($incAt) {
return in_array('disable', $this->identity) || $this->disable_at;
}
return in_array('disable', $this->identity); return in_array('disable', $this->identity);
} }
@ -252,31 +222,6 @@ class User extends AbstractModel
return in_array('admin', $this->identity); return in_array('admin', $this->identity);
} }
/**
* 返回是否AI机器人
* @return bool
*/
public function isAiBot(&$aiName = '')
{
if (preg_match('/^ai-(.*?)@bot\.system$/', $this->email, $matches)) {
$aiName = $matches[1];
return true;
}
return false;
}
/**
* 返回是否用户机器人
* @return bool
*/
public function isUserBot()
{
if (preg_match('/^user-(.*?)@bot\.system$/', $this->email)) {
return true;
}
return false;
}
/** /**
* 判断是否管理员 * 判断是否管理员
*/ */
@ -322,7 +267,7 @@ class User extends AbstractModel
*/ */
public function deleteUser($reason) public function deleteUser($reason)
{ {
$ret = AbstractModel::transaction(function () use ($reason) { return AbstractModel::transaction(function () use ($reason) {
// 删除原因 // 删除原因
$userDelete = UserDelete::createInstance([ $userDelete = UserDelete::createInstance([
'operator' => User::userid(), 'operator' => User::userid(),
@ -343,27 +288,6 @@ class User extends AbstractModel
// //
return $this->delete(); return $this->delete();
}); });
return $ret;
}
/**
* 检查发送聊天内容前必须设置昵称、电话
* @return void
*/
public function checkChatInformation()
{
if ($this->bot) {
return;
}
$chatInformation = Base::settingFind('system', 'chat_information');
if ($chatInformation == 'required') {
if (empty($this->getRawOriginal('nickname'))) {
throw new ApiException('请设置昵称', [], -2);
}
if (empty($this->getRawOriginal('tel'))) {
throw new ApiException('请设置联系电话', [], -3);
}
}
} }
/** ***************************************************************************************** */ /** ***************************************************************************************** */
@ -417,295 +341,7 @@ class User extends AbstractModel
$dialog?->joinGroup($user->userid, 0); $dialog?->joinGroup($user->userid, 0);
} }
} }
$createdUser = $user->find($user->userid); return $user->find($user->userid);
if (!$createdUser->bot) {
// Manticore 索引同步
AbstractObserver::taskDeliver(new ManticoreSyncTask('user_sync', $createdUser->toArray()));
// 触发 user_onboard hook
Apps::dispatchUserHook($createdUser, 'user_onboard', 'onboard');
}
return $createdUser;
}
/**
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
* @param string $email
* @param string $password
* @param string $nickname
* @param array $options changePass(bool,默认true) / 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,
];
} }
/** /**
@ -743,15 +379,13 @@ class User extends AbstractModel
{ {
$user = self::authInfo(); $user = self::authInfo();
if (!$user) { if (!$user) {
$token = Base::token(); if (Base::token()) {
if ($token) {
UserDevice::forget($token);
throw new ApiException('身份已失效,请重新登录', [], -1); throw new ApiException('身份已失效,请重新登录', [], -1);
} else { } else {
throw new ApiException('请登录后继续...', [], -1); throw new ApiException('请登录后继续...', [], -1);
} }
} }
if ($user->isDisable()) { if (in_array('disable', $user->identity)) {
throw new ApiException('帐号已停用...', [], -1); throw new ApiException('帐号已停用...', [], -1);
} }
if ($identity) { if ($identity) {
@ -766,47 +400,27 @@ class User extends AbstractModel
*/ */
private static function authInfo() private static function authInfo()
{ {
if (RequestContext::has('auth')) { global $_A;
// 缓存 if (isset($_A["__static_auth"])) {
return RequestContext::get('auth'); return $_A["__static_auth"];
} }
if (Doo::userId() <= 0) { if (Doo::userId() > 0
// 没有登录 && !Doo::userExpired()
return RequestContext::save('auth', false); && $user = self::whereUserid(Doo::userId())->whereEmail(Doo::userEmail())->whereEncrypt(Doo::userEncrypt())->first()) {
} $upArray = [];
if (Doo::userExpired()) { if (Base::getIp() && $user->line_ip != Base::getIp()) {
// 登录过期 $upArray['line_ip'] = Base::getIp();
return RequestContext::save('auth', false);
}
if (!UserDevice::check()) {
// token 不存在
return RequestContext::save('auth', false);
}
$user = self::whereUserid(Doo::userId())->whereEmail(Doo::userEmail())->whereEncrypt(Doo::userEncrypt())->first();
if (!$user) {
// 登录信息不匹配
return RequestContext::save('auth', false);
}
// 更新登录信息
$upArray = [];
if (Base::getIp() && $user->line_ip != Base::getIp()) {
$upArray['line_ip'] = Base::getIp();
}
if (Carbon::parse($user->line_at)->addSeconds(30)->lt(Carbon::now())) {
$upArray['line_at'] = Carbon::now();
}
$headerLanguage = RequestContext::get('header_language');
if (empty($user->lang) || $headerLanguage) {
if (Doo::checkLanguage($headerLanguage) && $user->lang != $headerLanguage) {
$upArray['lang'] = $headerLanguage;
} }
if (Carbon::parse($user->line_at)->addSeconds(30)->lt(Carbon::now())) {
$upArray['line_at'] = Carbon::now();
}
if ($upArray) {
$user->updateInstance($upArray);
$user->save();
}
return $_A["__static_auth"] = $user;
} }
if ($upArray) { return $_A["__static_auth"] = false;
$user->updateInstance($upArray);
$user->save();
}
return RequestContext::save('auth', $user);
} }
/** /**
@ -830,48 +444,32 @@ class User extends AbstractModel
} else { } else {
$token = Doo::userToken(); $token = Doo::userToken();
} }
UserDevice::record($token);
unset($userinfo->encrypt); unset($userinfo->encrypt);
unset($userinfo->password); unset($userinfo->password);
return $userinfo->token = $token; return $userinfo->token = $token;
} }
/**
* 生成无设备的 token主要用于接口调用 token 不检查设备是否存在)
* @param self $userinfo
* @param $ttl
* @return mixed
*/
public static function generateTokenNoDevice($userinfo, $ttl)
{
$key = 'user_token_no_device_' . $userinfo->userid;
return Cache::remember($key, $ttl, function () use ($userinfo, $ttl) {
$token = Doo::tokenEncode($userinfo->userid, $userinfo->email, $userinfo->encrypt);
Cache::put(UserDevice::ck(md5($token)), $userinfo->userid, $ttl);
return $token;
});
}
/** /**
* userid 获取 基础信息 * userid 获取 基础信息
* @param int $userid 会员ID * @param int $userid 会员ID
* @return self * @return self
*/ */
public static function userid2basic($userid, $addField = []) public static function userid2basic($userid)
{ {
global $_A;
if (empty($userid)) { if (empty($userid)) {
return null; return null;
} }
$userid = intval($userid); $userid = intval($userid);
if (RequestContext::has("userid2basic_" . $userid)) { if (isset($_A["__static_userid2basic_" . $userid])) {
return RequestContext::get("userid2basic_" . $userid); return $_A["__static_userid2basic_" . $userid];
} }
$userInfo = self::whereUserid($userid)->select(array_merge(User::$basicField, $addField))->first(); $userInfo = self::whereUserid($userid)->select(User::$basicField)->first();
if ($userInfo) { if ($userInfo) {
$userInfo->online = $userInfo->getOnlineStatus(); $userInfo->online = $userInfo->getOnlineStatus();
$userInfo->department_name = $userInfo->getDepartmentName(); $userInfo->department_name = $userInfo->getDepartmentName();
} }
return RequestContext::save("userid2basic_" . $userid, $userInfo ?: []); return $_A["__static_userid2basic_" . $userid] = ($userInfo ?: []);
} }
@ -909,16 +507,6 @@ class User extends AbstractModel
} }
} }
/**
* 临时帐号别名
* @return mixed|string
*/
public static function tempAccountAlias()
{
$alias = Base::settingFind('system', 'temp_account_alias');
return $alias ?: Doo::translate("临时帐号");
}
/** /**
* 获取头像 * 获取头像
* @param $userid * @param $userid
@ -951,16 +539,6 @@ class User extends AbstractModel
return url("images/avatar/default_openai.png"); return url("images/avatar/default_openai.png");
case 'ai-claude@bot.system': case 'ai-claude@bot.system':
return url("images/avatar/default_claude.png"); return url("images/avatar/default_claude.png");
case 'ai-deepseek@bot.system':
return url("images/avatar/default_deepseek.png");
case 'ai-gemini@bot.system':
return url("images/avatar/default_gemini.png");
case 'ai-grok@bot.system':
return url("images/avatar/default_grok.png");
case 'ai-ollama@bot.system':
return url("images/avatar/default_ollama.png");
case 'ai-zhipu@bot.system':
return url("images/avatar/default_zhipu.png");
case 'bot-manager@bot.system': case 'bot-manager@bot.system':
return url("images/avatar/default_bot.png"); return url("images/avatar/default_bot.png");
case 'meeting-alert@bot.system': case 'meeting-alert@bot.system':
@ -1033,67 +611,16 @@ class User extends AbstractModel
])->save(); ])->save();
} }
// //
if (empty($update['nickname'])) { $update['nickname'] = UserBot::systemBotName($email);
$update['nickname'] = UserBot::systemBotName($email);
}
} }
if ($update) { if ($update) {
if (isset($update['nickname']) && $botUser->nickname != $update['nickname']) { $botUser->updateInstance($update);
if (isset($update['nickname'])) {
$botUser->az = Base::getFirstCharter($botUser->nickname); $botUser->az = Base::getFirstCharter($botUser->nickname);
$botUser->pinyin = Base::cn2pinyin($botUser->nickname); $botUser->pinyin = Base::cn2pinyin($botUser->nickname);
} }
$botUser->updateInstance($update);
$botUser->save(); $botUser->save();
} }
return $botUser; return $botUser;
} }
/**
* 是否机器人
* @param $userid
* @return bool
*/
public static function isBot($userid)
{
if (empty($userid)) {
return false;
}
// 这个不会有变化,所以可以使用永久缓存
return (bool)Cache::rememberForever('is-bot-user-' . $userid, function () use ($userid) {
return (bool)User::find($userid)?->bot;
});
}
/**
* 按关键词搜索用户Scope
* 支持:邮箱(含@、用户ID纯数字、昵称/拼音/职业
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $keyword 搜索关键词
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeSearchByKeyword($query, string $keyword)
{
if (str_contains($keyword, "@")) {
// 包含 @ 按邮箱搜索
return $query->where("email", "like", "%{$keyword}%");
}
if (is_numeric($keyword)) {
// 纯数字匹配用户ID 或 昵称/拼音/职业
return $query->where(function ($q) use ($keyword) {
$q->where("userid", intval($keyword))
->orWhere("nickname", "like", "%{$keyword}%")
->orWhere("pinyin", "like", "%{$keyword}%")
->orWhere("profession", "like", "%{$keyword}%");
});
}
// 普通文本:搜索昵称/拼音/职业
return $query->where(function ($q) use ($keyword) {
$q->where("nickname", "like", "%{$keyword}%")
->orWhere("pinyin", "like", "%{$keyword}%")
->orWhere("profession", "like", "%{$keyword}%");
});
}
} }

View File

@ -1,102 +0,0 @@
<?php
namespace App\Models;
/**
* App\Models\UserAppSort
*
* @property int $id
* @property int $userid 用户ID
* @property array|null $sorts 排序配置
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereSorts($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereUserid($value)
* @mixin \Eloquent
*/
class UserAppSort extends AbstractModel
{
protected $fillable = [
'userid',
'sorts',
];
protected $casts = [
'sorts' => 'array',
];
/**
* 获取用户排序配置
* @param int $userid
* @return array
*/
public static function getSorts(int $userid): array
{
$record = static::whereUserid($userid)->first();
if (!$record) {
return self::normalizeSorts([]);
}
return self::normalizeSorts($record->sorts);
}
/**
* 保存排序配置
* @param int $userid
* @param array $sorts
* @return static
*/
public static function saveSorts(int $userid, array $sorts): self
{
return static::updateOrCreate(
['userid' => $userid],
['sorts' => self::normalizeSorts($sorts)]
);
}
/**
* 规范化排序数据
* @param mixed $sorts
* @return array
*/
public static function normalizeSorts($sorts): array
{
$result = [
'base' => [],
'admin' => [],
];
if (!is_array($sorts)) {
return $result;
}
foreach (['base', 'admin'] as $group) {
$list = $sorts[$group] ?? [];
if (!is_array($list)) {
$list = [];
}
$normalized = [];
foreach ($list as $value) {
if (!is_string($value)) {
continue;
}
$value = trim($value);
if ($value === '') {
continue;
}
$normalized[] = $value;
}
$result[$group] = array_values(array_unique($normalized));
}
return $result;
}
}

Some files were not shown because too many files have changed in this diff Show More