Compare commits

..

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

2440 changed files with 143015 additions and 257512 deletions

View File

@ -1 +0,0 @@
.claude

View File

@ -1,53 +0,0 @@
#!/usr/bin/env bash
# Claude Code PostToolUse hook:Edit/Write 改动 app/ 下的 PHP 文件后,自动在 PHP 容器内
# 对该文件跑 phpstan 单文件分析。失败时 exit 2,把错误回灌给 Claude 修复。
# 任何环境不满足(无 python3 / 容器未运行 / 未装 phpstan)都静默放行,绝不阻塞编辑。
set -u
INPUT=$(cat)
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
command -v python3 >/dev/null 2>&1 || exit 0
command -v docker >/dev/null 2>&1 || exit 0
FILE_PATH=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" 2>/dev/null) || exit 0
[ -n "$FILE_PATH" ] || exit 0
case "$FILE_PATH" in
*.php) ;;
*) exit 0 ;;
esac
REL_PATH="${FILE_PATH#"$PROJECT_DIR"/}"
case "$REL_PATH" in
app/*) ;;
*) exit 0 ;;
esac
[ -f "$PROJECT_DIR/$REL_PATH" ] || exit 0
# 定位挂载本项目的 PHP 容器:
# ① 环境变量 DOOTASK_PHP_CONTAINER;② .env 的 APP_ID;③ 扫描 /var/www 挂载源为本项目的容器
CONTAINER="${DOOTASK_PHP_CONTAINER:-}"
if [ -z "$CONTAINER" ] && [ -f "$PROJECT_DIR/.env" ]; then
APP_ID=$(grep -E '^APP_ID=' "$PROJECT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2 | tr -d '"' | tr -d "'")
if [ -n "$APP_ID" ] && docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "dootask-php-$APP_ID"; then
CONTAINER="dootask-php-$APP_ID"
fi
fi
if [ -z "$CONTAINER" ]; then
RUNNING=$(docker ps -q 2>/dev/null)
[ -n "$RUNNING" ] && CONTAINER=$(docker inspect --format '{{.Name}}|{{range .Mounts}}{{if eq .Destination "/var/www"}}{{.Source}}{{end}}{{end}}' $RUNNING 2>/dev/null \
| awk -F'|' -v dir="$PROJECT_DIR" '$2 == dir {gsub(/^\//, "", $1); print $1; exit}')
fi
[ -n "$CONTAINER" ] || exit 0
docker exec "$CONTAINER" test -f /var/www/vendor/bin/phpstan 2>/dev/null || exit 0
OUTPUT=$(docker exec "$CONTAINER" sh -c "cd /var/www && php vendor/bin/phpstan analyse --no-progress --error-format=raw --memory-limit=-1 '$REL_PATH'" 2>&1)
if [ $? -ne 0 ]; then
{
echo "phpstan 检查未通过($REL_PATH),请修复以下问题:"
printf '%s\n' "$OUTPUT" | grep -v '^Note:' | tail -30
} >&2
exit 2
fi
exit 0

View File

@ -1,16 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/php-stan-check.sh",
"timeout": 120
}
]
}
]
}
}

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.4'
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

View File

@ -1,81 +0,0 @@
name: Tests
on:
push:
branches: [pro, master, dev]
pull_request:
jobs:
static-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, intl, gd, xml, zip, swoole, redis
tools: composer:v2
- name: Install Composer Dependencies
run: composer install --prefer-dist --no-interaction
- name: PHPStan
run: composer stan
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install NPM Dependencies
run: npm install --no-audit --no-fund
- name: ESLint
run: npm run lint
# 存量缺失文案 93 条(见 scripts/check-language.mjs 输出),清零后移除 continue-on-error 改为强制
- name: Language Check
run: npm run check:lang
continue-on-error: true
phpunit:
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:10.7.3
env:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: dootask
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -ptest"
--health-interval=10s
--health-timeout=5s
--health-retries=10
redis:
image: redis:alpine
ports:
- 6379:6379
steps:
- name: Checkout
uses: actions/checkout@v4
# 用项目自身的 PHP 镜像跑测试(内含 /usr/lib/doo/doo.so FFI 库,裸 runner 无法运行)
- name: Run PHPUnit in DooTask PHP image
run: |
docker run --rm --network host -v "$GITHUB_WORKSPACE":/var/www -w /var/www \
kuaifan/php:swoole-8.4 sh -c '
composer install --prefer-dist --no-interaction &&
cp .env.example .env &&
sed -i "s/^DB_HOST=.*/DB_HOST=127.0.0.1/" .env &&
sed -i "s/^DB_DATABASE=.*/DB_DATABASE=dootask/" .env &&
sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=test/" .env &&
sed -i "s/^REDIS_HOST=.*/REDIS_HOST=127.0.0.1/" .env &&
php artisan key:generate &&
php artisan migrate --seed --force &&
php vendor/bin/phpunit
'

58
.gitignore vendored
View File

@ -1,68 +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/
/.phpunit.cache

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

165
.prefetch
View File

@ -1,165 +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/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,90 +0,0 @@
## 项目概述
Laravel 13 (LaravelS/Swoole, PHP 8.4) + 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。用户明确说"跑一下 / 出包"时除外。
### 质量门禁改完代码必须自查CI 同步在跑,见 .github/workflows/tests.yml
- `./cmd composer stan` — phpstanlevel 1 + baseline存量已封存新增错误必须清零
- `npm run lint` — ESLinterror 必须为 0warn 是存量遗留,见 eslint.config.mjs 注释)
- `npm run check:lang` — 校验前端 `$L()` 字面量是否已登记到 `language/original-web.txt`
- 改动控制器 public 方法或路由后跑 `./cmd artisan doc:api-map` 重新生成对照表
## 代码检索地图(先查表,再 grep
- API URL ↔ 控制器方法对照:`routes/api-map.md`(生成式文件,勿手改)
- 前端事件总线mitt收发对照`docs/events-map.md``npm run events:map` 重新生成)
- `$A` / `$L` 全局工具类型声明:`types/dootask-globals.d.ts`(新增 `$A` 方法须同步此文件)
## 架构增量规则(只约束新增代码,存量"动到哪迁到哪"
- **巨型文件冻结**:不再往 `ProjectController``UsersController``DialogController``app/Module/Base.php``resources/assets/js/store/actions.js` 新增方法/函数;新功能领域开新控制器或新模块文件(动态路由天然支持多控制器)
- **业务编排归层**:跨模型的业务流程写在 `app/Module/`(或 `app/Services/`模型只保留数据访问与自身状态变更Swoole Task 只做投递与调用,不直接编排业务
- **配置读取**:业务代码禁止直接 `env()`,统一走 `config()`(项目自有配置集中在 `config/dootask.php`
## Gotchas
### LaravelS/Swoole
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
- 要存请求级状态,用 `RequestContext::save('key', $value)` / `RequestContext::get('key')`(参考 `User::authInfo()` 的用法,见 `app/Services/RequestContext.php`
- 构造函数、服务提供者、`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)`——禁止拼接翻译
## ai-kb 同步规则
`resources/ai-kb/` 是产品内 AI 助手 RAG 检索的功能知识库(目录结构、写作规范、索引机制见其 `README.md``_schema/`)。
- **同步时机**:改动用户可见的功能/菜单/按钮/流程/字段、API 行为(错误码、参数含义、返回结构)、插件/微应用、权限/角色定义时,必须在同一次提交中同步更新 ai-kb不要把 ai-kb 改动单独拆成一个提交
- **怎么改**:在 `_meta/feature-map.yaml` 找到对应 feature 的 chunk 清单,按 `_schema/chunk-style.md``_schema/frontmatter.md` 修改或新建 chunk并把 frontmatter 的 `last_verified` 更新为当前主程序版本号
- **改完即止**:无需触发任何索引操作,插件容器启动时会自动对账收敛
## Playwright 测试
- Playwright 测试结果放在 `tests/playwright-results/`,包含测试环境、测试用例、结果截图等信息
## 交互规范
- **提问时附带建议**:当需要向用户提问或请求澄清时,应同时提供具体的建议选项或推荐方案,帮助用户快速决策,而非仅抛出开放式问题
## 语言偏好
- 回复一律使用简体中文,除非用户明确要求其他语言

169
README.md
View File

@ -1,159 +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`
## Installation Requirements ## Setup
- Required: `Docker v20.10+` and `Docker Compose v2.0+` - `Docker v20.10+` & `Docker Compose v2.0+` must be installed
- Supported Systems: `CentOS/Debian/Ubuntu/macOS` and other Linux/Unix systems - System: `Centos/Debian/Ubuntu/macOS`
- Hardware Recommendation: 2+ cores, 4GB+ memory - Hardware suggestion: 2 cores and above 4G 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 ### Deployment (Pro Edition)
**Option 1: One-line script (recommended)**
Run it in an empty directory to clone and install automatically; run it inside an existing installation to check and upgrade:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash # 1、Clone the repository
```
**Option 2: Manual deployment** # Clone projects on github
git clone -b pro --depth=1 https://github.com/kuaifan/dootask.git
```bash # Or you can use gitee
# 1、Clone the project to your local machine or server git clone -b pro --depth=1 https://gitee.com/aipaw/dootask.git
# Clone project from GitHub
git clone --depth=1 https://github.com/kuaifan/dootask.git
# Or you can use Gitee
git clone --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 ```bash
# Run command and follow the prompts # You can do this using the following command
./cmd https ./cmd artisan "your command" # To run a artisan command
./cmd php "your command" # To run a php command
./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!**
Recommended: use the one-line script (run it inside an existing installation; it pulls the latest code and finishes the upgrade in a single run):
```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
Or use the local command:
```bash ```bash
# Method 1: Running commands in a project
./cmd update ./cmd update
```
* If you encounter 502 errors after upgrade, run `./cmd reup` to restart services. # Or method 2: use this method if method 1 fails
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,8 +1,8 @@
# 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交流群**
@ -12,35 +12,23 @@
## 安装程序 ## 安装程序
- 必须安装:`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
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
**方式二:手动部署**
```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
``` ```
@ -54,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 代理配置添加
@ -99,61 +97,51 @@ 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
``` ```
## 升级更新 ## 升级更新
**注意:在升级之前请备份好你的数据!** **注意:在升级之前请备份好你的数据!**
推荐使用一键脚本升级(在已安装目录中执行,自动拉取最新代码并完成升级,无需重复执行):
```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
或使用本地命令:
```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 自动发布)

File diff suppressed because it is too large Load Diff

View File

@ -1,143 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Route;
use ReflectionClass;
use ReflectionMethod;
class DocApiMap extends Command
{
protected $signature = 'doc:api-map';
protected $description = '生成 API 路由对照表routes/api-map.md';
public function handle(): int
{
$controllers = $this->collectControllers();
if (empty($controllers)) {
$this->error('未从路由中解析到任何 api 控制器');
return 1;
}
$total = 0;
$sections = [];
foreach ($controllers as $prefix => $class) {
$rows = $this->collectMethods($prefix, $class);
$total += count($rows);
$sections[] = $this->renderSection($prefix, $class, $rows);
}
$path = base_path('routes/api-map.md');
file_put_contents($path, $this->renderHeader($total) . implode("\n", $sections));
$this->info("已生成: routes/api-map.md控制器 " . count($controllers) . " 个,接口 {$total} 个)");
return 0;
}
/**
* 从已注册路由中收集 api 前缀与控制器的映射
* 匹配 routes/web.php 中的动态路由api/{prefix}/{method}
* @return array [prefix => 控制器类名]
*/
private function collectControllers(): array
{
$controllers = [];
foreach (Route::getRoutes() as $route) {
if (!preg_match('/^api\/(\w+)\/\{method}$/', $route->uri())) {
continue;
}
preg_match('/^api\/(\w+)\/\{method}$/', $route->uri(), $match);
$class = $route->getAction('controller');
if ($class && class_exists($class)) {
$controllers[$match[1]] = $class;
}
}
return $controllers;
}
/**
* 反射收集控制器的接口方法
* @param string $prefix 路由前缀(如 project
* @param string $class 控制器类名
* @return array [['url' => ..., 'method' => ..., 'http' => ..., 'title' => ...], ...]
*/
private function collectMethods(string $prefix, string $class): array
{
$rows = [];
$reflection = new ReflectionClass($class);
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
// 仅保留本类声明的实例方法,排除 __invoke/__before/__construct 等魔术/框架方法
if ($method->getDeclaringClass()->getName() !== $class
|| $method->isStatic()
|| str_starts_with($method->getName(), '__')) {
continue;
}
[$http, $title] = $this->parseApiDoc($method);
$rows[] = [
'url' => "api/{$prefix}/" . str_replace('__', '/', $method->getName()),
'method' => $method->getName() . '()',
'http' => $http,
'title' => $title,
];
}
return $rows;
}
/**
* 解析方法 docblock 中的 @api 注释行
* 格式如:@api {get} api/project/lists 获取项目列表
* @return array [HTTP 方法, 标题],无 @api 注释时为 ['any', '']
*/
private function parseApiDoc(ReflectionMethod $method): array
{
$doc = $method->getDocComment();
if ($doc && preg_match('/@api\s+\{(\w+)}\s+(\S+)(?:[ \t]+(.+))?/', $doc, $match)) {
return [strtolower($match[1]), trim($match[3] ?? '')];
}
return ['any', ''];
}
/**
* 生成文件头说明
*/
private function renderHeader(int $total): string
{
return <<<MD
# API 路由对照表
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数:{$total}
## 路由规则
API 使用动态路由(见 `routes/web.php`URL 段映射为控制器方法名:
- `api/{controller}/{method}` `{method}()`,如 `api/project/lists` `ProjectController::lists()`
- `api/{controller}/{method}/{action}` `{method}__{action}()`(双下划线连接),如 `api/project/invite/join` `ProjectController::invite__join()`
- 路由最多两段,方法名最多一个双下划线
MD;
}
/**
* 生成单个控制器的表格段落
*/
private function renderSection(string $prefix, string $class, array $rows): string
{
$short = class_basename($class);
$lines = [
"## {$prefix}{$short}",
'',
'| URL | 方法名 | HTTP | 说明 |',
'| --- | --- | --- | --- |',
];
foreach ($rows as $row) {
$lines[] = "| {$row['url']} | {$row['method']} | {$row['http']} | {$row['title']} |";
}
$lines[] = '';
return implode("\n", $lines);
}
}

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,29 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Module\OnlineLicense;
use Illuminate\Console\Command;
/**
* 在线授权续期(容器内独立进程按小时调用,无需 LARAVELS_TIMER、不经过 HTTP 转发)。
*
* php 容器 supervisor 程序 [program:license] 循环调用:
* while true; do php artisan online-license:renew; sleep 3600; done
*/
class OnlineLicenseRenew extends Command
{
protected $signature = 'online-license:renew';
protected $description = '在线授权:本地状态机推进 + 租约将尽时自动续期';
public function handle(): int
{
if (!OnlineLicense::enabled()) {
return 0;
}
OnlineLicense::cron();
$status = OnlineLicense::status();
$this->info('online-license: ' . ($status['status'] ?? 'offline') . ' lease=' . ($status['lease_expired_at'] ?? '-'));
return 0;
}
}

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,99 +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签名须兼容 Symfony Console Command::handleSignal
*/
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
$this->markShouldStop();
return false; // 继续执行,由批次循环优雅退出
}
/**
* 标记优雅退出pcntl 回调第二参是 siginfo不能直接复用 handleSignal
*/
private function markShouldStop(): void
{
$this->info("\n收到信号,将在当前批次完成后退出...");
$this->shouldStop = true;
}
/**
* 注册信号处理器
*/
private function registerSignalHandlers(): void
{
if (extension_loaded('pcntl')) {
pcntl_async_signals(true);
pcntl_signal(SIGINT, fn () => $this->markShouldStop());
pcntl_signal(SIGTERM, fn () => $this->markShouldStop());
}
}
/**
* 检查命令是否可以启动(锁检查)
*
* @return bool 返回 true 表示可以启动false 表示已被占用
*/
private function acquireLock(): bool
{
$lockInfo = $this->getLock();
if ($lockInfo) {
$this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}");
return false;
}
$this->setLock();
return true;
}
}

41
app/Console/Kernel.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
//
];
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
// $schedule->command('inspire')->hourly();
}
/**
* Register the commands for the application.
*
* @return void
*/
protected function commands()
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

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

@ -0,0 +1,81 @@
<?php
namespace App\Exceptions;
use App\Module\Base;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\Log;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* A list of the exception types that are not reported.
*
* @var array
*/
protected $dontReport = [
//
];
/**
* A list of the inputs that are never flashed for validation exceptions.
*
* @var array
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*
* @return void
*/
public function register()
{
$this->reportable(function (Throwable $e) {
//
});
}
/**
* 将异常转换为 HTTP 响应。
* @param $request
* @param Throwable $e
* @return array|\Illuminate\Http\JsonResponse|\Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response
* @throws Throwable
*/
public function render($request, Throwable $e)
{
if ($e instanceof ApiException) {
return response()->json(Base::retError($e->getMessage(), $e->getData(), $e->getCode()));
} elseif ($e instanceof ModelNotFoundException) {
return response()->json(Base::retError('Interface error'));
}
return parent::render($request, $e);
}
/**
* 重写report优雅记录
* @param Throwable $e
* @throws Throwable
*/
public function report(Throwable $e)
{
if ($e instanceof ApiException) {
if ($e->getCode() !== -1) {
Log::error($e->getMessage(), [
'code' => $e->getCode(),
'data' => $e->getData(),
'exception' => ' at ' . $e->getFile() . ':' . $e->getLine()
]);
}
} else {
parent::report($e);
}
}
}

View File

@ -1,165 +0,0 @@
<?php
namespace App\Exceptions;
use App\Module\Base;
use App\Module\Image;
/**
* 图片路径处理(原 Exceptions\Handler::ImagePathHandler新结构下由 bootstrap/app.php
* withExceptions NotFoundHttpException 时调用)
*/
class ImagePathHandler
{
/**
* @param \Illuminate\Http\Request $request
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse|null 命中返回图片响应,未命中返回 null(继续默认 404
*/
public static function render($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));
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,569 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\AiAssistantFeedback;
use App\Models\AiAssistantSearchLog;
use App\Models\AiAssistantSession;
use App\Models\User;
use App\Models\WebSocket;
use App\Module\AI;
use App\Module\Apps;
use App\Module\Base;
use App\Tasks\PushTask;
use Cache;
use Illuminate\Support\Str;
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 上下文数组
* @apiParam {String} [locale] ai-kb 检索语种zh、en缺省取请求语言 language包含 zh 视为 zh否则 en
* @apiParam {String} [session_id] 前端会话ID透传给 AI 服务作 context_key用于检索打点关联
*
* @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', []);
$locale = trim(Request::input('locale', '')) ?: trim(Base::headerOrInput('language'));
$locale = str_contains(strtolower($locale), 'zh') ? 'zh' : 'en';
$contextKey = mb_substr(trim(Request::input('session_id', '')), 0, 100);
// 当前用户 WebSocket fd供 AI 经 doo page 操作本人浏览器(页面操作用)。
// 复用 operation__dispatch 同款归属校验:在表即在线、归属即本人,否则置 0。
$fd = intval(Base::headerOrInput('fd'));
if ($fd > 0 && intval(WebSocket::whereFd($fd)->value('userid')) !== intval($user->userid)) {
$fd = 0;
}
// 灰度判定(参考 config/ai.php总开关 + canary 白名单
$ragEnabled = AI::ragEnabledFor((int) $user->userid);
return AI::createStreamKey($modelType, $modelName, $contextInput, $locale, $ragEnabled, $contextKey, $fd);
}
/**
* @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;
}
/**
* @api {post} api/assistant/log/search 记录帮助知识库检索日志
*
* @apiDescription 需要token身份AI 插件透传用户 token 服务端回调)。记录一次 search_help_docs 检索,用于分析检索质量、反哺 ai-kb 内容迭代
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName log__search
*
* @apiParam {String} query 检索query
* @apiParam {String} [locale] 语种 zh|en
* @apiParam {String} [source] 来源 chat|invoke
* @apiParam {String} [context_key] 上下文标识
* @apiParam {Number} [dialog_id] 对话ID
* @apiParam {Array} [source_ids] 命中source id列表
* @apiParam {Number} [top_score] 最高相似度
* @apiParam {Number} [result_count] 命中数量
* @apiParam {Number} [duration_ms] 检索耗时毫秒
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
*/
public function log__search()
{
$user = User::auth();
$query = mb_substr(trim(Request::input('query', '')), 0, 500);
$locale = trim(Request::input('locale', ''));
$source = trim(Request::input('source', ''));
$contextKey = mb_substr(trim(Request::input('context_key', '')), 0, 191);
$dialogId = intval(Request::input('dialog_id', 0));
$sourceIds = Request::input('source_ids', []);
$topScore = floatval(Request::input('top_score', 0));
$resultCount = intval(Request::input('result_count', 0));
$durationMs = intval(Request::input('duration_ms', 0));
if ($query === '') {
return Base::retError('参数错误');
}
if (!in_array($source, ['chat', 'invoke'])) {
$source = '';
}
if (!is_array($sourceIds)) {
$sourceIds = [];
}
$log = AiAssistantSearchLog::createInstance([
'userid' => $user->userid,
'dialog_id' => max(0, $dialogId),
'context_key' => $contextKey,
'source' => $source,
'query' => $query,
'locale' => in_array($locale, ['zh', 'en']) ? $locale : '',
'source_ids' => Base::array2json(array_slice(array_values($sourceIds), 0, 10)),
'top_score' => max(0, min(1, $topScore)),
'result_count' => max(0, $resultCount),
'duration_ms' => max(0, $durationMs),
'empty' => $resultCount > 0 ? 0 : 1,
]);
$log->save();
return Base::retSuccess('success');
}
/**
* @api {post} api/assistant/feedback/save 保存回复反馈
*
* @apiDescription 需要token身份。保存用户对一条 AI 回复的 👍/👎 反馈,同一条回复可改票(覆盖更新);传空 feedback 表示取消反馈(删除记录)
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName feedback__save
*
* @apiParam {String} session_key 场景分类key
* @apiParam {String} session_id 前端会话ID
* @apiParam {Number} local_id 回复条目localId
* @apiParam {String} feedback like|dislike空字符串表示取消反馈
* @apiParam {String} [prompt] 用户问题
* @apiParam {String} [answer] 回复摘录
* @apiParam {Array} [source_ids] 回复引用的kb source id列表
* @apiParam {String} [model] 模型名
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.feedback 已保存的反馈值
*/
public function feedback__save()
{
$user = User::auth();
$sessionKey = mb_substr(trim(Request::input('session_key', 'default')), 0, 100);
$sessionId = mb_substr(trim(Request::input('session_id', '')), 0, 100);
$localId = intval(Request::input('local_id', 0));
$feedback = trim(Request::input('feedback', ''));
$prompt = mb_substr(trim(Request::input('prompt', '')), 0, 1000);
$answer = mb_substr(trim(Request::input('answer', '')), 0, 2000);
$sourceIds = Request::input('source_ids', []);
$model = mb_substr(trim(Request::input('model', '')), 0, 100);
if (empty($sessionId) || $localId <= 0) {
return Base::retError('参数错误');
}
if (!in_array($feedback, ['', 'like', 'dislike'])) {
return Base::retError('反馈类型错误');
}
if (!is_array($sourceIds)) {
$sourceIds = [];
}
$exist = AiAssistantFeedback::where('userid', $user->userid)
->where('session_key', $sessionKey)
->where('session_id', $sessionId)
->where('local_id', $localId)
->first();
// 空反馈表示取消:删除已有记录
if ($feedback === '') {
$exist?->delete();
return Base::retSuccess('success', [
'feedback' => '',
]);
}
$row = AiAssistantFeedback::createInstance([
'userid' => $user->userid,
'session_key' => $sessionKey,
'session_id' => $sessionId,
'local_id' => $localId,
'feedback' => $feedback,
'prompt' => $prompt,
'answer' => $answer,
'answer_digest' => md5($answer),
'source_ids' => Base::array2json(array_slice(array_values($sourceIds), 0, 10)),
'model' => $model,
], $exist?->id);
$row->save();
return Base::retSuccess('success', [
'feedback' => $feedback,
]);
}
/**
* @api {post} api/assistant/operation/dispatch 派发页面操作
*
* @apiDescription 需要token身份。通过用户常驻 WebSocket/ws向其浏览器派发一次页面操作获取页面上下文 / 执行动作 / 操作元素),由前端 AI 助手执行后经 operationResult 回传,结果写入缓存供 operation/result 轮询取走。复用主程序 /ws无需为页面操作另开 WebSocket。
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName operation__dispatch
*
* @apiParam {Number} fd 目标会话 fd须为当前用户在线的 WebSocket 连接)
* @apiParam {String} action 操作类型,如 get_page_context|execute_action|execute_element_action
* @apiParam {Object} [payload] 操作参数
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.requestId 本次操作的请求ID用于轮询 operation/result
*/
public function operation__dispatch()
{
$user = User::auth();
$fd = intval(Base::headerOrInput('fd'));
$action = trim(Request::input('action', ''));
$payload = Request::input('payload', []);
if ($fd <= 0 || $action === '') {
return Base::retError('参数错误');
}
if (!is_array($payload)) {
$payload = [];
}
// fd 归属校验:在表即在线,归属即本人
$ownerId = WebSocket::whereFd($fd)->value('userid');
if (intval($ownerId) !== intval($user->userid)) {
return Base::retError('会话不存在或无权限');
}
$requestId = Str::random(24);
// 精确推送到该 fd不补发离线消息
PushTask::push([
'fd' => $fd,
'msg' => [
'type' => 'operation',
'data' => [
'requestId' => $requestId,
'action' => $action,
'payload' => $payload,
],
],
], false);
return Base::retSuccess('success', [
'requestId' => $requestId,
]);
}
/**
* @api {get} api/assistant/operation/result 取页面操作结果
*
* @apiDescription 需要token身份。轮询取走 operation/dispatch 派发的一次页面操作结果(取走即删);未回传时返回 status=pending。
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName operation__result
*
* @apiParam {String} request_id 操作请求ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.status ready|pending
*/
public function operation__result()
{
$user = User::auth();
$requestId = trim(Base::headerOrInput('request_id'));
if ($requestId === '') {
return Base::retError('参数错误');
}
$row = Cache::get("ai_op_result:{$requestId}");
if (!is_array($row)) {
return Base::retSuccess('success', ['status' => 'pending']);
}
// 命中后校验归属再取走,避免越权读取他人结果
if (intval($row['userid']) !== intval($user->userid)) {
return Base::retError('无权限');
}
Cache::forget("ai_op_result:{$requestId}");
return Base::retSuccess('success', [
'status' => 'ready',
'success' => !empty($row['success']),
'result' => $row['result'] ?? null,
'error' => $row['error'] ?? null,
]);
}
/**
* 获取会话列表
*/
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,14 +9,8 @@ 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 Swoole\Coroutine;
use Carbon\Carbon; use Carbon\Carbon;
use Redirect; use Redirect;
@ -33,7 +25,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 +42,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 +59,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 +67,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 +83,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 +114,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 +122,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 +147,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 +165,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 +191,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 +274,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 +297,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 +334,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 +381,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 +393,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 +409,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 +448,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
@ -591,10 +496,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 +508,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 +515,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 +555,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 +570,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 +608,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,20 +623,17 @@ 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');
if (!is_array($config)) { $token = \Firebase\JWT\JWT::encode($config, env('APP_KEY') ,'HS256');
return Base::retError('参数错误');
}
$token = \Firebase\JWT\JWT::encode($config, config('app.key') ,'HS256');
return Base::retSuccess('成功', [ return Base::retSuccess('成功', [
'token' => $token 'token' => $token
]); ]);
} }
/** /**
* @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
@ -764,8 +650,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');
@ -775,7 +659,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));
@ -803,7 +687,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
@ -811,9 +695,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错误
@ -824,24 +705,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
@ -873,7 +743,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
@ -896,8 +766,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('历史数据不存在或已被删除');
@ -915,7 +783,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
@ -951,7 +819,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
@ -1041,7 +909,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
@ -1075,7 +943,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
@ -1086,9 +954,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 返回信息(错误描述)
@ -1100,22 +965,57 @@ 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 打包文件 * @api {get} api/file/download/check 19. 检测下载
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup file
* @apiName download__check
*
* @apiParam {Array} [ids] 文件ID格式: [id, id2, id3]
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function download__check(){
$user = User::auth();
$ids = Request::input('ids');
if (!is_array($ids) || empty($ids)) {
return Base::retError('请选择下载的文件或文件夹');
}
if (count($ids) > 100) {
return Base::retError('一次最多只能下载100个文件或文件夹');
}
$files = [];
$totalSize = 0;
AbstractModel::transaction(function() use ($user, $ids, &$files, &$totalSize) {
foreach ($ids as $k => $id) {
$files[] = File::getFilesTree(intval($id), $user, 1);
$totalSize += $files[$k]->totalSize;
}
});
if ($totalSize > File::zipMaxSize) {
throw new ApiException('文件总大小已超过1GB请分批下载');
}
return Base::retSuccess('success');
}
/**
* @api {get} api/file/download/pack 20. 打包文件
* *
* @apiDescription 需要token身份 * @apiDescription 需要token身份
* @apiVersion 1.0.0 * @apiVersion 1.0.0
@ -1131,125 +1031,89 @@ class FileController extends AbstractController
*/ */
public function download__pack() 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(); $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'); $ids = Request::input('ids');
$fileName = Request::input('name'); $downName = 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)) { if (!is_array($ids) || empty($ids)) {
return Base::retError('请选择下载的文件或文件夹'); abort(403, "Please select the file or folder to download.");
} }
if (count($ids) > 100) { if (count($ids) > 100) {
return Base::retError('一次最多可以下载100个文件或文件夹'); abort(403, "You can download a maximum of 100 files or folders at a time.");
} }
$botUser = User::botGetOrCreate('system-msg');
if (empty($botUser)) {
return Base::retError('系统机器人不存在');
}
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
$files = []; $files = [];
$totalSize = 0; $totalSize = 0;
AbstractModel::transaction(function() use ($user, $ids, &$files, &$totalSize) {
foreach ($ids as $k => $id) { foreach ($ids as $k => $id) {
$files[] = File::getFilesTree(intval($id), $user, 1); $files[] = File::getFilesTree(intval($id), $user, 1);
$totalSize += $files[$k]->totalSize; $totalSize += $files[$k]->totalSize;
} }
});
if ($totalSize > File::zipMaxSize) { if ($totalSize > File::zipMaxSize) {
return Base::retError('文件总大小已超过1GB请分批下载'); abort(403, "The total size of the file exceeds 1GB. Please download it in batches.");
} }
$key = Down::cache_encode([
'file' => $zipFile,
]);
$fileUrl = Base::fillUrl('api/file/download/pack?key=' . $key);
$zip = new \ZipArchive(); $zip = new \ZipArchive();
$zipName = 'temp/download/' . date("Ym") . '/' . $user->userid . '/' . $downName;
$zipPath = storage_path('app/'.$zipName);
Base::makeDir(dirname($zipPath)); Base::makeDir(dirname($zipPath));
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
return Base::retError('创建压缩文件失败'); abort(403, "Failed to create compressed file.");
} }
$userid = $user->userid; go(function() use ($zip, $files, $downName) {
go(function () use ($userid, $zipPath, $fileUrl, $zip, $files, $fileName, $botUser, $dialog) {
Coroutine::sleep(0.1); Coroutine::sleep(0.1);
// 压缩进度 // 压缩进度
$progress = 0; $progress = 0;
$zip->registerProgressCallback(0.05, function ($ratio) use ($userid, $fileUrl, $fileName, &$progress) { $zip->registerProgressCallback(0.05, function($ratio) use ($downName, &$progress) {
$progress = round($ratio * 100); $progress = round($ratio * 100);
File::pushMsgSimple('compress', [ File::filePushMsg('compress', [
'name' => $fileName, 'name'=> $downName,
'url' => $fileUrl,
'progress' => $progress 'progress' => $progress
], $userid); ]);
}); });
//
foreach ($files as $file) { foreach ($files as $file) {
File::addFileTreeToZip($zip, $file); File::addFileTreeToZip($zip, $file);
} }
$zip->close(); $zip->close();
//
if ($progress < 100) { if ($progress < 100) {
File::pushMsgSimple('compress', [ File::filePushMsg('compress', [
'name' => $fileName, 'name'=> $downName,
'url' => $fileUrl,
'progress' => 100 '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, return Base::retSuccess('success');
'url' => $fileUrl, }
]);
/**
* @api {get} api/file/download/confirm 21. 确认下载
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup file
* @apiName download__confirm
*
* @apiParam {String} [name] 下载文件名
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function download__confirm()
{
$user = User::auth();
$downName = Request::input('name');
$zipName = 'temp/download/' . date("Ym") . '/' . $user->userid . '/' . $downName;
$zipPath = storage_path('app/'.$zipName);
if (!file_exists($zipPath)) {
abort(403, "The file does not exist.");
}
return response()->download($zipPath);
} }
} }

View File

@ -1,95 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\Base;
use App\Module\OnlineLicense;
use Request;
/**
* 在线授权客户端(与 SystemController::license 的离线粘贴并存)。
*
* 动态路由routes/web.php
* api/license/email/send -> email__send()
* api/license/login -> login()
* api/license/trial -> trial()
* api/license/status -> status()
* api/license/refresh -> refresh()
* api/license/logout -> logout()
*/
class LicenseController extends AbstractController
{
/**
* 发送邮箱验证码(登录与试用共用)
*/
public function email__send()
{
User::auth('admin');
$email = trim(Request::input('email'));
if ($email === '') {
return Base::retError('请输入邮箱');
}
$masked = OnlineLicense::emailSend($email);
return Base::retSuccess('验证码已发送', ['email' => $masked]);
}
/**
* 邮箱 + 验证码登录并签发在线授权
*/
public function login()
{
User::auth('admin');
$email = trim(Request::input('email'));
$code = trim(Request::input('code'));
if ($email === '' || $code === '') {
return Base::retError('请输入邮箱和验证码');
}
$data = OnlineLicense::login($email, $code);
return Base::retSuccess('授权成功', $data);
}
/**
* 邮箱 + 验证码申请试用并签发
*/
public function trial()
{
User::auth('admin');
$email = trim(Request::input('email'));
$code = trim(Request::input('code'));
if ($email === '' || $code === '') {
return Base::retError('请输入邮箱和验证码');
}
$data = OnlineLicense::trial($email, $code);
return Base::retSuccess('试用已开通', $data);
}
/**
* 当前在线授权状态
*/
public function status()
{
User::auth('admin');
return Base::retSuccess('success', OnlineLicense::status());
}
/**
* 进入授权页时的静默刷新:服务可达则更新授权数据,网络失败则不更新、不提示。
*/
public function refresh()
{
User::auth('admin');
OnlineLicense::refresh();
return Base::retSuccess('success', OnlineLicense::status());
}
/**
* 退出在线授权(释放座位 + 回落默认)
*/
public function logout()
{
User::auth('admin');
OnlineLicense::logout();
return Base::retSuccess('已退出在线授权');
}
}

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

@ -8,25 +8,20 @@ use Request;
use Redirect; use Redirect;
use Response; 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\RandomColor;
use App\Tasks\LoopTask; use App\Tasks\LoopTask;
use App\Module\Extranet;
use App\Tasks\AppPushTask; use App\Tasks\AppPushTask;
use App\Module\RandomColor;
use App\Tasks\JokeSoupTask; use App\Tasks\JokeSoupTask;
use App\Tasks\DeleteTmpTask; use App\Tasks\DeleteTmpTask;
use App\Tasks\EmailNoticeTask; use App\Tasks\EmailNoticeTask;
use App\Tasks\AutoArchivedTask; use App\Tasks\AutoArchivedTask;
use App\Tasks\DeleteBotMsgTask; use App\Tasks\DeleteBotMsgTask;
use App\Tasks\CheckinRemindTask; use App\Tasks\CheckinRemindTask;
use App\Tasks\CloseMeetingRoomTask;
use App\Tasks\ManticoreSyncTask;
use App\Tasks\UnclaimedTaskRemindTask;
use App\Tasks\TodoRemindTask;
use App\Tasks\AiTaskLoopTask;
use Hhxsv5\LaravelS\Swoole\Task\Task; use Hhxsv5\LaravelS\Swoole\Task\Task;
use App\Module\PatchedAvatar as Avatar; use App\Tasks\UnclaimedTaskRemindTask;
use LasseRafn\InitialAvatarGenerator\InitialAvatar;
/** /**
@ -42,8 +37,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 +59,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 +122,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,126 +132,38 @@ 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', ->header('Pragma', 'public')
'width' => $size, ->header('Cache-Control', 'max-age=1814400')
'height' => $size, ->header('Content-type', 'image/png')
'chars' => 2, ->header('Expires', gmdate('D, d M Y H:i:s \G\M\T', time() + 1814400));
'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,
],
]);
$avatar->create($name)->save($file);
return response()->file($file, [
'Pragma' => 'public',
'Cache-Control' => 'max-age=1814400',
'Content-type' => 'image/png',
'Expires' => gmdate('D, d M Y H:i:s \G\M\T', time() + 1814400),
]);
} }
/** /**
@ -256,13 +192,11 @@ 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('file_pack'));
Task::deliver(new DeleteTmpTask('user_device', 24));
Task::deliver(new DeleteTmpTask('umeng_log', 24 * 3));
// 删除机器人消息 // 删除机器人消息
Task::deliver(new DeleteBotMsgTask()); Task::deliver(new DeleteBotMsgTask());
// 周期任务 // 周期任务
@ -273,14 +207,6 @@ class IndexController extends InvokeController
Task::deliver(new JokeSoupTask()); Task::deliver(new JokeSoupTask());
// 未领取任务通知 // 未领取任务通知
Task::deliver(new UnclaimedTaskRemindTask()); 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";
} }
@ -296,127 +222,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 !== config('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);
} }
/** /**
@ -442,77 +321,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;
@ -24,12 +27,29 @@ class InvokeController extends BaseController
if ($action) { if ($action) {
$app .= "__" . $action; $app .= "__" . $action;
} }
// 接口不存在(仅 public 方法可作为端点protected/private 为内部方法,不暴露为路由) // 接口不存在
if (!method_exists($this, $app) || !(new \ReflectionMethod($this, $app))->isPublic()) { if (!method_exists($this, $app)) {
$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();

68
app/Http/Kernel.php Normal file
View File

@ -0,0 +1,68 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'webapi' => \App\Http\Middleware\WebApi::class,
];
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function redirectTo($request)
{
if (! $request->expectsJson()) {
return route('login');
}
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array
*/
protected $except = [
//
];
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array
*/
protected $except = [
//
];
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null ...$guards
* @return mixed
*/
public function handle(Request $request, Closure $next, ...$guards)
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array
*/
protected $except = [
'current_password',
'password',
'password_confirmation',
];
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array
*/
public function hosts()
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Fideloper\Proxy\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
// 接口部分
'api/*',
// 发布桌面端
'desktop/publish/',
];
}

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,16 +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
*/ */
public static array $objectClasses = [ public static $objectClasses = [
'inetOrgPerson',
'organizationalPerson',
'person', 'person',
'top', 'top',
'posixAccount',
]; ];
private static $emailAttrs = ['mail', 'cn', 'uid', 'userPrincipalName'];
/** /**
* @return mixed|null * @return mixed|null
*/ */
@ -66,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();
@ -100,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
@ -119,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
@ -198,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();
@ -244,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;
} }
@ -255,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) {
@ -277,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;
@ -290,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());
@ -299,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

@ -5,7 +5,6 @@ namespace App\Models;
use App\Exceptions\ApiException; use App\Exceptions\ApiException;
use App\Module\Base; use App\Module\Base;
use DateTimeInterface; use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -21,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,46 +33,12 @@ class AbstractModel extends Model
const ID = 'id'; const ID = 'id';
/** protected $dates = [
* 全局日期字段Laravel 10 移除 $dates 属性后改经 getCasts 合并,子模型 $casts 同名键优先)
*/
protected $defaultDatetimeCasts = [
'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',
]; ];
public function getCasts(): array
{
$casts = parent::getCasts();
foreach ($this->defaultDatetimeCasts as $field) {
$casts[$field] ??= 'datetime';
}
return $casts;
}
protected $appendattrs = []; protected $appendattrs = [];
/** /**
@ -164,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
@ -202,66 +150,6 @@ class AbstractModel extends Model
return $instance; return $instance;
} }
/**
* 覆写框架 saveOrIgnore 的底层插入逻辑。
*
* 框架默认走 insertOrIgnoreReturningINSERT ... ON CONFLICT ... RETURNING
* MySQL/MariaDB grammar 不支持该变体,会抛
* "This database engine does not support insert or ignore with returning."
* 这里改用 MySQL 支持的 INSERT IGNORE并在成功插入时手动回填自增ID
* 保持与框架一致的返回语义(冲突被忽略时返回 false)。
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array|string|null $uniqueBy
* @return bool
*/
protected function performInsertOrIgnore(Builder $query, array|string|null $uniqueBy)
{
// MySQL INSERT IGNORE 无法按指定列限制冲突范围,所有 unique 冲突一并吞掉。
// 若调用方传了 $uniqueBy 期望精确 scope这里直接抛错避免与框架语义偷偷不一致。
if ($uniqueBy !== null) {
throw new \InvalidArgumentException('saveOrIgnore $uniqueBy is not supported on MySQL driver; pass null.');
}
if ($this->usesUniqueIds()) {
$this->setUniqueIds();
}
if ($this->fireModelEvent('creating') === false) {
return false;
}
if ($this->usesTimestamps()) {
$this->updateTimestamps();
}
$attributes = $this->getAttributesForInsert();
if (empty($attributes)) {
return true;
}
if ($query->toBase()->insertOrIgnore($attributes) === 0) {
return false;
}
if ($this->getIncrementing()) {
$lastId = $query->getConnection()->getPdo()->lastInsertId();
// 无 auto_increment 列的表上 INSERT IGNORE 即使插入成功 lastInsertId 也返回 "0"
// 别用它去覆盖业务设置的主键。
if ($lastId > 0) {
$this->setAttribute($this->getKeyName(), $lastId);
}
}
$this->exists = true;
$this->wasRecentlyCreated = true;
$this->fireModelEvent('created', false);
return true;
}
/** /**
* 更新数据校验 * 更新数据校验
* @param array $param * @param array $param
@ -301,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,48 +0,0 @@
<?php
namespace App\Models;
/**
* AI 助手回复反馈(👍/👎)
*
* @property int $id
* @property int $userid
* @property string $session_key
* @property string $session_id
* @property int $local_id
* @property string $feedback
* @property string|null $prompt
* @property string $answer_digest
* @property string|null $answer
* @property string|null $source_ids
* @property string $model
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback change($array)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback remove()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereAnswer($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereAnswerDigest($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereFeedback($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereLocalId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereModel($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback wherePrompt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereSessionId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereSessionKey($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereSourceIds($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereUserid($value)
* @mixin \Eloquent
*/
class AiAssistantFeedback extends AbstractModel
{
protected $table = 'ai_assistant_feedbacks';
}

View File

@ -1,50 +0,0 @@
<?php
namespace App\Models;
/**
* AI 助手帮助知识库检索日志
*
* @property int $id
* @property int $userid
* @property int $dialog_id
* @property string $context_key
* @property string $source
* @property string $query
* @property string $locale
* @property string|null $source_ids
* @property float $top_score
* @property int $result_count
* @property int $duration_ms
* @property int $empty
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog change($array)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog remove()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereContextKey($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereDialogId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereDurationMs($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereEmpty($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereLocale($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereQuery($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereResultCount($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereSource($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereSourceIds($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereTopScore($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereUserid($value)
* @mixin \Eloquent
*/
class AiAssistantSearchLog extends AbstractModel
{
protected $table = 'ai_assistant_search_logs';
}

View File

@ -1,42 +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
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession change($array)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession remove()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereImages($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereSceneKey($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereSessionId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereSessionKey($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereUserid($value)
* @mixin \Eloquent
*/
class AiAssistantSession extends AbstractModel
{
protected $table = 'ai_assistant_sessions';
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
/**
* App\Models\ApproveProcMsg
*
* @property int $id
* @property int|null $proc_inst_id 流程实例ID
* @property int|null $userid 会员ID
* @property int|null $msg_id 消息ID
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg query()
* @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 whereMsgId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereProcInstId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereUserid($value)
* @mixin \Eloquent
*/
class ApproveProcMsg extends AbstractModel
{
}

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,7 +92,7 @@ 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',
]; ];
/** /**
@ -132,52 +100,11 @@ class File extends AbstractModel
*/ */
const zipMaxSize = 1024 * 1024 * 1024; // 1G 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 +112,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 +128,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 +166,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 +190,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,13 +238,14 @@ class File extends AbstractModel
} }
} }
// //
$path = 'uploads/tmp/file/' . date("Ym") . '/'; $setting = Base::setting('system');
$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 "size" => ($setting['file_upload_limit'] ?: 0) * 1024
]); ]);
if (Base::isError($data)) { if (Base::isError($data)) {
throw new ApiException($data['msg']); throw new ApiException($data['msg']);
@ -329,9 +256,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 +283,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 +321,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 +336,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 +544,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 +604,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 +647,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 +688,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('文件不存在或已被删除');
} }
@ -1045,39 +919,29 @@ class File extends AbstractModel
} }
/** /**
* 根据文件类型判断是否需要安装应用 * 文件推送消息
* @param $type * @param $action
* @return void * @param array|null $data 发送内容
* @param array $userid 会员ID
*/ */
public static function isNeedInstallApp($type): void public static function filePushMsg($action, $data = null, $userid = null)
{ {
// 文件类型与应用的映射配置 //
$fileTypeAppMapping = [ $userid = User::auth()->userid();
// Office 应用映射 if (empty($userid)) {
[ return;
'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']);
}
} }
//
$msg = [
'type' => 'file',
'action' => $action,
'data' => $data,
];
$params = [
'userid' => $userid,
'msg' => $msg
];
$task = new PushTask($params, false);
Task::deliver($task);
} }
} }

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,149 +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
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure change($array)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure remove()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereAction($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereDataId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereDataType($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereErrorMessage($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereLastRetryAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereRetryCount($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereUpdatedAt($value)
* @mixin \Eloquent
*/
class ManticoreSyncFailure extends AbstractModel
{
protected $table = 'manticore_sync_failures';
protected $fillable = [
'data_type',
'data_id',
'action',
'error_message',
'retry_count',
'last_retry_at',
];
protected $casts = [
'last_retry_at' => 'datetime',
];
/**
* 记录同步失败
*
* @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)
@ -68,10 +54,6 @@ use Request;
* @method static \Illuminate\Database\Eloquent\Builder|Project whereUserid($value) * @method static \Illuminate\Database\Eloquent\Builder|Project whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|Project withTrashed() * @method static \Illuminate\Database\Eloquent\Builder|Project withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|Project withoutTrashed() * @method static \Illuminate\Database\Eloquent\Builder|Project withoutTrashed()
* @property-read array $deputy_userids
* @method static \Illuminate\Database\Eloquent\Builder<static>|Project whereAiAutoAnalyze($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Project whereDepartmentOwnerView($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Project whereTaskTemplateShare($value)
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class Project extends AbstractModel class Project extends AbstractModel
@ -84,7 +66,6 @@ class Project extends AbstractModel
protected $appends = [ protected $appends = [
'owner_userid', 'owner_userid',
'deputy_userids',
]; ];
/** /**
@ -100,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
*/ */
@ -190,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
@ -215,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);
@ -225,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
@ -287,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();
}); });
} }
@ -428,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);
} }
} }
@ -514,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,
@ -565,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;
} }
} }
} }
@ -609,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
@ -657,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) {
@ -714,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('创建项目聊天室失败');
} }
@ -722,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}]'));
} }
}); });
// //
@ -736,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)
@ -756,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)

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,176 +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
* @property-read \App\Models\ProjectTask|null $task
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent change($array)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent remove()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereError($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereEventType($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereExecutedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereMsgId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereResult($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereRetryCount($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereUpdatedAt($value)
* @mixin \Eloquent
*/
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

@ -11,33 +11,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 +44,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 +60,10 @@ 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; $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,20 +73,17 @@ 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); $result = file_put_contents($publicPath, $content);
if(!$result && $oldContent){ if(!$result){
info("保存任务详情至文件失败");
info($publicPath);
info($oldContent);
throw new ApiException("保存任务详情至文件失败,请重试"); throw new ApiException("保存任务详情至文件失败,请重试");
} }
// //

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)

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