mirror of
https://github.com/kuaifan/dootask.git
synced 2026-06-23 15:52:22 +00:00
Compare commits
No commits in common. "pro" and "v0.33.34" have entirely different histories.
@ -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
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/php-stan-check.sh",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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`
|
||||
@ -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
|
||||
@ -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_ID(xxx)已被其他实例使用` → 停止,让用户清空 `.env` 的 `APP_ID`/`APP_IPPR` 再装
|
||||
- `端口 xxx 已被占用` → 停止,让用户换 `--port`
|
||||
- `目录【xxx】权限不足` / 目录权限检测失败 → 这是目录属主/权限问题,引导用户用 **dootask-fix-permission** 技能修复后重装
|
||||
- `安装依赖失败`(composer)→ 报告,交用户决定(常因网络/镜像源)
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 不加 sudo 直接 `./cmd install` | 用 `sudo ./cmd install`(脚本强制 root) |
|
||||
| 失败后"我再试一次"或自动跳过 | 立即停止,交还用户 |
|
||||
| 在已有数据环境上不问就重装 | 先确认会 `migrate --seed`,可能影响现有数据 |
|
||||
| 遇权限报错自己乱 `chmod`/`chown` | 走 dootask-fix-permission 技能统一处理 |
|
||||
| 不问就加 `--relock` | 默认不加;仅用户明确要求或锁损坏时用 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "端口/权限报错了我顺手帮 TA 改一下别的" → 停下,只处理本次报的问题,按指引走对应技能
|
||||
- "种子数据应该没事,直接重装" → 不,先确认是否会覆盖现有数据
|
||||
- "sudo 麻烦,先试试不加" → 不,install 必须 root
|
||||
@ -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` 权限;触发后可挂后台轮询结果。
|
||||
- 选「不发布」则结束。
|
||||
|
||||
## 失败处理
|
||||
|
||||
任何步骤失败立即停止、报告错误信息,交用户决定;不要自动重试或跳过。
|
||||
@ -1,239 +0,0 @@
|
||||
<?php
|
||||
// DooTask 发布——翻译流水线(纯本地 php,host 直接跑,不进容器、不调 OpenAI、不需 autoload)。
|
||||
// 逐行对齐 language/translate.php 的检测/保存/生成逻辑,唯独把"调用外部模型翻译"那一段抽走,
|
||||
// 翻译改在技能流程内完成。用 php 而非 node 的唯一原因:array_multisort + json_encode
|
||||
// 的逐字节产物必须与项目原生工具一致,否则每次发版都会产生大面积排序/转义噪声 diff(已验证 host php 可字节级复现)。
|
||||
//
|
||||
// 子命令:
|
||||
// language.php diff
|
||||
// —— 输出 JSON:needs(待翻译,key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示) / regexErrors(占位符错乱,致命)
|
||||
// language.php apply <translated.json>
|
||||
// —— 把新翻译合并进 translate.json(追加 + 剔除冗余),不生成 public 文件
|
||||
// language.php generate
|
||||
// —— 由 translate.json 重新生成 public/language/{web,api}/*
|
||||
//
|
||||
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
$ROOT = dirname(__DIR__, 4);
|
||||
$LANG_DIR = $ROOT . '/language';
|
||||
$LANG_FIELDS = ['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru'];
|
||||
|
||||
if (!is_dir($LANG_DIR)) {
|
||||
fwrite(STDERR, "未找到 language 目录($LANG_DIR)。\n");
|
||||
exit(1);
|
||||
}
|
||||
chdir($LANG_DIR);
|
||||
|
||||
$cmd = $argv[1] ?? '';
|
||||
|
||||
// ---- 公共:读取 original-*.txt ----
|
||||
function read_generateds(): array
|
||||
{
|
||||
$originals = [];
|
||||
$generateds = [];
|
||||
foreach (['web', 'api'] as $type) {
|
||||
$content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : "";
|
||||
$array = array_values(array_filter(array_unique(explode("\n", $content))));
|
||||
$generateds[$type] = $array;
|
||||
$originals = array_merge($originals, $array);
|
||||
}
|
||||
return [$originals, $generateds];
|
||||
}
|
||||
|
||||
// ---- 公共:构建 translations 映射(normalizedKey -> obj),并收集冗余/占位符错乱 ----
|
||||
function build_translations(array $originals): array
|
||||
{
|
||||
$translations = [];
|
||||
$redundants = [];
|
||||
$regrror = [];
|
||||
if (!file_exists("translate.json")) {
|
||||
fwrite(STDERR, "translate.json not exists\n");
|
||||
exit(1);
|
||||
}
|
||||
$tmps = json_decode(file_get_contents("translate.json"), true);
|
||||
foreach ($tmps as $obj) {
|
||||
if (!isset($obj['key'])) {
|
||||
continue;
|
||||
}
|
||||
$currentKey = $obj['key'];
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
|
||||
if (!in_array($originalKey, $originals)) {
|
||||
$redundants[$originalKey] = $obj;
|
||||
continue;
|
||||
}
|
||||
$translations[$originalKey] = $obj;
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
foreach ($obj as $k => $v) {
|
||||
if (empty($v)) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($v, $match)) {
|
||||
$regrror[$originalKey] = ['key' => $currentKey, 'field' => $k, 'value' => $v, 'match' => $match];
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [$translations, $redundants, $regrror];
|
||||
}
|
||||
|
||||
// ---- 公共:由 translate.json + originals 重新生成 public 文件 ----
|
||||
function generate(array $generateds, array $translations): void
|
||||
{
|
||||
foreach ($generateds as $type => $array) {
|
||||
$datas = [];
|
||||
foreach ($array as $text) {
|
||||
$text = trim($text);
|
||||
if (isset($translations[$text])) {
|
||||
$datas[] = $translations[$text];
|
||||
}
|
||||
}
|
||||
$inOrder = [];
|
||||
foreach ($datas as $index => $item) {
|
||||
if (preg_match('/\(%[TM]\d+\)/', $item['key'])) {
|
||||
$inOrder[$index] = strlen($item['key']);
|
||||
} else {
|
||||
$inOrder[$index] = strlen($item['key']) + 10000000000;
|
||||
}
|
||||
}
|
||||
array_multisort($inOrder, SORT_DESC, $datas);
|
||||
$results = [];
|
||||
foreach ($datas as $items) {
|
||||
foreach ($items as $kk => $item) {
|
||||
$results[$kk][] = $item;
|
||||
}
|
||||
}
|
||||
if ($type === 'api') {
|
||||
if (!is_dir("../public/language/api")) {
|
||||
mkdir("../public/language/api", 0777, true);
|
||||
}
|
||||
foreach ($results as $kk => $item) {
|
||||
file_put_contents("../public/language/api/$kk.json", json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
} elseif ($type === 'web') {
|
||||
if (!is_dir("../public/language/web")) {
|
||||
mkdir("../public/language/web", 0777, true);
|
||||
}
|
||||
foreach ($results as $kk => $item) {
|
||||
file_put_contents("../public/language/web/$kk.js", "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
echo "[$type] total: " . count($results['key']) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($cmd === 'diff') {
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations, $redundants, $regrror] = build_translations($originals);
|
||||
|
||||
// 需要翻译的数据(对齐 translate.php 150-169:占位符按单一计数器编号)
|
||||
$needs = [];
|
||||
foreach ($originals as $text) {
|
||||
$key = trim($text);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($translations[$key])) {
|
||||
$needs[$key] = $key;
|
||||
}
|
||||
}
|
||||
$needsOut = [];
|
||||
foreach ($needs as $key) {
|
||||
$c = 1;
|
||||
$converted = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) {
|
||||
$label = strlen($m[1]) > 1 ? "M" : "T";
|
||||
return "(%" . $label . $c++ . ")";
|
||||
}, $key);
|
||||
$needsOut[] = ['key' => $converted];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'needsCount' => count($needsOut),
|
||||
'redundantCount' => count($redundants),
|
||||
'regexErrorCount' => count($regrror),
|
||||
'needs' => $needsOut,
|
||||
'redundants' => array_keys($redundants),
|
||||
'regexErrors' => array_values($regrror),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
|
||||
if (count($regrror) > 0) {
|
||||
exit(2); // 已有数据占位符错乱,需先修复
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($cmd === 'apply') {
|
||||
$file = $argv[2] ?? '';
|
||||
if ($file === '' || !file_exists($file)) {
|
||||
fwrite(STDERR, "用法:apply <translated.json>(文件不存在)\n");
|
||||
exit(1);
|
||||
}
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations, $redundants, $regrror] = build_translations($originals);
|
||||
if (count($regrror) > 0) {
|
||||
fwrite(STDERR, "translate.json 已有条目占位符错乱,请先修复再发版。\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$incoming = json_decode(file_get_contents($file), true);
|
||||
if (!is_array($incoming)) {
|
||||
fwrite(STDERR, "translated.json 必须是数组\n");
|
||||
exit(1);
|
||||
}
|
||||
$added = 0;
|
||||
foreach ($incoming as $raw) {
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
if (!array_key_exists($f, $raw)) {
|
||||
fwrite(STDERR, "新翻译缺字段 \"$f\":" . json_encode($raw, JSON_UNESCAPED_UNICODE) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
// 占位符完整性:key 里每个 (%T1)/(%M1) 必须出现在每个非空语言值里
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $raw['key'], $m)) {
|
||||
foreach ($m[0] as $match) {
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
if ($f === 'key' || $f === 'zh') {
|
||||
continue;
|
||||
}
|
||||
if (empty($raw[$f])) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($raw[$f], $match)) {
|
||||
fwrite(STDERR, "占位符 $match 在字段 \"$f\" 缺失:{$raw['key']}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 规范化:固定字段顺序 + zh 置空
|
||||
$item = [];
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
$item[$f] = $f === 'zh' ? '' : $raw[$f];
|
||||
}
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $item['key']);
|
||||
$translations[$originalKey] = $item;
|
||||
$added++;
|
||||
}
|
||||
|
||||
// array_values:现有条目(去冗余)在前,新条目追加在后
|
||||
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
echo json_encode([
|
||||
'added' => $added,
|
||||
'total' => count($translations),
|
||||
'droppedRedundant' => count($redundants),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($cmd === 'generate') {
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations] = build_translations($originals);
|
||||
generate($generateds, $translations);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "未知子命令:'$cmd'。可用:diff | apply <file> | generate\n");
|
||||
exit(1);
|
||||
@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// 计算并写入新版本号到 package.json(version + codeVerson),算法对齐 bin/version.js。
|
||||
// 不生成 CHANGELOG(在技能流程内撰写),只输出版本号与 changelog 的提交区间。
|
||||
//
|
||||
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../../..');
|
||||
const pkgFile = path.join(ROOT, 'package.json');
|
||||
const verOffset = 6394; // 版本号偏移量(与 bin/version.js 一致)
|
||||
const codeOffset = 35; // 代码版本号偏移量
|
||||
|
||||
function git(cmd) {
|
||||
return execSync(cmd, { cwd: ROOT, maxBuffer: 1024 * 1024 * 10 }).toString().trim();
|
||||
}
|
||||
|
||||
const verCount = parseInt(git('git rev-list --count HEAD'), 10);
|
||||
const codeCount = parseInt(git("git tag --merged pro -l 'v*' | wc -l"), 10);
|
||||
const num = verOffset + verCount;
|
||||
if (Number.isNaN(num)) {
|
||||
console.error(`版本计算失败:rev-list count=${verCount}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
|
||||
const codeVersion = codeOffset + codeCount;
|
||||
|
||||
let pkg = fs.readFileSync(pkgFile, 'utf8');
|
||||
const prevVersion = (pkg.match(/"version":\s*"(.*?)"/) || [])[1] || '';
|
||||
pkg = pkg.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
|
||||
pkg = pkg.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
|
||||
fs.writeFileSync(pkgFile, pkg, 'utf8');
|
||||
|
||||
// 上一个 release 提交作为 changelog 区间下界
|
||||
let prevReleaseCommit = '';
|
||||
try {
|
||||
prevReleaseCommit = git("git log --grep='^release: v' -n 1 --pretty=format:%H");
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
console.log(JSON.stringify({
|
||||
version,
|
||||
codeVersion,
|
||||
prevVersion,
|
||||
prevReleaseCommit,
|
||||
changelogRange: prevReleaseCommit ? `${prevReleaseCommit}..HEAD` : '(未找到上一个 release 提交,需人工确定区间)',
|
||||
}, null, 2));
|
||||
@ -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` 在
|
||||
@ -10,14 +10,13 @@ APP_URL=http://localhost
|
||||
APP_ID=
|
||||
APP_IPPR=
|
||||
APP_PORT=2222
|
||||
APP_SSL_PORT=
|
||||
APP_DEV_PORT=
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mariadb
|
||||
DB_HOST="${APP_IPPR}.5"
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=dootask
|
||||
DB_USERNAME=dootask
|
||||
@ -34,7 +33,7 @@ SESSION_LIFETIME=120
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_HOST=redis
|
||||
REDIS_HOST="${APP_IPPR}.4"
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
@ -57,6 +56,9 @@ PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
JUKE_KEY_JOKE=
|
||||
JUKE_KEY_SOUP=
|
||||
|
||||
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
|
||||
|
||||
432
.github/workflows/ios-publish.yml
vendored
432
.github/workflows/ios-publish.yml
vendored
@ -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
33
.github/workflows/publish-desktop.yml
vendored
Normal 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
|
||||
|
||||
286
.github/workflows/publish.yml
vendored
286
.github/workflows/publish.yml
vendored
@ -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
|
||||
45
.github/workflows/sync-gitee.yml
vendored
45
.github/workflows/sync-gitee.yml
vendored
@ -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
|
||||
81
.github/workflows/tests.yml
vendored
81
.github/workflows/tests.yml
vendored
@ -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
58
.gitignore
vendored
@ -1,68 +1,28 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
/vendor
|
||||
|
||||
# Build and temporary files
|
||||
/build
|
||||
/public/hot
|
||||
/public/tmp
|
||||
/tmp
|
||||
/backup
|
||||
|
||||
# Uploads and user-generated content
|
||||
/public/summary
|
||||
/public/uploads/*
|
||||
/public/.well-known
|
||||
/public/.user.ini
|
||||
|
||||
# Storage and configuration
|
||||
/config/LICENSE
|
||||
/storage/*.key
|
||||
|
||||
# Environment and configuration
|
||||
/config/LICENSE
|
||||
/vendor
|
||||
/build
|
||||
/tmp
|
||||
._*
|
||||
.env
|
||||
vars.yaml
|
||||
|
||||
# IDE and editor files
|
||||
.cursor/*
|
||||
!.cursor/rules/
|
||||
!.cursor/rules/**
|
||||
.idea
|
||||
.vscode
|
||||
.windsurfrules
|
||||
|
||||
# Development tools
|
||||
.vagrant
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
# Development file
|
||||
/index.html
|
||||
|
||||
# Testing
|
||||
.phpunit.result.cache
|
||||
test.*
|
||||
|
||||
# Logs and debug files
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Lock files
|
||||
dootask.lock
|
||||
test.*
|
||||
package-lock.json
|
||||
|
||||
# Laravel/Swoole specific
|
||||
laravels-timer-process.pid
|
||||
.DS_Store
|
||||
vars.yaml
|
||||
laravels.conf
|
||||
laravels.pid
|
||||
|
||||
# System files
|
||||
._*
|
||||
.DS_Store
|
||||
|
||||
# Documentation
|
||||
README_LOCAL.md
|
||||
|
||||
# playwright
|
||||
.playwright-mcp/
|
||||
/.phpunit.cache
|
||||
|
||||
13
.gitpod.yml
Normal file
13
.gitpod.yml
Normal 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
165
.prefetch
@ -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
|
||||
3891
CHANGELOG.md
3891
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
90
CLAUDE.md
90
CLAUDE.md
@ -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,改完会自己 reload;AI 再跑会争抢进程
|
||||
- `./cmd prod` / `./cmd build` — 发版才用,走 `/release` 流程
|
||||
|
||||
前端代码改动只做 Edit/Write,不要为了"验证"启动 dev server。用户明确说"跑一下 / 出包"时除外。
|
||||
|
||||
### 质量门禁(改完代码必须自查,CI 同步在跑,见 .github/workflows/tests.yml)
|
||||
|
||||
- `./cmd composer stan` — phpstan(level 1 + baseline,存量已封存,新增错误必须清零)
|
||||
- `npm run lint` — ESLint(error 必须为 0;warn 是存量遗留,见 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/`,包含测试环境、测试用例、结果截图等信息
|
||||
|
||||
## 交互规范
|
||||
|
||||
- **提问时附带建议**:当需要向用户提问或请求澄清时,应同时提供具体的建议选项或推荐方案,帮助用户快速决策,而非仅抛出开放式问题
|
||||
|
||||
## 语言偏好
|
||||
|
||||
- 回复一律使用简体中文,除非用户明确要求其他语言
|
||||
162
README.md
162
README.md
@ -1,150 +1,146 @@
|
||||
# DooTask - Open Source Task Management System
|
||||
# Install (Docker)
|
||||
|
||||
English | **[中文文档](./README_CN.md)**
|
||||
|
||||
- [Screenshot Preview](./README_PREVIEW.md)
|
||||
- [Demo Site](http://www.dootask.com/)
|
||||
- [Screenshot Preview](README_PREVIEW.md)
|
||||
- [Demo site](http://www.dootask.com/)
|
||||
|
||||
**QQ Group**
|
||||
|
||||
- Group Number: `546574618`
|
||||
Group No.: `546574618`
|
||||
|
||||
## 📍 Migration from 0.x to 1.x
|
||||
## Setup
|
||||
|
||||
- Please ensure to back up your data before upgrading!
|
||||
- If the upgrade fails, try running `./cmd update` multiple times.
|
||||
- If you encounter "Container xxx not found" during upgrade, run `./cmd reup` and then execute `./cmd update`.
|
||||
- If you see a 502 error after upgrading, run `./cmd reup` to restart the services.
|
||||
- If you encounter "Application 'xxx' not installed" after upgrading, log in with the admin account and install the relevant applications from the App Store.
|
||||
- `Docker v20.10+` & `Docker Compose v2.0+` must be installed
|
||||
- System: `Centos/Debian/Ubuntu/macOS`
|
||||
- Hardware suggestion: 2 cores and above 4G memory
|
||||
|
||||
## Installation Requirements
|
||||
|
||||
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
|
||||
- Supported Systems: `CentOS/Debian/Ubuntu/macOS` and other Linux/Unix systems
|
||||
- Hardware Recommendation: 2+ cores, 4GB+ memory
|
||||
- Database: MariaDB (provided by the default Docker Compose `mariadb` service)
|
||||
- Special Note: Windows users can install Linux environment using WSL2 before installing DooTask.
|
||||
|
||||
### Deploy Project
|
||||
### Deployment (Pro Edition)
|
||||
|
||||
```bash
|
||||
# 1、Clone the project to your local machine or server
|
||||
# 1、Clone the repository
|
||||
|
||||
# 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
|
||||
# Clone projects on github
|
||||
git clone -b pro --depth=1 https://github.com/kuaifan/dootask.git
|
||||
# Or you can use gitee
|
||||
git clone -b pro --depth=1 https://gitee.com/aipaw/dootask.git
|
||||
|
||||
# 2、Enter directory
|
||||
cd dootask
|
||||
|
||||
# 3、One-click installation (Custom port installation: ./cmd install --port 80)
|
||||
# 3、Installation(Custom port installation: ./cmd install --port 2222)
|
||||
./cmd install
|
||||
```
|
||||
|
||||
### Reset Password
|
||||
### Reset password
|
||||
|
||||
```bash
|
||||
# Reset default administrator password
|
||||
# Reset default account password
|
||||
./cmd repassword
|
||||
```
|
||||
|
||||
### Change Port
|
||||
### Change port
|
||||
|
||||
```bash
|
||||
# This method only changes HTTP port. For HTTPS port, please read SSL configuration below
|
||||
./cmd port 80
|
||||
./cmd port 2222
|
||||
```
|
||||
|
||||
### Stop Service
|
||||
### Change App Url
|
||||
|
||||
```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
|
||||
./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
|
||||
|
||||
Please ensure you have installed `NodeJs 20+`
|
||||
### Development compilation
|
||||
|
||||
```bash
|
||||
# Development mode
|
||||
# Development mode, Mac OS only
|
||||
./cmd dev
|
||||
|
||||
# Build project (This is for web client. For desktop apps, refer to ".github/workflows/publish.yml")
|
||||
# Production projects, macOS only
|
||||
./cmd prod
|
||||
```
|
||||
|
||||
### SSL Configuration
|
||||
### Shortcuts for running command
|
||||
|
||||
#### Method 1: Automatic Configuration
|
||||
|
||||
```bash
|
||||
# Run command and follow the prompts
|
||||
./cmd https
|
||||
```bash
|
||||
# You can do this using the following command
|
||||
./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
|
||||
# 1、Add Nginx proxy configuration
|
||||
# 1、Nginx config add
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# 2、Run command (To cancel Nginx proxy configuration: ./cmd https close)
|
||||
./cmd https agent
|
||||
# 2、Running commands in a project
|
||||
./cmd https
|
||||
```
|
||||
|
||||
## Upgrade & Update
|
||||
## Upgrade
|
||||
|
||||
**Note: Please backup your data before upgrading!**
|
||||
**Note: Please back up your data before upgrading!**
|
||||
|
||||
```bash
|
||||
# Method 1: Running commands in a project
|
||||
./cmd update
|
||||
```
|
||||
|
||||
* Please retry if upgrade fails across major versions.
|
||||
* If you encounter 502 errors after upgrade, run `./cmd reup` to restart services.
|
||||
|
||||
## 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
|
||||
# Or method 2: use this method if method 1 fails
|
||||
git pull
|
||||
./cmd mysql backup
|
||||
```
|
||||
|
||||
> `./cmd mysql` is the CLI subcommand name; backups run against the MariaDB container.
|
||||
|
||||
2、Copy the following files and directories from old project to the same paths in new project
|
||||
|
||||
- `Database backup file`
|
||||
- `docker/appstore`
|
||||
- `public/uploads`
|
||||
|
||||
3、Restore database to new project
|
||||
```bash
|
||||
# Run command in the new project
|
||||
./cmd uninstall
|
||||
./cmd install
|
||||
./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
|
||||
# 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
|
||||
```
|
||||
|
||||
### More Commands
|
||||
|
||||
```bash
|
||||
./cmd help
|
||||
```
|
||||
|
||||
21
README_CLIENT.md
Normal file
21
README_CLIENT.md
Normal 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` 根据提示编译
|
||||
|
||||
|
||||
105
README_CN.md
105
README_CN.md
@ -1,44 +1,34 @@
|
||||
# DooTask - 开源任务管理系统
|
||||
# Install (Docker)
|
||||
|
||||
**[English](./README.md)** | 中文文档
|
||||
|
||||
- [截图预览](./README_PREVIEW.md)
|
||||
- [截图预览](README_PREVIEW.md)
|
||||
- [演示站点](http://www.dootask.com/)
|
||||
|
||||
**QQ交流群**
|
||||
|
||||
- QQ群号: `546574618`
|
||||
|
||||
## 📍 0.x 迁移到 1.x
|
||||
|
||||
- 升级时请务必备份好数据!
|
||||
- 如果升级失败请尝试执行 `./cmd update` 重试几次。
|
||||
- 如果升级中出现 `没有找到 xxx 容器` 的提示,请运行 `./cmd reup` 后再执行 `./cmd update`。
|
||||
- 如果升级后出现502错误请运行 `./cmd reup` 重启服务即可。
|
||||
- 如果升级后出现 `应用「xxx」未安装` 的提示,请使用管理员账号进入应用商店安装相关应用。
|
||||
|
||||
## 安装程序
|
||||
|
||||
- 必须安装:`Docker v20.10+` 和 `Docker Compose v2.0+`
|
||||
- 支持环境:`Centos/Debian/Ubuntu/macOS` 等 linux/unix 系统
|
||||
- 支持环境:`Centos/Debian/Ubuntu/macOS`
|
||||
- 硬件建议:2核4G以上
|
||||
- 数据库:MariaDB(默认 Docker Compose 中的 `mariadb` 服务)
|
||||
- 特别说明:Windows 可以使用 WSL2 安装 Linux 环境后再安装 DooTask。
|
||||
|
||||
### 部署项目
|
||||
### 部署项目(Pro版)
|
||||
|
||||
```bash
|
||||
# 1、克隆项目到您的本地或服务器
|
||||
|
||||
# 通过github克隆项目
|
||||
git clone --depth=1 https://github.com/kuaifan/dootask.git
|
||||
git clone -b pro --depth=1 https://github.com/kuaifan/dootask.git
|
||||
# 或者你也可以使用gitee
|
||||
git clone --depth=1 https://gitee.com/aipaw/dootask.git
|
||||
git clone -b pro --depth=1 https://gitee.com/aipaw/dootask.git
|
||||
|
||||
# 2、进入目录
|
||||
cd dootask
|
||||
|
||||
# 3、一键安装项目(自定义端口安装,如:./cmd install --port 80)
|
||||
# 3、一键安装项目(自定义端口安装 ./cmd install --port 2222)
|
||||
./cmd install
|
||||
```
|
||||
|
||||
@ -52,44 +42,54 @@ cd dootask
|
||||
### 更换端口
|
||||
|
||||
```bash
|
||||
# 此方法仅更换http端口,更换https端口请阅读下面SSL配置
|
||||
./cmd port 80
|
||||
./cmd port 2222
|
||||
```
|
||||
|
||||
### 更换URL
|
||||
|
||||
```bash
|
||||
# 此地址仅影响邮件回复功能
|
||||
./cmd url {域名地址}
|
||||
|
||||
# 例如:
|
||||
./cmd url https://domain.com
|
||||
```
|
||||
|
||||
### 停止服务
|
||||
|
||||
```bash
|
||||
./cmd down
|
||||
```
|
||||
./cmd stop
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
./cmd up
|
||||
# 一旦应用程序被设置,无论何时你想要启动服务器(如果它被停止)运行以下命令
|
||||
./cmd start
|
||||
```
|
||||
|
||||
### 开发编译
|
||||
|
||||
请确保你已经安装了 `NodeJs 20+`
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
# 开发模式,仅限macOS
|
||||
./cmd dev
|
||||
|
||||
# 编译项目(这是网页端的,客户端请参考“.github/workflows/publish.yml”文件)
|
||||
# 编译项目,仅限macOS
|
||||
./cmd prod
|
||||
```
|
||||
|
||||
### SSL 配置
|
||||
|
||||
#### 方法1:自动配置
|
||||
### 运行命令的快捷方式
|
||||
|
||||
```bash
|
||||
# 执行指令,根据提示执行即可
|
||||
./cmd https
|
||||
```bash
|
||||
# 你可以使用以下命令来执行
|
||||
./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: 还原数据库)
|
||||
```
|
||||
|
||||
#### 方法2:Nginx 代理配置
|
||||
### NGINX 代理 SSL
|
||||
|
||||
```bash
|
||||
# 1、Nginx 代理配置添加
|
||||
@ -97,8 +97,8 @@ proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# 2、执行指令(如果取消 Nginx 代理配置请运行:./cmd https close)
|
||||
./cmd https agent
|
||||
# 2、在项目下运行命令
|
||||
./cmd https
|
||||
```
|
||||
|
||||
## 升级更新
|
||||
@ -106,45 +106,42 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
**注意:在升级之前请备份好你的数据!**
|
||||
|
||||
```bash
|
||||
# 方法1:在项目下运行命令
|
||||
./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
|
||||
# 在旧的项目下执行指令
|
||||
# 在旧的项目下运行命令
|
||||
./cmd mysql backup
|
||||
```
|
||||
|
||||
> `./cmd mysql` 为 CLI 子命令名称,实际操作的是 MariaDB 容器。
|
||||
|
||||
2、将旧项目以下文件和目录拷贝至新项目同路径位置
|
||||
|
||||
- `数据库备份文件`
|
||||
- `docker/appstore`
|
||||
- `public/uploads`
|
||||
2、将`数据库备份文件`及`public/uploads`目录拷贝至新项目
|
||||
|
||||
3、还原数据库至新项目
|
||||
```bash
|
||||
# 在新的项目下执行指令
|
||||
# 在新的项目下运行命令
|
||||
./cmd mysql recovery
|
||||
```
|
||||
|
||||
## 卸载项目
|
||||
|
||||
```bash
|
||||
# 在项目下运行命令
|
||||
./cmd uninstall
|
||||
```
|
||||
|
||||
### 更多指令
|
||||
|
||||
```bash
|
||||
./cmd help
|
||||
```
|
||||
|
||||
@ -1,31 +1,26 @@
|
||||
# 发布
|
||||
# 发布说明
|
||||
|
||||
## 准备工作
|
||||
## 发布前
|
||||
|
||||
1. 添加环境变量 `APPLEID`、`APPLEIDPASS` 用于公证
|
||||
2. 添加环境变量 `CSC_LINK`、`CSC_KEY_PASSWORD` 用于签名
|
||||
3. 添加环境变量 `GITHUB_TOKEN`、`GITHUB_REPOSITORY` 用于发布到GitHub(GitHub Actions 发布不需要)
|
||||
4. 添加环境变量 `PUBLISH_KEY` 用于发布到私有服务器
|
||||
3. 添加环境变量 `GH_TOKEN`、`GH_REPOSITORY` 用于发布到GitHub
|
||||
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` 作用是生成网页端;
|
||||
- 客户端 (Windows、Mac、Android) 会通过 GitHub Actions 自动生成并发布;所以,如果要自动发布只需要提交git并推送即可;
|
||||
- 如果想手动生成客户端执行 `./cmd electron` 根据提示选择操作。
|
||||
## 编译App
|
||||
|
||||
|
||||
## 编译 App
|
||||
|
||||
```shell
|
||||
./cmd appbuild publish # 编译生成App需要的资源
|
||||
```
|
||||
|
||||
编译完后进入 `resources/mobile` EEUI框架目录内打包 Android 或 iOS 应用(Android 以实现 GitHub Actions 自动发布)
|
||||
1. 执行 `./cmd appbuild` 或 `./cmd appbuild setting` 编译
|
||||
2. 进入 `resources/mobile` eeui框架内打包Android或iOS应用
|
||||
|
||||
35625
_ide_helper.php
35625
_ide_helper.php
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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
41
app/Console/Kernel.php
Normal 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');
|
||||
}
|
||||
}
|
||||
27
app/Events/ServerStartEvent.php
Normal file
27
app/Events/ServerStartEvent.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\WebSocket;
|
||||
use App\Services\RequestContext;
|
||||
use Cache;
|
||||
use Hhxsv5\LaravelS\Swoole\Events\WorkerStartInterface;
|
||||
use Swoole\Http\Server;
|
||||
|
||||
@ -16,16 +16,9 @@ class WorkerStartEvent implements WorkerStartInterface
|
||||
|
||||
public function handle(Server $server, $workerId)
|
||||
{
|
||||
// 仅在Worker进程启动时执行一次初始化代码
|
||||
$initTable = app('swoole')->initFlagTable;
|
||||
if ($initTable->incr('init_flag', 'value') === 1) {
|
||||
$this->handleFirstWorkerTasks();
|
||||
if (isset($server->startMsecTime) && Cache::get("swooleServerStartMsecTime") != $server->startMsecTime) {
|
||||
Cache::forever("swooleServerStartMsecTime", $server->startMsecTime);
|
||||
WebSocket::query()->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function handleFirstWorkerTasks()
|
||||
{
|
||||
WebSocket::query()->delete();
|
||||
RequestContext::clearBaseUrlCache();
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,18 +10,13 @@ class ApiException extends RuntimeException
|
||||
*/
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $writeLog = true;
|
||||
|
||||
/**
|
||||
* ApiException constructor.
|
||||
* @param string|array $msg
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @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'])) {
|
||||
$code = $msg['code'];
|
||||
@ -29,7 +24,6 @@ class ApiException extends RuntimeException
|
||||
$msg = $msg['msg'];
|
||||
}
|
||||
$this->data = $data;
|
||||
$this->writeLog = $writeLog && $code !== -1;
|
||||
parent::__construct($msg, $code);
|
||||
}
|
||||
|
||||
@ -40,12 +34,4 @@ class ApiException extends RuntimeException
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isWriteLog(): bool
|
||||
{
|
||||
return $this->writeLog;
|
||||
}
|
||||
}
|
||||
|
||||
81
app/Exceptions/Handler.php
Normal file
81
app/Exceptions/Handler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
if (!function_exists('asset_main')) {
|
||||
function asset_main($path, $secure = null)
|
||||
{
|
||||
return preg_replace("/^https?:\/\//", "//", app('url')->asset($path, $secure));
|
||||
return preg_replace("/^https*:\/\//", "//", app('url')->asset($path, $secure));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1132
app/Http/Controllers/Api/ApproveController.php
Executable file
1132
app/Http/Controllers/Api/ApproveController.php
Executable file
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
@ -11,14 +11,8 @@ use App\Models\FileContent;
|
||||
use App\Models\FileLink;
|
||||
use App\Models\FileUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecentItem;
|
||||
use App\Module\Base;
|
||||
use App\Module\Down;
|
||||
use App\Module\Lock;
|
||||
use App\Module\Timer;
|
||||
use App\Module\Ihttp;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use Response;
|
||||
use Swoole\Coroutine;
|
||||
use Carbon\Carbon;
|
||||
use Redirect;
|
||||
@ -33,7 +27,7 @@ use ZipArchive;
|
||||
class FileController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/file/lists 获取文件列表
|
||||
* @api {get} api/file/lists 01. 获取文件列表
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -50,13 +44,14 @@ class FileController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$pid = intval(Request::input('pid'));
|
||||
$data = Request::all();
|
||||
$pid = intval($data['pid']);
|
||||
//
|
||||
return Base::retSuccess('success', (new File)->getFileList($user, $pid));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/one 获取单条数据
|
||||
* @api {get} api/file/one 02. 获取单条数据
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -66,14 +61,6 @@ class FileController extends AbstractController
|
||||
* @apiParam {Number|String} id
|
||||
* - Number 文件ID(需要登录)
|
||||
* - String 链接码(不需要登录,用于预览)
|
||||
* @apiParam {String} [with_url] 是否返回文件访问URL
|
||||
* - no: 不返回(默认)
|
||||
* - yes: 返回content_url字段
|
||||
* @apiParam {String} [with_text] 是否提取文件文本内容(用于AI阅读,支持分页)
|
||||
* - no: 不提取(默认)
|
||||
* - yes: 提取文本内容,支持 docx/xlsx/pptx/pdf/txt 等格式
|
||||
* @apiParam {Number} [text_offset] with_text=yes时有效,文本起始位置(字符数),默认0
|
||||
* @apiParam {Number} [text_limit] with_text=yes时有效,文本获取长度(字符数),默认50000,最大200000
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@ -82,15 +69,11 @@ class FileController extends AbstractController
|
||||
public function one()
|
||||
{
|
||||
$id = Request::input('id');
|
||||
$with_url = Request::input('with_url', 'no');
|
||||
$with_text = Request::input('with_text', 'no');
|
||||
$text_offset = intval(Request::input('text_offset', 0));
|
||||
$text_limit = intval(Request::input('text_limit', 50000));
|
||||
//
|
||||
$permission = 0;
|
||||
if (Base::isNumber($id)) {
|
||||
$user = User::auth();
|
||||
$file = File::permissionFind(intval($id), $user, $with_url === 'yes' ? 1 : 0, $permission);
|
||||
$file = File::permissionFind(intval($id), $user, 0, $permission);
|
||||
} elseif ($id) {
|
||||
$fileLink = FileLink::whereCode($id)->first();
|
||||
$file = $fileLink?->file;
|
||||
@ -102,87 +85,25 @@ class FileController extends AbstractController
|
||||
}
|
||||
return Base::retError($msg, $data);
|
||||
}
|
||||
|
||||
// 如果文件不允许游客访问,则需要登录
|
||||
if (!$file->guest_access) {
|
||||
User::auth();
|
||||
}
|
||||
|
||||
$fileLink->increment("num");
|
||||
} else {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
//
|
||||
$array = $file->toArray();
|
||||
$array['permission'] = $permission;
|
||||
|
||||
// 如果请求返回文件URL
|
||||
if ($with_url === 'yes') {
|
||||
$array['content_url'] = FileContent::getFileUrl($file->id);
|
||||
}
|
||||
|
||||
// 如果请求提取文本内容
|
||||
if ($with_text === 'yes') {
|
||||
$array['text_content'] = ManticoreFile::extractFileContentPaginated($file, $text_offset, $text_limit);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $array);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/fetch 通过路径获取文件文本内容
|
||||
* @api {get} api/file/search 03. 搜索文件列表
|
||||
*
|
||||
* @apiDescription 用于 MCP/AI 工具通过文件路径获取内容,支持分页获取大文件
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName fetch
|
||||
*
|
||||
* @apiParam {String} path 文件路径(相对于系统根目录,如 uploads/file/...)
|
||||
* @apiParam {Number} [offset] 起始位置(字符数),默认0
|
||||
* @apiParam {Number} [limit] 获取长度(字符数),默认50000,最大200000
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* - content: 文本内容
|
||||
* - total_length: 完整内容总长度
|
||||
* - offset: 当前起始位置
|
||||
* - limit: 本次获取长度
|
||||
* - has_more: 是否还有更多内容
|
||||
*/
|
||||
public function fetch()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
$path = trim(Request::input('path'));
|
||||
$offset = intval(Request::input('offset', 0));
|
||||
$limit = intval(Request::input('limit', 50000));
|
||||
|
||||
if (empty($path)) {
|
||||
return Base::retError('参数错误:path 不能为空');
|
||||
}
|
||||
|
||||
// 直接传入路径,ManticoreFile 内部处理 URL 解析
|
||||
$result = ManticoreFile::extractFileContentPaginated($path, $offset, $limit);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return Base::retError($result['error']);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/search 搜索文件列表
|
||||
*
|
||||
* @apiDescription 需要token身份(仅搜索文件名,AI 内容搜索请使用 api/search/file)
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName search
|
||||
*
|
||||
* @apiParam {String} [link] 通过分享地址搜索(如:https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==)
|
||||
* @apiParam {String} [key] 关键词
|
||||
* @apiParam {Number} [take] 获取数量(默认:50,最大:100)
|
||||
* @apiParam {String} [link] 通过分享地址搜索(如:https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==)
|
||||
* @apiParam {String} [key] 关键词
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@ -195,7 +116,7 @@ class FileController extends AbstractController
|
||||
$link = trim(Request::input('link'));
|
||||
$key = trim(Request::input('key'));
|
||||
$id = 0;
|
||||
$take = Base::getPaginate(100, 50, 'take');
|
||||
$take = 50;
|
||||
if (preg_match("/\/single\/file\/(.*?)$/i", $link, $match)) {
|
||||
$id = intval(FileLink::whereCode($match[1])->value('file_id'));
|
||||
$take = 1;
|
||||
@ -203,20 +124,13 @@ class FileController extends AbstractController
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索自己的
|
||||
$builder = File::whereUserid($user->userid);
|
||||
if ($id) {
|
||||
$builder->where("id", $id);
|
||||
}
|
||||
if ($key) {
|
||||
if (!$id && Base::isNumber($key)) {
|
||||
$builder->where(function ($query) use ($key) {
|
||||
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
}
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
}
|
||||
$array = $builder->take($take)->get()->toArray();
|
||||
// 搜索共享的
|
||||
@ -235,13 +149,7 @@ class FileController extends AbstractController
|
||||
$builder->where("id", $id);
|
||||
}
|
||||
if ($key) {
|
||||
if (Base::isNumber($key)) {
|
||||
$builder->where(function ($query) use ($key) {
|
||||
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
}
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
}
|
||||
$list = $builder->take($take)->get();
|
||||
if ($list->isNotEmpty()) {
|
||||
@ -259,7 +167,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/add 添加、修改文件(夹)
|
||||
* @api {get} api/file/add 04. 添加、修改文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -285,8 +193,8 @@ class FileController extends AbstractController
|
||||
$pid = intval(Request::input('pid'));
|
||||
if (mb_strlen($name) < 2) {
|
||||
return Base::retError('文件名称不可以少于2个字');
|
||||
} elseif (mb_strlen($name) > 100) {
|
||||
return Base::retError('文件名称最多只能设置100个字');
|
||||
} elseif (mb_strlen($name) > 32) {
|
||||
return Base::retError('文件名称最多只能设置32个字');
|
||||
}
|
||||
$tmpName = preg_replace("/[\\\\\/:*?\"<>|]/", '', $name);
|
||||
if ($tmpName != $name) {
|
||||
@ -368,7 +276,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/copy 复制文件(夹)
|
||||
* @api {get} api/file/copy 05. 复制文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -391,7 +299,6 @@ class FileController extends AbstractController
|
||||
//
|
||||
$userid = $user->userid;
|
||||
if ($row->pid > 0) {
|
||||
File::permissionFind($row->pid, $user, 1);
|
||||
$userid = intval(File::whereId($row->pid)->value('userid'));
|
||||
}
|
||||
//
|
||||
@ -429,7 +336,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/move 移动文件(夹)
|
||||
* @api {get} api/file/move 06. 移动文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -476,7 +383,7 @@ class FileController extends AbstractController
|
||||
throw new ApiException("{$file->name} 内含有共享文件,无法移动到另一个共享文件夹内");
|
||||
}
|
||||
$file->userid = $toShareFile->userid;
|
||||
$file->updateChildFilesUserid($toShareFile->userid);
|
||||
File::where('pids', 'LIKE', "%,{$file->id},%")->update(['userid' => $toShareFile->userid]);
|
||||
}
|
||||
//
|
||||
$tmpId = $pid;
|
||||
@ -488,7 +395,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
} else {
|
||||
$file->userid = $user->userid;
|
||||
$file->updateChildFilesUserid($user->userid);
|
||||
File::where('pids', 'LIKE', "%,{$file->id},%")->update(['userid' => $user->userid]);
|
||||
}
|
||||
//
|
||||
$file->pid = $pid;
|
||||
@ -504,7 +411,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/remove 删除文件(夹)
|
||||
* @api {get} api/file/remove 07. 删除文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -543,7 +450,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content 获取文件内容
|
||||
* @api {get} api/file/content 08. 获取文件内容
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -591,10 +498,6 @@ class FileController extends AbstractController
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
//
|
||||
if ($down == 'no') {
|
||||
File::isNeedInstallApp($file->type);
|
||||
}
|
||||
//
|
||||
if ($only_update_at == 'yes') {
|
||||
return Base::retSuccess('success', [
|
||||
'id' => $file->id,
|
||||
@ -607,16 +510,6 @@ class FileController extends AbstractController
|
||||
$builder->whereId($history_id);
|
||||
}
|
||||
$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') {
|
||||
return Redirect::to(FileContent::formatPreview($file, $content?->content));
|
||||
}
|
||||
@ -624,7 +517,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/save 保存文件内容
|
||||
* @api {get} api/file/content/save 09. 保存文件内容
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -664,7 +557,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
}
|
||||
$text = strip_tags($data['content']);
|
||||
if ($isRep) {
|
||||
if ($isRep == true) {
|
||||
$content = Base::array2json($data);
|
||||
}
|
||||
}
|
||||
@ -679,12 +572,10 @@ class FileController extends AbstractController
|
||||
$contentArray = Base::json2array($content);
|
||||
$contentString = $contentArray['xml'];
|
||||
$file->ext = 'drawio';
|
||||
File::isNeedInstallApp($file->type);
|
||||
break;
|
||||
case 'mind':
|
||||
$contentString = $content;
|
||||
$file->ext = 'mind';
|
||||
File::isNeedInstallApp($file->type);
|
||||
break;
|
||||
case 'txt':
|
||||
case 'code':
|
||||
@ -719,9 +610,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
|
||||
* @apiGroup file
|
||||
* @apiName office__token
|
||||
@ -734,20 +625,17 @@ class FileController extends AbstractController
|
||||
*/
|
||||
public function office__token()
|
||||
{
|
||||
File::isNeedInstallApp('office');
|
||||
User::auth();
|
||||
//
|
||||
$config = Request::input('config');
|
||||
if (!is_array($config)) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
$token = \Firebase\JWT\JWT::encode($config, config('app.key') ,'HS256');
|
||||
$token = \Firebase\JWT\JWT::encode($config, env('APP_KEY') ,'HS256');
|
||||
return Base::retSuccess('成功', [
|
||||
'token' => $token
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/office 保存文件内容(office)
|
||||
* @api {get} api/file/content/office 11. 保存文件内容(office)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -764,8 +652,6 @@ class FileController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
File::isNeedInstallApp('office');
|
||||
//
|
||||
$id = intval(Request::input('id'));
|
||||
$status = intval(Request::input('status'));
|
||||
$key = Request::input('key');
|
||||
@ -775,7 +661,7 @@ class FileController extends AbstractController
|
||||
//
|
||||
if ($status === 2) {
|
||||
$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;
|
||||
$save = public_path($path);
|
||||
Base::makeDir(dirname($save));
|
||||
@ -803,7 +689,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/upload 保存文件内容(上传文件)
|
||||
* @api {get} api/file/content/upload 12. 保存文件内容(上传文件)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -811,9 +697,6 @@ class FileController extends AbstractController
|
||||
* @apiName content__upload
|
||||
*
|
||||
* @apiParam {Number} [pid] 父级ID
|
||||
* @apiParam {Number} [cover] 覆盖已存在的文件
|
||||
* - 0:不覆盖,保留两者(默认)
|
||||
* - 1:覆盖
|
||||
* @apiParam {String} [files] 文件名
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
@ -824,24 +707,13 @@ class FileController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
$pid = intval(Request::input('pid'));
|
||||
// 同一用户往相同父目录上传时排队,避免并发导致数据库死锁
|
||||
try {
|
||||
return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid) {
|
||||
$overwrite = intval(Request::input('cover'));
|
||||
$webkitRelativePath = Request::input('webkitRelativePath');
|
||||
$data = (new File)->contentUpload($user, $pid, $webkitRelativePath, $overwrite);
|
||||
return Base::retSuccess($data['data']['name'] . ' 上传成功', $data['addItem']);
|
||||
}, 120000, 120000);
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
|
||||
throw new ApiException('上传繁忙,请稍后再试');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
$webkitRelativePath = Request::input('webkitRelativePath');
|
||||
$data = (new File)->contentUpload($user, $pid, $webkitRelativePath);
|
||||
return Base::retSuccess($data['data']['name'] . ' 上传成功', $data['addItem']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/history 获取内容历史
|
||||
* @api {get} api/file/content/history 13. 获取内容历史
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -873,7 +745,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/restore 恢复文件历史
|
||||
* @api {get} api/file/content/restore 14. 恢复文件历史
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -896,8 +768,6 @@ class FileController extends AbstractController
|
||||
//
|
||||
$file = File::permissionFind($id, $user);
|
||||
//
|
||||
File::isNeedInstallApp($file->type);
|
||||
//
|
||||
$history = FileContent::whereFid($file->id)->whereId($history_id)->first();
|
||||
if (empty($history)) {
|
||||
return Base::retError('历史数据不存在或已被删除');
|
||||
@ -915,7 +785,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/share 获取共享信息
|
||||
* @api {get} api/file/share 15. 获取共享信息
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -951,7 +821,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/share/update 设置共享
|
||||
* @api {get} api/file/share/update 16. 设置共享
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -1041,7 +911,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/share/out 退出共享
|
||||
* @api {get} api/file/share/out 17. 退出共享
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -1075,7 +945,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/link 获取链接
|
||||
* @api {get} api/file/link 18. 获取链接
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -1086,9 +956,6 @@ class FileController extends AbstractController
|
||||
* @apiParam {String} refresh 刷新链接
|
||||
* - no: 只获取(默认)
|
||||
* - yes: 刷新链接,之前的将失效
|
||||
* @apiParam {String} guest_access 是否允许游客访问
|
||||
* - no: 不允许(默认)
|
||||
* - yes: 允许游客访问
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@ -1100,22 +967,15 @@ class FileController extends AbstractController
|
||||
//
|
||||
$id = intval(Request::input('id'));
|
||||
$refresh = Request::input('refresh', 'no');
|
||||
$guestAccess = Request::input('guest_access', 'no');
|
||||
//
|
||||
$file = File::permissionFind($id, $user);
|
||||
|
||||
// 更新文件的游客访问权限
|
||||
$file->guest_access = $guestAccess === 'yes' ? 1 : 0;
|
||||
$file->save();
|
||||
|
||||
$fileLink = $file->getShareLink($user->userid, $refresh == 'yes');
|
||||
$fileLink['guest_access'] = $file->guest_access;
|
||||
//
|
||||
return Base::retSuccess('success', $fileLink);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/download/pack 打包文件
|
||||
* @api {get} api/file/download/pack 19. 打包文件
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@ -1131,49 +991,9 @@ class FileController extends AbstractController
|
||||
*/
|
||||
public function download__pack()
|
||||
{
|
||||
if (Request::has('key')) {
|
||||
$array = Down::cache_decode();
|
||||
$file = $array['file'];
|
||||
if (empty($file) || !file_exists(storage_path($file))) {
|
||||
return Base::ajaxError("文件不存在!", [], 0, 403);
|
||||
}
|
||||
return Response::download(storage_path($file));
|
||||
}
|
||||
|
||||
$user = User::auth();
|
||||
if ($user->isTemp()) {
|
||||
return Base::retError('无法打包下载');
|
||||
}
|
||||
$setting = Base::setting('fileSetting');
|
||||
switch ($setting['permission_pack_type']) {
|
||||
case 'admin':
|
||||
if (!$user->isAdmin()) {
|
||||
return Base::retError('此功能仅管理员可用');
|
||||
}
|
||||
break;
|
||||
case 'appointAllow':
|
||||
if (!in_array($user->userid, $setting['permission_pack_userids'])) {
|
||||
return Base::retError('此功能仅指定用户可用');
|
||||
}
|
||||
break;
|
||||
case 'appointProhibit':
|
||||
if (in_array($user->userid, $setting['permission_pack_userids'])) {
|
||||
return Base::retError('此功能已禁止使用');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$ids = Request::input('ids');
|
||||
$fileName = Request::input('name');
|
||||
$fileName = preg_replace("/[\/\\\:\*\?\"\<\>\|]/", "", $fileName);
|
||||
if (empty($fileName)) {
|
||||
$fileName = 'Package_' . $user->userid;
|
||||
}
|
||||
$fileName .= '_' . Timer::time() . '.zip';
|
||||
|
||||
$filePath = "temp/file/pack/" . date("Ym", Timer::time());
|
||||
$zipFile = "app/" . $filePath . "/" . $fileName;
|
||||
$zipPath = storage_path($zipFile);
|
||||
$downName = Request::input('name');
|
||||
|
||||
if (!is_array($ids) || empty($ids)) {
|
||||
return Base::retError('请选择下载的文件或文件夹');
|
||||
@ -1181,12 +1001,9 @@ class FileController extends AbstractController
|
||||
if (count($ids) > 100) {
|
||||
return Base::retError('一次最多可以下载100个文件或文件夹');
|
||||
}
|
||||
|
||||
$botUser = User::botGetOrCreate('system-msg');
|
||||
if (empty($botUser)) {
|
||||
return Base::retError('系统机器人不存在');
|
||||
if (count($ids) > 100) {
|
||||
return Base::retError('一次最多可以下载100个文件或文件夹');
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
|
||||
|
||||
$files = [];
|
||||
$totalSize = 0;
|
||||
@ -1200,30 +1017,25 @@ class FileController extends AbstractController
|
||||
return Base::retError('文件总大小已超过1GB,请分批下载');
|
||||
}
|
||||
|
||||
$key = Down::cache_encode([
|
||||
'file' => $zipFile,
|
||||
]);
|
||||
$fileUrl = Base::fillUrl('api/file/download/pack?key=' . $key);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
$zipName = 'tmp/file/' . date("Ym") . '/' . $user->userid . '/' . $downName;
|
||||
$zipPath = public_path($zipName);
|
||||
Base::makeDir(dirname($zipPath));
|
||||
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
return Base::retError('创建压缩文件失败');
|
||||
}
|
||||
|
||||
$userid = $user->userid;
|
||||
go(function () use ($userid, $zipPath, $fileUrl, $zip, $files, $fileName, $botUser, $dialog) {
|
||||
go(function() use ($user, $zip, $files, $downName, $zipName) {
|
||||
Coroutine::sleep(0.1);
|
||||
// 压缩进度
|
||||
$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);
|
||||
File::pushMsgSimple('compress', [
|
||||
'name' => $fileName,
|
||||
'url' => $fileUrl,
|
||||
File::filePushMsg('compress', [
|
||||
'name'=> $downName,
|
||||
'progress' => $progress
|
||||
], $userid);
|
||||
]);
|
||||
});
|
||||
//
|
||||
foreach ($files as $file) {
|
||||
@ -1232,24 +1044,52 @@ class FileController extends AbstractController
|
||||
$zip->close();
|
||||
//
|
||||
if ($progress < 100) {
|
||||
File::pushMsgSimple('compress', [
|
||||
'name' => $fileName,
|
||||
'url' => $fileUrl,
|
||||
File::filePushMsg('compress', [
|
||||
'name'=> $downName,
|
||||
'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);
|
||||
$botUser = User::botGetOrCreate('system-msg');
|
||||
if (empty($botUser)) {
|
||||
return;
|
||||
}
|
||||
if ($dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid)) {
|
||||
$text = "<b>文件下载打包已完成。</b>";
|
||||
$text .= "\n\n";
|
||||
$text .= "文件名:{$downName}";
|
||||
$text .= "\n";
|
||||
$text .= "下载地址:".Base::fillUrl($zipName);
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'text', ['text' => $text], $botUser->userid, false, false, true);
|
||||
}
|
||||
});
|
||||
return Base::retSuccess('success', [
|
||||
'name' => $fileName,
|
||||
'url' => $fileUrl,
|
||||
]);
|
||||
|
||||
return Base::retSuccess('success');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/download/confirm 20. 确认下载
|
||||
*
|
||||
* @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 = 'tmp/file/' . date("Ym") . '/' . $user->userid . '/' . $downName;
|
||||
$zipPath = public_path($zipName);
|
||||
if (!file_exists($zipPath)) {
|
||||
abort(403, "The file does not exist.");
|
||||
}
|
||||
return response()->download($zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -2,8 +2,15 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
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 Cache;
|
||||
use Carbon\Carbon;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
@ -65,9 +72,7 @@ class PublicController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* {post} 签到 - 上报
|
||||
* - 1、路由器(openwrt)签到上报
|
||||
* - 2、考勤机签到上报
|
||||
* {post} 签到 - 路由器(openwrt)上报
|
||||
*
|
||||
* @apiParam {String} key
|
||||
* @apiParam {String} mac 使用逗号分割多个
|
||||
@ -80,30 +85,20 @@ class PublicController extends AbstractController
|
||||
$key = trim(Request::input('key'));
|
||||
$mac = trim(Request::input('mac'));
|
||||
$time = intval(Request::input('time'));
|
||||
$type = trim(Request::input('type'));
|
||||
//
|
||||
$setting = Base::setting('checkinSetting');
|
||||
if ($setting['open'] !== 'open') {
|
||||
return 'function off';
|
||||
}
|
||||
$alreadyTip = false;
|
||||
if ($type === 'face') {
|
||||
if (!in_array('face', $setting['modes'])) {
|
||||
return 'mode off';
|
||||
}
|
||||
if ($key != $setting['face_key']) {
|
||||
return 'key error';
|
||||
}
|
||||
$alreadyTip = $setting['face_retip'] === 'open';
|
||||
} else {
|
||||
if (!in_array('auto', $setting['modes'])) {
|
||||
return 'mode off';
|
||||
}
|
||||
if ($key != $setting['key']) {
|
||||
return 'key error';
|
||||
}
|
||||
if (!in_array('auto', $setting['modes'])) {
|
||||
return 'mode off';
|
||||
}
|
||||
if ($key != $setting['key']) {
|
||||
return 'key error';
|
||||
}
|
||||
if ($error = UserBot::checkinBotCheckin($mac, $time)) {
|
||||
return $error;
|
||||
}
|
||||
UserBot::checkinBotCheckin($mac, $time, $alreadyTip);
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,16 +6,14 @@ use App\Exceptions\ApiException;
|
||||
use App\Models\AbstractModel;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\Report;
|
||||
use App\Models\ReportAnalysis;
|
||||
use App\Models\ReportLink;
|
||||
use App\Models\ReportReceive;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Tasks\PushTask;
|
||||
use Carbon\Carbon;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Request;
|
||||
@ -29,15 +27,13 @@ use Illuminate\Support\Facades\Validator;
|
||||
class ReportController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/report/my 我发送的汇报
|
||||
* @api {get} api/report/my 01. 我发送的汇报
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup report
|
||||
* @apiName my
|
||||
*
|
||||
* @apiParam {Object} [keys] 搜索条件
|
||||
* - keys.key: 关键词
|
||||
* - keys.type: 汇报类型,weekly:周报,daily:日报
|
||||
* - keys.created_at: 汇报时间
|
||||
* @apiParam {Number} [page] 当前页,默认:1
|
||||
@ -51,31 +47,15 @@ class ReportController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$builder = Report::with(['receivesUser'])
|
||||
->select(Report::LIST_FIELDS)
|
||||
->whereUserid($user->userid);
|
||||
$builder = Report::with(['receivesUser'])->whereUserid($user->userid);
|
||||
$keys = Request::input('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])) {
|
||||
$builder->whereType($keys['type']);
|
||||
}
|
||||
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'][1] > 0) $builder->where('created_at', '<=', Base::newCarbon($keys['created_at'][1])->endOfDay());
|
||||
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', '<=', date('Y-m-d H:i:s', Base::dayTimeE($keys['created_at'][1])));
|
||||
}
|
||||
}
|
||||
$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
|
||||
* @apiGroup report
|
||||
* @apiName receive
|
||||
*
|
||||
* @apiParam {Object} [keys] 搜索条件
|
||||
* - keys.key: 关键词
|
||||
* - keys.department_id: 部门ID
|
||||
* - keys.type: 汇报类型,weekly:周报,daily:日报
|
||||
* - keys.status: 状态,unread:未读,read:已读
|
||||
* - keys.created_at: 汇报时间
|
||||
* @apiParam {Number} [page] 当前页,默认:1
|
||||
* @apiParam {Number} [pagesize] 每页显示数量,默认:20,最大:50
|
||||
@ -106,59 +83,39 @@ class ReportController extends AbstractController
|
||||
public function receive(): array
|
||||
{
|
||||
$user = User::auth();
|
||||
$builder = Report::with(['receivesUser'])
|
||||
->select(Report::LIST_FIELDS);
|
||||
$builder = Report::with(['receivesUser']);
|
||||
$builder->whereHas("receivesUser", function ($query) use ($user) {
|
||||
$query->where("report_receives.userid", $user->userid);
|
||||
});
|
||||
$keys = Request::input('keys');
|
||||
if (is_array($keys)) {
|
||||
if ($keys['key']) {
|
||||
if (str_contains($keys['key'], '@')) {
|
||||
$builder->whereHas('sendUser', function ($q2) use ($keys) {
|
||||
$builder->where(function($query) use ($keys) {
|
||||
$query->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("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']},%");
|
||||
})->orWhere("title", "LIKE", "%{$keys['key']}%");
|
||||
});
|
||||
}
|
||||
if (in_array($keys['type'], [Report::WEEKLY, Report::DAILY])) {
|
||||
$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 ($keys['created_at'][0] > 0) $builder->where('created_at', '>=', Base::newCarbon($keys['created_at'][0])->startOfDay());
|
||||
if ($keys['created_at'][1] > 0) $builder->where('created_at', '<=', Base::newCarbon($keys['created_at'][1])->endOfDay());
|
||||
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', '<=', date('Y-m-d H:i:s', Base::dayTimeE($keys['created_at'][1])));
|
||||
}
|
||||
}
|
||||
$list = $builder->orderByDesc('created_at')->paginate(Base::getPaginate(50, 20));
|
||||
if ($list->items()) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/store 保存并发送工作汇报
|
||||
* @api {get} api/report/store 03. 保存并发送工作汇报
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup report
|
||||
* @apiName store
|
||||
@ -218,7 +175,7 @@ class ReportController extends AbstractController
|
||||
|
||||
foreach ($input["receive"] as $userid) {
|
||||
$input["receive_content"][] = [
|
||||
"receive_at" => Carbon::now()->toDateTimeString(),
|
||||
"receive_time" => Carbon::now()->toDateTimeString(),
|
||||
"userid" => $userid,
|
||||
"read" => 0,
|
||||
];
|
||||
@ -234,6 +191,7 @@ class ReportController extends AbstractController
|
||||
$report->updateInstance([
|
||||
"title" => $input["title"],
|
||||
"type" => $input["type"],
|
||||
"content" => htmlspecialchars($input["content"]),
|
||||
]);
|
||||
} else {
|
||||
// 生成唯一标识
|
||||
@ -247,25 +205,11 @@ class ReportController extends AbstractController
|
||||
"title" => $input["title"],
|
||||
"type" => $input["type"],
|
||||
"userid" => $user->userid,
|
||||
"content" => htmlspecialchars($input["content"]),
|
||||
]);
|
||||
}
|
||||
$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();
|
||||
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
|
||||
* @apiGroup report
|
||||
* @apiName template
|
||||
@ -317,7 +260,6 @@ class ReportController extends AbstractController
|
||||
$offset = abs(intval(Request::input("offset", 0)));
|
||||
$id = intval(Request::input("offset", 0));
|
||||
$now_dt = trim(Request::input("date")) ? Carbon::parse(Request::input("date")) : Carbon::now();
|
||||
|
||||
// 获取开始时间
|
||||
if ($type === Report::DAILY) {
|
||||
$start_time = Carbon::today();
|
||||
@ -339,18 +281,9 @@ class ReportController extends AbstractController
|
||||
$start_time->startOfWeek();
|
||||
$end_time = Carbon::instance($start_time)->endOfWeek();
|
||||
}
|
||||
// 周报时预计算下一周期时间范围(下周)
|
||||
$next_start_time = null;
|
||||
$next_end_time = null;
|
||||
if ($type === Report::WEEKLY) {
|
||||
$next_start_time = Carbon::instance($start_time)->copy()->addWeek();
|
||||
$next_end_time = Carbon::instance($end_time)->copy()->addWeek();
|
||||
}
|
||||
|
||||
// 生成唯一标识
|
||||
$sign = Report::generateSign($type, 0, Carbon::instance($start_time));
|
||||
$one = Report::whereSign($sign)->whereType($type)->first();
|
||||
|
||||
// 如果已经提交了相关汇报
|
||||
if ($one && $id > 0) {
|
||||
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()
|
||||
->whereNotNull("complete_at")
|
||||
->whereBetween("complete_at", [$start_time->toDateTimeString(), $end_time->toDateTimeString()])
|
||||
@ -381,109 +306,57 @@ class ReportController extends AbstractController
|
||||
->get();
|
||||
if ($complete_task->isNotEmpty()) {
|
||||
foreach ($complete_task as $task) {
|
||||
// 排除取消态任务:不将已取消任务计入“已完成工作”
|
||||
if (ProjectTask::isCanceledFlowName($task->flow_item_name)) {
|
||||
continue;
|
||||
}
|
||||
$complete_at = Carbon::parse($task->complete_at);
|
||||
$remark = $type == Report::WEEKLY ? ('<div style="text-align:center">[' . Doo::translate('周' . ['日', '一', '二', '三', '四', '五', '六'][$complete_at->dayOfWeek]) . ']</div>') : ' ';
|
||||
$completeDatas[] = [
|
||||
$task->project->name,
|
||||
$task->name,
|
||||
$task->taskUser->where("owner", 1)->map(function ($item) {
|
||||
return User::userid2nickname($item->userid);
|
||||
})->implode(", "),
|
||||
$remark,
|
||||
];
|
||||
$pre = $type == Report::WEEKLY ? ('<span>[' . Doo::translate('周' . ['日', '一', '二', '三', '四', '五', '六'][$complete_at->dayOfWeek]) . ']</span> ') : '';
|
||||
$completeContent .= "<li>{$pre}[{$task->project->name}] {$task->name}</li>";
|
||||
}
|
||||
} else {
|
||||
$completeContent = '<li> </li>';
|
||||
}
|
||||
|
||||
// 未完成的任务
|
||||
$unfinishedDatas = [];
|
||||
$unfinished_task = ProjectTask::buildUnfinishedTaskQuery($user->userid, $start_time, $end_time, true)->get();
|
||||
$unfinishedContent = "";
|
||||
$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()) {
|
||||
foreach ($unfinished_task as $task) {
|
||||
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>' : ' ';
|
||||
$unfinishedDatas[] = [
|
||||
$task->project->name,
|
||||
$task->name,
|
||||
$task->taskUser->where("owner", 1)->map(function ($item) {
|
||||
return User::userid2nickname($item->userid);
|
||||
})->implode(", "),
|
||||
$remark,
|
||||
];
|
||||
$pre = (!empty($end_at) && $end_at->lt($now_dt)) ? '<span style="color:#ff0000;">[' . Doo::translate('超期') . ']</span> ' : '';
|
||||
$unfinishedContent .= "<li>{$pre}[{$task->project->name}] {$task->name}</li>";
|
||||
}
|
||||
} else {
|
||||
$unfinishedContent = '<li> </li>';
|
||||
}
|
||||
|
||||
// 生成标题
|
||||
if ($type === Report::WEEKLY) {
|
||||
$title = $user->nickname . "的周报[" . $start_time->format("m/d") . "-" . $end_time->format("m/d") . "]";
|
||||
$title .= "[" . $start_time->month . "月第" . $start_time->weekOfMonth . "周]";
|
||||
$unfinishedTitle = '本周未完成的工作';
|
||||
} else {
|
||||
$title = $user->nickname . "的日报[" . $start_time->format("Y/m/d") . "]";
|
||||
$unfinishedTitle = '今日未完成的工作';
|
||||
}
|
||||
$title = Doo::translate($title);
|
||||
|
||||
// 生成内容
|
||||
$contents = [];
|
||||
$contents[] = '<h2>' . Doo::translate('已完成工作') . '</h2>';
|
||||
$contents[] = view('report', [
|
||||
'labels' => $labels,
|
||||
'datas' => $completeDatas,
|
||||
])->render();
|
||||
|
||||
$contents[] = '<p> </p>';
|
||||
$contents[] = '<h2>' . Doo::translate($unfinishedTitle) . '</h2>';
|
||||
$contents[] = view('report', [
|
||||
'labels' => $labels,
|
||||
'datas' => $unfinishedDatas,
|
||||
])->render();
|
||||
|
||||
$content = '<h2>' . Doo::translate('已完成工作') . '</h2><ol>' .
|
||||
$completeContent . '</ol><h2>' .
|
||||
Doo::translate('未完成的工作') . '</h2><ol>' .
|
||||
$unfinishedContent . '</ol>';
|
||||
if ($type === Report::WEEKLY) {
|
||||
// 下周拟定计划:基于下周时间范围预生成候选任务
|
||||
$nextPlanDatas = [];
|
||||
if ($next_start_time && $next_end_time) {
|
||||
$next_tasks = ProjectTask::buildUnfinishedTaskQuery($user->userid, $next_start_time, $next_end_time, false)->get();
|
||||
if ($next_tasks->isNotEmpty()) {
|
||||
foreach ($next_tasks as $task) {
|
||||
$planTime = '-';
|
||||
if ($task->start_at || $task->end_at) {
|
||||
$startText = $task->start_at ? Carbon::parse($task->start_at)->format('Y-m-d H:i') : '';
|
||||
$endText = $task->end_at ? Carbon::parse($task->end_at)->format('Y-m-d H:i') : '';
|
||||
$planTime = trim($startText . ($endText ? (' ~ ' . $endText) : ''));
|
||||
}
|
||||
$nextPlanDatas[] = [
|
||||
'[' . $task->project->name . '] ' . $task->name,
|
||||
$planTime,
|
||||
$task->taskUser->where("owner", 1)->map(function ($item) {
|
||||
return User::userid2nickname($item->userid);
|
||||
})->implode(", "),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$contents[] = '<p> </p>';
|
||||
$contents[] = "<h2>" . Doo::translate("下周拟定计划") . "[" . $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();
|
||||
$content .= "<h2>" . Doo::translate("下周拟定计划") . "[" . $start_time->addWeek()->format("m/d") . "-" . $end_time->addWeek()->format("m/d") . "]</h2><ol><li> </li></ol>";
|
||||
}
|
||||
|
||||
$data = [
|
||||
"time" => $start_time->toDateTimeString(),
|
||||
"sign" => $sign,
|
||||
"title" => $title,
|
||||
"content" => implode("", $contents),
|
||||
"content" => $content,
|
||||
"complete_task" => $complete_task,
|
||||
"unfinished_task" => $unfinished_task,
|
||||
];
|
||||
|
||||
if ($one) {
|
||||
$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
|
||||
* @apiGroup report
|
||||
* @apiName detail
|
||||
*
|
||||
* @apiParam {Number} [id] 报告ID
|
||||
* @apiParam {String} [code] 报告分享代码,与ID二选一,优先ID
|
||||
* @apiParam {Number} [id] 报告id
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@ -508,145 +379,30 @@ class ReportController extends AbstractController
|
||||
public function detail(): array
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$id = intval(trim(Request::input("id")));
|
||||
$code = trim(Request::input("code"));
|
||||
//
|
||||
if (empty($id) && empty($code)) {
|
||||
if (empty($id))
|
||||
return Base::retError("缺少ID参数");
|
||||
}
|
||||
//
|
||||
if (!empty($id)) {
|
||||
$one = Report::getOne($id);
|
||||
$one->type_val = $one->getRawOriginal("type");
|
||||
// 标记为已读
|
||||
if (!empty($one->receivesUser)) {
|
||||
foreach ($one->receivesUser as $item) {
|
||||
if ($item->userid === $user->userid && $item->pivot->read === 0) {
|
||||
$one->receivesUser()->updateExistingPivot($user->userid, [
|
||||
"read" => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$one = Report::getOne($id);
|
||||
$one->type_val = $one->getRawOriginal("type");
|
||||
|
||||
// 标记为已读
|
||||
if (!empty($one->receivesUser)) {
|
||||
foreach ($one->receivesUser as $item) {
|
||||
if ($item->userid === $user->userid && $item->pivot->read === 0) {
|
||||
$one->receivesUser()->updateExistingPivot($user->userid, [
|
||||
"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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @apiGroup report
|
||||
* @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
|
||||
* @apiGroup report
|
||||
* @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
|
||||
* @apiGroup report
|
||||
* @apiName unread
|
||||
@ -782,19 +474,15 @@ class ReportController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$total = Report::select('reports.id')
|
||||
->join('report_receives', 'report_receives.rid', '=', 'reports.id')
|
||||
->where('report_receives.userid', $user->userid)
|
||||
->where('report_receives.read', 0)
|
||||
->count();
|
||||
//
|
||||
return Base::retSuccess("success", compact("total"));
|
||||
$data = Report::whereHas("Receives", function (Builder $query) use ($user) {
|
||||
$query->where("userid", $user->userid)->where("read", 0);
|
||||
})->orderByDesc('created_at')->paginate(Base::getPaginate(50, 20));
|
||||
return Base::retSuccess("success", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/read 标记汇报已读,可批量
|
||||
* @api {get} api/report/read 09. 标记汇报已读,可批量
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup report
|
||||
* @apiName read
|
||||
@ -830,22 +518,4 @@ class ReportController extends AbstractController
|
||||
}
|
||||
return Base::retSuccess("success", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否有权限查看/分析指定工作汇报
|
||||
* @param Report $report
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
protected function userCanAccessReport(Report $report, User $user): bool
|
||||
{
|
||||
if ($report->userid === $user->userid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ReportReceive::query()
|
||||
->whereRid($report->id)
|
||||
->whereUserid($user->userid)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 对话ID(0表示不限制)
|
||||
* @return array
|
||||
*/
|
||||
private function searchMessageByMysql(int $userid, string $key, int $take, int $dialogId = 0): array
|
||||
{
|
||||
$builder = WebSocketDialogMsg::select([
|
||||
'id as msg_id',
|
||||
'dialog_id',
|
||||
'userid',
|
||||
'type',
|
||||
'msg',
|
||||
'created_at',
|
||||
])
|
||||
->accessibleByUser($userid)
|
||||
->where('bot', 0)
|
||||
->searchByKeyword($key);
|
||||
|
||||
if ($dialogId > 0) {
|
||||
$builder->where('dialog_id', $dialogId);
|
||||
}
|
||||
|
||||
$items = $builder->orderByDesc('id')
|
||||
->limit($take)
|
||||
->get();
|
||||
|
||||
return $items->map(function ($item) {
|
||||
return [
|
||||
'msg_id' => $item->msg_id,
|
||||
'dialog_id' => $item->dialog_id,
|
||||
'userid' => $item->userid,
|
||||
'type' => $item->type,
|
||||
'msg' => $item->msg,
|
||||
'created_at' => $item->created_at,
|
||||
'relevance' => 0,
|
||||
'content_preview' => null,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化消息搜索结果
|
||||
*
|
||||
* @param array $results 搜索结果
|
||||
* @param string $mode 返回模式
|
||||
* @param int $userid 用户ID
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
private function formatMessageResults(array $results, string $mode, int $userid)
|
||||
{
|
||||
switch ($mode) {
|
||||
case 'position':
|
||||
// 只返回消息ID
|
||||
$data = array_column($results, 'msg_id');
|
||||
return Base::retSuccess('success', compact('data'));
|
||||
|
||||
case 'dialog':
|
||||
// 返回对话级数据
|
||||
$list = [];
|
||||
$seenDialogs = [];
|
||||
foreach ($results as $item) {
|
||||
$dialogIdFromResult = $item['dialog_id'];
|
||||
// 每个对话只返回一次
|
||||
if (isset($seenDialogs[$dialogIdFromResult])) {
|
||||
continue;
|
||||
}
|
||||
$seenDialogs[$dialogIdFromResult] = true;
|
||||
|
||||
if ($dialog = WebSocketDialog::find($dialogIdFromResult)) {
|
||||
$dialogData = array_merge($dialog->toArray(), [
|
||||
'search_msg_id' => $item['msg_id'],
|
||||
]);
|
||||
$list[] = WebSocketDialog::synthesizeData($dialogData, $userid);
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', ['data' => $list]);
|
||||
|
||||
case 'message':
|
||||
default:
|
||||
// 返回消息详细信息(默认行为)
|
||||
$msgIds = array_column($results, 'msg_id');
|
||||
if (!empty($msgIds)) {
|
||||
$msgs = WebSocketDialogMsg::whereIn('id', $msgIds)
|
||||
->with(['user' => function ($query) {
|
||||
$query->select(User::$basicField);
|
||||
}])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// 创建结果映射以保持原始顺序和额外字段
|
||||
$resultsMap = [];
|
||||
foreach ($results as $item) {
|
||||
$resultsMap[$item['msg_id']] = $item;
|
||||
}
|
||||
|
||||
$formattedResults = [];
|
||||
foreach ($msgIds as $msgId) {
|
||||
$msgData = $msgs->get($msgId);
|
||||
$originalItem = $resultsMap[$msgId] ?? [];
|
||||
if ($msgData) {
|
||||
$formattedResults[] = [
|
||||
'id' => $msgData->id,
|
||||
'msg_id' => $msgData->id,
|
||||
'dialog_id' => $msgData->dialog_id,
|
||||
'userid' => $msgData->userid,
|
||||
'type' => $msgData->type,
|
||||
'msg' => $msgData->msg,
|
||||
'created_at' => $msgData->created_at,
|
||||
'user' => $msgData->user,
|
||||
'relevance' => $originalItem['relevance'] ?? 0,
|
||||
'content_preview' => $originalItem['content_preview'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', $formattedResults);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取用户标签映射
|
||||
*
|
||||
* @param array $userids 用户ID数组
|
||||
* @return array 用户ID => 标签名称数组的映射
|
||||
*/
|
||||
private function getUserTagsMap(array $userids): array
|
||||
{
|
||||
if (empty($userids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取所有用户的标签(带认可数)
|
||||
$tags = UserTag::whereIn('user_id', $userids)
|
||||
->withCount('recognitions')
|
||||
->get();
|
||||
|
||||
// 按用户分组,每个用户取 Top 10 标签
|
||||
$result = [];
|
||||
foreach ($userids as $userid) {
|
||||
$result[$userid] = [];
|
||||
}
|
||||
|
||||
$userTags = $tags->groupBy('user_id');
|
||||
foreach ($userTags as $userid => $tagCollection) {
|
||||
$result[$userid] = $tagCollection
|
||||
->sortByDesc('recognitions_count')
|
||||
->take(10)
|
||||
->pluck('name')
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
/**
|
||||
* 测试
|
||||
*/
|
||||
class TestController extends AbstractController
|
||||
{
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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` |
|
||||
@ -1,137 +1,89 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 给apidoc项目增加顺序编号 / 支持恢复
|
||||
* 给apidoc项目增加顺序编号
|
||||
*/
|
||||
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
const NUMBER_WIDTH = 2;
|
||||
|
||||
$isRestore = isset($argv[1]) && strtolower($argv[1]) === 'restore';
|
||||
|
||||
$basePath = dirname(__FILE__) . '/';
|
||||
$controllerFiles = glob($basePath . '*Controller.php');
|
||||
|
||||
if (!$controllerFiles) {
|
||||
echo "No Controller.php files found\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
foreach ($controllerFiles as $filePath) {
|
||||
$original = file_get_contents($filePath);
|
||||
[$updated, $linesChanged] = processFile($original, $isRestore);
|
||||
|
||||
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;
|
||||
$path = dirname(__FILE__). '/';
|
||||
$lists = scandir($path);
|
||||
//
|
||||
foreach ($lists AS $item) {
|
||||
$fillPath = $path . $item;
|
||||
if (str_ends_with($fillPath, 'Controller.php')) {
|
||||
$content = file_get_contents($fillPath);
|
||||
preg_match_all("/\* @api \{(.+?)\} (.*?)\n/i", $content, $matchs);
|
||||
$i = 1;
|
||||
foreach ($matchs[2] AS $key=>$text) {
|
||||
if (in_array(strtolower($matchs[1][$key]), array('get', 'post'))) {
|
||||
$expl = explode(" ", __sRemove($text));
|
||||
$end = $expl[1];
|
||||
if ($expl[2]) {
|
||||
$end = '';
|
||||
foreach ($expl AS $k=>$v) { if ($k >= 2) { $end.= " ".$v; } }
|
||||
}
|
||||
$counter++;
|
||||
} else {
|
||||
$numberedSuffix = $suffix;
|
||||
$newtext = "* @api {".$matchs[1][$key]."} ".$expl[0]." ".__zeroFill($i, 2).". ".trim($end);
|
||||
$content = str_replace("* @api {".$matchs[1][$key]."} ".$text, $newtext, $content);
|
||||
$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
|
||||
{
|
||||
$trimmed = ltrim($text);
|
||||
$pattern = '/^\d+\.\s*/';
|
||||
return preg_replace($pattern, '', $trimmed) ?? $trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩多余空格
|
||||
*/
|
||||
function normalizeDescription(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
function __sRemove($str) {
|
||||
$str = str_replace(" ", " ", $str);
|
||||
if (__strExists($str, " ")) {
|
||||
return __sRemove($str);
|
||||
}
|
||||
|
||||
return preg_replace('/\s+/', ' ', $text) ?? $text;
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成固定宽度的数字
|
||||
* 是否包含字符
|
||||
* @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;
|
||||
}
|
||||
|
||||
@ -8,25 +8,20 @@ use Request;
|
||||
use Redirect;
|
||||
use Response;
|
||||
use App\Models\File;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Base;
|
||||
use App\Module\Extranet;
|
||||
use App\Module\RandomColor;
|
||||
use App\Tasks\LoopTask;
|
||||
use App\Module\Extranet;
|
||||
use App\Tasks\AppPushTask;
|
||||
use App\Module\RandomColor;
|
||||
use App\Tasks\JokeSoupTask;
|
||||
use App\Tasks\DeleteTmpTask;
|
||||
use App\Tasks\EmailNoticeTask;
|
||||
use App\Tasks\AutoArchivedTask;
|
||||
use App\Tasks\DeleteBotMsgTask;
|
||||
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 App\Module\PatchedAvatar as Avatar;
|
||||
use App\Tasks\UnclaimedTaskRemindTask;
|
||||
use LasseRafn\InitialAvatarGenerator\InitialAvatar;
|
||||
|
||||
|
||||
/**
|
||||
@ -42,8 +37,9 @@ class IndexController extends InvokeController
|
||||
if ($action) {
|
||||
$app .= "__" . $action;
|
||||
}
|
||||
if ($app == 'default') {
|
||||
return '';
|
||||
if ($app === 'manifest.txt') {
|
||||
$app = 'manifest';
|
||||
$child = 'txt';
|
||||
}
|
||||
if (!method_exists($this, $app)) {
|
||||
$app = method_exists($this, $method) ? $method : 'main';
|
||||
@ -63,21 +59,58 @@ class IndexController extends InvokeController
|
||||
$array = Base::json2array(file_get_contents($hotFile));
|
||||
$style = null;
|
||||
$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 {
|
||||
$array = Base::json2array(file_get_contents($manifestFile));
|
||||
$style = asset_main($array['resources/assets/js/app.js']['css'][0]);
|
||||
$script = asset_main($array['resources/assets/js/app.js']['file']);
|
||||
}
|
||||
return response()->view('main', [
|
||||
'system_alias' => Base::settingFind('system', 'system_alias', 'WebPage'),
|
||||
'version' => Base::getVersion(),
|
||||
'style' => $style,
|
||||
'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 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()
|
||||
{
|
||||
@ -108,126 +132,38 @@ class IndexController extends InvokeController
|
||||
if ($segment && preg_match('/.*?\.png$/i', $segment)) {
|
||||
$name = substr($segment, 0, -4);
|
||||
} else {
|
||||
$name = Request::input('name', 'D');
|
||||
$name = Request::input('name', 'H');
|
||||
}
|
||||
$size = Request::input('size', 128);
|
||||
$color = Request::input('color');
|
||||
$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)) {
|
||||
$name = mb_substr($name, mb_strlen($name) - 2);
|
||||
}
|
||||
if (empty($name)) {
|
||||
$name = 'D';
|
||||
}
|
||||
if (empty($color)) {
|
||||
$color = '#ffffff';
|
||||
$cacheKey = "avatarBackgroundColor::" . md5($name);
|
||||
$background = Cache::rememberForever($cacheKey, function () {
|
||||
$background = Cache::rememberForever($cacheKey, function() {
|
||||
return RandomColor::one(['luminosity' => 'dark']);
|
||||
});
|
||||
}
|
||||
//
|
||||
$path = public_path('uploads/tmp/avatar/' . substr(md5($name), 0, 2));
|
||||
$file = Base::joinPath($path, md5($name) . '.png');
|
||||
if (file_exists($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),
|
||||
]);
|
||||
}
|
||||
Base::makeDir($path);
|
||||
$avatar = new InitialAvatar();
|
||||
$content = $avatar->name($name)
|
||||
->size($size)
|
||||
->color($color)
|
||||
->background($background)
|
||||
->fontSize(0.35)
|
||||
->autoFont()
|
||||
->generate()
|
||||
->stream('png', 100);
|
||||
//
|
||||
$avatar = new Avatar([
|
||||
'shape' => 'square',
|
||||
'width' => $size,
|
||||
'height' => $size,
|
||||
'chars' => 2,
|
||||
'fontSize' => $size / 2.9,
|
||||
'uppercase' => true,
|
||||
'fonts' => [resource_path('assets/statics/fonts/Source_Han_Sans_SC_Regular.otf')],
|
||||
'foregrounds' => [$color],
|
||||
'backgrounds' => [$background],
|
||||
'border' => [
|
||||
'size' => 0,
|
||||
'color' => 'foreground',
|
||||
'radius' => 0,
|
||||
],
|
||||
]);
|
||||
$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),
|
||||
]);
|
||||
return response($content)
|
||||
->header('Pragma', 'public')
|
||||
->header('Cache-Control', 'max-age=1814400')
|
||||
->header('Content-type', 'image/png')
|
||||
->header('Expires', gmdate('D, d M Y H:i:s \G\M\T', time() + 1814400));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -256,13 +192,11 @@ class IndexController extends InvokeController
|
||||
// App推送
|
||||
Task::deliver(new AppPushTask());
|
||||
// 删除过期的临时表数据
|
||||
Task::deliver(new DeleteTmpTask('tmp_msgs', 1));
|
||||
Task::deliver(new DeleteTmpTask('tmp'));
|
||||
Task::deliver(new DeleteTmpTask('wg_tmp_msgs', 1));
|
||||
Task::deliver(new DeleteTmpTask('task_worker', 12));
|
||||
Task::deliver(new DeleteTmpTask('tmp'));
|
||||
Task::deliver(new DeleteTmpTask('file'));
|
||||
Task::deliver(new DeleteTmpTask('tmp_file', 24));
|
||||
Task::deliver(new DeleteTmpTask('user_device', 24));
|
||||
Task::deliver(new DeleteTmpTask('umeng_log', 24 * 3));
|
||||
Task::deliver(new DeleteTmpTask('file_pack'));
|
||||
// 删除机器人消息
|
||||
Task::deliver(new DeleteBotMsgTask());
|
||||
// 周期任务
|
||||
@ -273,14 +207,6 @@ class IndexController extends InvokeController
|
||||
Task::deliver(new JokeSoupTask());
|
||||
// 未领取任务通知
|
||||
Task::deliver(new UnclaimedTaskRemindTask());
|
||||
// 待办提醒
|
||||
Task::deliver(new TodoRemindTask());
|
||||
// 关闭会议室
|
||||
Task::deliver(new CloseMeetingRoomTask());
|
||||
// Manticore Search 同步
|
||||
Task::deliver(new ManticoreSyncTask());
|
||||
// AI 任务建议
|
||||
Task::deliver(new AiTaskLoopTask());
|
||||
|
||||
return "success";
|
||||
}
|
||||
@ -296,127 +222,80 @@ class IndexController extends InvokeController
|
||||
if (strtolower($name) === 'latest') {
|
||||
$name = $latestVersion;
|
||||
}
|
||||
|
||||
// 上传(header 中包含 publish-version)
|
||||
// 上传
|
||||
if (preg_match("/^\d+\.\d+\.\d+$/", $publishVersion)) {
|
||||
// 判断密钥
|
||||
$publishKey = Request::header('publish-key');
|
||||
if ($publishKey !== config('app.key')) {
|
||||
if ($publishKey !== env('APP_KEY')) {
|
||||
return Base::retError("key error");
|
||||
}
|
||||
// 判断版本
|
||||
$action = Request::get('action');
|
||||
$draftPath = "uploads/desktop-draft/{$publishVersion}/";
|
||||
if ($action === 'release') {
|
||||
// 将草稿版本发布为正式版本
|
||||
$draftPath = public_path($draftPath);
|
||||
$releasePath = public_path("uploads/desktop/{$publishVersion}/");
|
||||
if (!file_exists($draftPath)) {
|
||||
return Base::retError("draft version not exists");
|
||||
if (version_compare($publishVersion, $latestVersion) > -1) { // 限制上传版本必须 ≥ 当前版本
|
||||
$publishPath = "uploads/desktop/{$publishVersion}/";
|
||||
$res = Base::upload([
|
||||
"file" => Request::file('file'),
|
||||
"type" => 'desktop',
|
||||
"path" => $publishPath,
|
||||
"fileName" => true
|
||||
]);
|
||||
if (Base::isSuccess($res)) {
|
||||
file_put_contents($latestFile, $publishVersion);
|
||||
}
|
||||
if (file_exists($releasePath)) {
|
||||
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 $res;
|
||||
}
|
||||
// 上传草稿版本
|
||||
return Base::upload([
|
||||
"file" => Request::file('file'),
|
||||
"type" => 'publish',
|
||||
"path" => $draftPath,
|
||||
"saveName" => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// 列表(访问路径 desktop/publish/{version})
|
||||
if (preg_match("/^v*(\d+\.\d+\.\d+)$/", $name, $match)) {
|
||||
$paths = [
|
||||
"uploads/desktop/{$match[1]}/",
|
||||
"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);
|
||||
// 列表
|
||||
if (preg_match("/^\d+\.\d+\.\d+$/", $name)) {
|
||||
$path = "uploads/desktop/{$name}";
|
||||
$dirPath = public_path($path);
|
||||
$lists = Base::readDir($dirPath);
|
||||
$files = [];
|
||||
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;
|
||||
}
|
||||
$fileName = basename($file, $dirPath);
|
||||
$fileSize = filesize($file);
|
||||
$fileName = Base::leftDelete($file, $dirPath);
|
||||
$files[] = [
|
||||
'name' => $fileName,
|
||||
'name' => substr($fileName, 1),
|
||||
'time' => date("Y-m-d H:i:s", filemtime($file)),
|
||||
'size' => $fileSize > 0 ? Base::readableBytes($fileSize) : 0,
|
||||
'url' => Base::fillUrl(Base::joinPath($avaiPath, $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}"),
|
||||
'size' => Base::readableBytes(filesize($file)),
|
||||
'url' => Base::fillUrl($path . $fileName),
|
||||
];
|
||||
}
|
||||
//
|
||||
return view('desktop', [
|
||||
'system_alias' => Base::settingFind('system', 'system_alias', 'WebPage'),
|
||||
'version' => $match[1],
|
||||
'files' => $files,
|
||||
'is_draft' => $isDraft,
|
||||
'latest_version' => $latestVersion,
|
||||
'other_version' => array_reverse($otherVersion),
|
||||
]);
|
||||
$path = "uploads/android";
|
||||
$dirPath = public_path($path);
|
||||
$lists = Base::readDir($dirPath);
|
||||
$apkFile = null;
|
||||
foreach ($lists as $file) {
|
||||
if (!str_ends_with($file, '.apk')) {
|
||||
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) {
|
||||
$filePath = public_path("uploads/desktop/{$latestVersion}/{$name}");
|
||||
if (file_exists($filePath)) {
|
||||
return Response::download($filePath);
|
||||
// 下载
|
||||
if ($name && file_exists($latestFile)) {
|
||||
$publishVersion = file_get_contents($latestFile);
|
||||
if (preg_match("/^\d+\.\d+\.\d+$/", $publishVersion)) {
|
||||
$filePath = public_path("uploads/desktop/{$publishVersion}/{$name}");
|
||||
if (file_exists($filePath)) {
|
||||
return Response::download($filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 404
|
||||
abort(404);
|
||||
return abort(404);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -442,77 +321,125 @@ class IndexController extends InvokeController
|
||||
$data = parse_url($key);
|
||||
$path = Arr::get($data, '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);
|
||||
$name = Arr::get($query, 'name');
|
||||
$ext = strtolower(Arr::get($query, 'ext'));
|
||||
$userAgent = strtolower(Request::server('HTTP_USER_AGENT'));
|
||||
if ($ext === 'pdf') {
|
||||
// 文件超过 10m 不支持在线预览,提示下载
|
||||
if (filesize($file) > 10 * 1024 * 1024) {
|
||||
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')) {
|
||||
if (file_exists($file)) {
|
||||
parse_str($data['query'], $query);
|
||||
$name = Arr::get($query, 'name');
|
||||
$ext = strtolower(Arr::get($query, 'ext'));
|
||||
$userAgent = strtolower(Request::server('HTTP_USER_AGENT'));
|
||||
if ($ext === 'pdf'
|
||||
&& (str_contains($userAgent, 'electron') || str_contains($userAgent, 'chrome'))) {
|
||||
return Response::download($file, $name, [
|
||||
'Content-Type' => 'application/pdf'
|
||||
], 'inline');
|
||||
}
|
||||
// EEUI App 直接在线预览查看
|
||||
if (Base::isEEUIApp() && Base::judgeClientVersion("0.34.47")) {
|
||||
if ($browser === 'safari-mobile') {
|
||||
$redirectUrl = Base::fillUrl($path);
|
||||
return <<<EOF
|
||||
<script>
|
||||
window.top.postMessage({
|
||||
action: "eeuiAppSendMessage",
|
||||
data: [
|
||||
{
|
||||
action: 'setPageData', // 设置页面数据
|
||||
data: {
|
||||
showProgress: true,
|
||||
titleFixed: true,
|
||||
urlFixed: true,
|
||||
}
|
||||
},
|
||||
{
|
||||
action: 'createTarget', // 创建目标(访问新地址)
|
||||
url: "{$redirectUrl}",
|
||||
}
|
||||
]
|
||||
}, "*")
|
||||
</script>
|
||||
EOF;
|
||||
//
|
||||
if (in_array($ext, File::localExt)) {
|
||||
$url = Base::fillUrl($path);
|
||||
} else {
|
||||
$url = 'http://' . env('APP_IPPR') . '.3/' . $path;
|
||||
}
|
||||
if ($ext !== 'pdf') {
|
||||
$url = Base::urlAddparameter($url, [
|
||||
'fullfilename' => $name . '.' . $ext
|
||||
]);
|
||||
}
|
||||
$toUrl = Base::fillUrl("fileview/onlinePreview?url=" . urlencode(base64_encode($url)));
|
||||
return Redirect::to($toUrl, 301);
|
||||
}
|
||||
return abort(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置语言和皮肤
|
||||
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
|
||||
*/
|
||||
public function setting__theme_language()
|
||||
{
|
||||
return view('setting', [
|
||||
'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, '"'), "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
if (in_array($ext, File::localExt)) {
|
||||
$url = Base::fillUrl($path);
|
||||
} else {
|
||||
$url = 'http://nginx/' . $path;
|
||||
return array_values($array);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取所有中文
|
||||
* @return array|string
|
||||
*/
|
||||
public function allcn__php()
|
||||
{
|
||||
if (!Base::is_internal_ip(Base::getIp())) {
|
||||
// 限制内网访问
|
||||
return "Forbidden Access";
|
||||
}
|
||||
$url = Base::urlAddparameter($url, [
|
||||
'fullfilename' => Base::rightDelete($name, '.' . $ext) . '_' . filemtime($file) . '.' . $ext
|
||||
]);
|
||||
$redirectUrl = Base::fillUrl("fileview/onlinePreview?url=" . urlencode(base64_encode($url)));
|
||||
return Redirect::to($redirectUrl, 301);
|
||||
$list = Base::readDir(app_path());
|
||||
$array = [];
|
||||
foreach ($list as $item) {
|
||||
$content = file_get_contents($item);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Tasks\IhttpTask;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
@ -24,12 +27,29 @@ class InvokeController extends BaseController
|
||||
if ($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) . ").";
|
||||
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);
|
||||
if ($res === true || Base::isSuccess($res)) {
|
||||
return $this->$app();
|
||||
|
||||
68
app/Http/Kernel.php
Normal file
68
app/Http/Kernel.php
Normal 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,
|
||||
];
|
||||
}
|
||||
21
app/Http/Middleware/Authenticate.php
Normal file
21
app/Http/Middleware/Authenticate.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
17
app/Http/Middleware/EncryptCookies.php
Normal file
17
app/Http/Middleware/EncryptCookies.php
Normal 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 = [
|
||||
//
|
||||
];
|
||||
}
|
||||
17
app/Http/Middleware/PreventRequestsDuringMaintenance.php
Normal file
17
app/Http/Middleware/PreventRequestsDuringMaintenance.php
Normal 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 = [
|
||||
//
|
||||
];
|
||||
}
|
||||
32
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file
32
app/Http/Middleware/RedirectIfAuthenticated.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/TrimStrings.php
Normal file
19
app/Http/Middleware/TrimStrings.php
Normal 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',
|
||||
];
|
||||
}
|
||||
20
app/Http/Middleware/TrustHosts.php
Normal file
20
app/Http/Middleware/TrustHosts.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Middleware/TrustProxies.php
Normal file
23
app/Http/Middleware/TrustProxies.php
Normal 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;
|
||||
}
|
||||
21
app/Http/Middleware/VerifyCsrfToken.php
Normal file
21
app/Http/Middleware/VerifyCsrfToken.php
Normal 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/',
|
||||
];
|
||||
}
|
||||
@ -4,10 +4,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Services\RequestContext;
|
||||
use Cache;
|
||||
use Closure;
|
||||
|
||||
class WebApi
|
||||
@ -21,23 +18,11 @@ class WebApi
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
// 记录请求信息
|
||||
RequestContext::set('start_time', microtime(true));
|
||||
RequestContext::set('header_language', $request->header('language'));
|
||||
global $_A;
|
||||
$_A = [];
|
||||
|
||||
// 更新请求的基本URL
|
||||
RequestContext::updateBaseUrl($request);
|
||||
|
||||
// 加载Doo类
|
||||
Doo::load();
|
||||
|
||||
// 记录 PC 端活跃时间
|
||||
$userid = Doo::userId();
|
||||
if ($userid > 0 && Base::isPc()) {
|
||||
Cache::put("user_pc_active:{$userid}", time(), 60);
|
||||
}
|
||||
|
||||
// 解密请求内容
|
||||
$encrypt = Doo::pgpParseStr($request->header('encrypt'));
|
||||
if ($request->isMethod('post')) {
|
||||
$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);
|
||||
|
||||
// 加密返回内容
|
||||
@ -67,16 +57,6 @@ class WebApi
|
||||
}
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function terminate()
|
||||
{
|
||||
// 请求结束后清理上下文
|
||||
RequestContext::clean();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,8 @@
|
||||
|
||||
namespace App\Ldap;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Services\RequestContext;
|
||||
use LdapRecord\Configuration\ConfigurationException;
|
||||
use LdapRecord\Container;
|
||||
use LdapRecord\LdapRecordException;
|
||||
@ -13,16 +11,20 @@ use LdapRecord\Models\Model;
|
||||
|
||||
class LdapUser extends Model
|
||||
{
|
||||
protected static $init = null;
|
||||
/**
|
||||
* The object classes of the LDAP model.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static array $objectClasses = [
|
||||
public static $objectClasses = [
|
||||
'inetOrgPerson',
|
||||
'organizationalPerson',
|
||||
'person',
|
||||
'top',
|
||||
'posixAccount',
|
||||
];
|
||||
|
||||
private static $emailAttrs = ['mail', 'cn', 'uid', 'userPrincipalName'];
|
||||
|
||||
/**
|
||||
* @return mixed|null
|
||||
*/
|
||||
@ -66,29 +68,19 @@ class LdapUser extends Model
|
||||
return Base::settingFind('thirdAccessSetting', 'ldap_sync_local') === 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录属性名
|
||||
* @return string
|
||||
*/
|
||||
public static function getLoginAttr(): string
|
||||
{
|
||||
$attr = Base::settingFind('thirdAccessSetting', 'ldap_login_attr');
|
||||
return in_array($attr, ['cn', 'uid', 'mail', 'sAMAccountName', 'userPrincipalName']) ? $attr : 'cn';
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化配置
|
||||
* @return bool
|
||||
*/
|
||||
public static function initConfig()
|
||||
{
|
||||
if (RequestContext::has('ldap_init')) {
|
||||
return RequestContext::get('ldap_init');
|
||||
if (is_bool(self::$init)) {
|
||||
return self::$init;
|
||||
}
|
||||
//
|
||||
$setting = Base::setting('thirdAccessSetting');
|
||||
if ($setting['ldap_open'] !== 'open') {
|
||||
return RequestContext::save('ldap_init', false);
|
||||
return self::$init = false;
|
||||
}
|
||||
//
|
||||
$connection = Container::getDefaultConnection();
|
||||
@ -100,15 +92,15 @@ class LdapUser extends Model
|
||||
"username" => $setting['ldap_user_dn'],
|
||||
"password" => $setting['ldap_password'],
|
||||
]);
|
||||
return RequestContext::save('ldap_init', true);
|
||||
return self::$init = true;
|
||||
} catch (ConfigurationException $e) {
|
||||
info($e->getMessage());
|
||||
return RequestContext::save('ldap_init', false);
|
||||
return self::$init = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过管理员绑定搜索用户,然后用用户 DN 做 Bind 认证
|
||||
* 获取
|
||||
* @param $username
|
||||
* @param $password
|
||||
* @return Model|null
|
||||
@ -119,68 +111,16 @@ class LdapUser extends Model
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$loginAttr = self::getLoginAttr();
|
||||
$row = self::static()
|
||||
->whereRaw($loginAttr, '=', $username)
|
||||
->first();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$connection = Container::getDefaultConnection();
|
||||
if (!$connection->auth()->attempt($row->getDn(), $password)) {
|
||||
return null;
|
||||
}
|
||||
// Swoole 下连接共享,必须恢复管理员绑定
|
||||
$connection->auth()->attempt(
|
||||
$connection->getConfiguration()->get('username'),
|
||||
$connection->getConfiguration()->get('password')
|
||||
);
|
||||
return $row;
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] auth fail: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过邮箱查找 LDAP 用户
|
||||
* @param $email
|
||||
* @return Model|null
|
||||
*/
|
||||
public static function findByEmail($email): ?Model
|
||||
{
|
||||
if (!self::initConfig()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
foreach (self::$emailAttrs as $attr) {
|
||||
$row = self::static()->whereRaw($attr, '=', $email)->first();
|
||||
if ($row) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
'userPassword' => $password
|
||||
])->first();
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的邮箱(从 LDAP 记录中提取)
|
||||
* @param Model $row
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getUserEmail(Model $row): ?string
|
||||
{
|
||||
foreach (self::$emailAttrs as $attr) {
|
||||
$val = $row->getFirstAttribute($attr);
|
||||
if ($val && Base::isEmail($val)) {
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* @param $username
|
||||
@ -198,18 +138,7 @@ class LdapUser extends Model
|
||||
return null;
|
||||
}
|
||||
if (empty($user)) {
|
||||
$email = self::getUserEmail($row);
|
||||
if (empty($email)) {
|
||||
throw new ApiException('LDAP 用户缺少邮箱属性,请联系管理员配置');
|
||||
}
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (empty($user)) {
|
||||
// LDAP 用户通过 LDAP 认证,本地密码用随机值以满足密码策略
|
||||
$localPassword = Base::generatePassword(16) . 'Aa1!';
|
||||
$user = User::reg($email, $localPassword);
|
||||
} elseif (!$user->isLdap()) {
|
||||
info("[LDAP] merged with existing local account: userid={$user->userid}, email={$email}");
|
||||
}
|
||||
$user = User::reg($username, $password);
|
||||
}
|
||||
if ($user) {
|
||||
$userimg = $row->getPhoto();
|
||||
@ -244,7 +173,7 @@ class LdapUser extends Model
|
||||
}
|
||||
//
|
||||
if (self::isSyncLocal()) {
|
||||
$row = self::findByEmail($user->email);
|
||||
$row = self::userFirst($user->email, $password);
|
||||
if ($row) {
|
||||
return;
|
||||
}
|
||||
@ -255,18 +184,17 @@ class LdapUser extends Model
|
||||
} else {
|
||||
$userimg = '';
|
||||
}
|
||||
$attrs = [
|
||||
self::static()->create([
|
||||
'cn' => $user->email,
|
||||
'gidNumber' => 0,
|
||||
'homeDirectory' => '/home/ldap/dootask/' . env("APP_NAME"),
|
||||
'sn' => $user->email,
|
||||
'uid' => $user->email,
|
||||
'uidNumber' => $user->userid,
|
||||
'userPassword' => $password,
|
||||
'displayName' => $user->nickname,
|
||||
'mail' => $user->email,
|
||||
];
|
||||
if ($userimg) {
|
||||
$attrs['jpegPhoto'] = $userimg;
|
||||
}
|
||||
self::static()->create($attrs);
|
||||
'jpegPhoto' => $userimg,
|
||||
]);
|
||||
$user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['ldap']), ['ldap']));
|
||||
$user->save();
|
||||
} catch (LdapRecordException $e) {
|
||||
@ -277,11 +205,11 @@ class LdapUser extends Model
|
||||
|
||||
/**
|
||||
* 更新
|
||||
* @param $email
|
||||
* @param $username
|
||||
* @param $array
|
||||
* @return void
|
||||
*/
|
||||
public static function userUpdate($email, $array)
|
||||
public static function userUpdate($username, $array)
|
||||
{
|
||||
if (empty($array)) {
|
||||
return;
|
||||
@ -290,7 +218,10 @@ class LdapUser extends Model
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$row = self::findByEmail($email);
|
||||
$row = self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
])->first();
|
||||
$row?->update($array);
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] update fail: " . $e->getMessage());
|
||||
@ -299,16 +230,19 @@ class LdapUser extends Model
|
||||
|
||||
/**
|
||||
* 删除
|
||||
* @param $email
|
||||
* @param $username
|
||||
* @return void
|
||||
*/
|
||||
public static function userDelete($email)
|
||||
public static function userDelete($username)
|
||||
{
|
||||
if (!self::initConfig()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$row = self::findByEmail($email);
|
||||
$row = self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
])->first();
|
||||
$row?->delete();
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] delete fail: " . $e->getMessage());
|
||||
|
||||
@ -5,7 +5,6 @@ namespace App\Models;
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Module\Base;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
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 cancelHidden()
|
||||
* @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 remove()
|
||||
* @mixin \Eloquent
|
||||
@ -32,46 +33,12 @@ class AbstractModel extends Model
|
||||
|
||||
const ID = 'id';
|
||||
|
||||
/**
|
||||
* 全局日期字段(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',
|
||||
|
||||
protected $dates = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
public function getCasts(): array
|
||||
{
|
||||
$casts = parent::getCasts();
|
||||
foreach ($this->defaultDatetimeCasts as $field) {
|
||||
$casts[$field] ??= 'datetime';
|
||||
}
|
||||
return $casts;
|
||||
}
|
||||
|
||||
protected $appendattrs = [];
|
||||
|
||||
/**
|
||||
@ -164,25 +131,6 @@ class AbstractModel extends Model
|
||||
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
|
||||
@ -202,66 +150,6 @@ class AbstractModel extends Model
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆写框架 saveOrIgnore 的底层插入逻辑。
|
||||
*
|
||||
* 框架默认走 insertOrIgnoreReturning(INSERT ... 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
|
||||
@ -301,44 +189,24 @@ class AbstractModel extends Model
|
||||
|
||||
/**
|
||||
* 数据库更新或插入
|
||||
* @param array $where 查询条件
|
||||
* @param array|\Closure $update 存在时更新的内容
|
||||
* @param array|\Closure $insert 不存在时插入的内容,如果没有则插入更新内容
|
||||
* @param bool $isInsert 是否是插入数据
|
||||
* @param bool|null $lockForUpdate 是否加锁(true:加锁,false:不加锁,null:在事务中会自动加锁)
|
||||
* @param $where
|
||||
* @param array $update 存在时更新的内容
|
||||
* @param array $insert 不存在时插入的内容,如果没有则插入更新内容
|
||||
* @param bool $isInsert 是否是插入数据
|
||||
* @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);
|
||||
if ($lockForUpdate === null) {
|
||||
$lockForUpdate = \DB::transactionLevel() > 0;
|
||||
}
|
||||
if ($lockForUpdate) {
|
||||
$query->lockForUpdate();
|
||||
}
|
||||
$row = $query->first();
|
||||
$row = static::where($where)->first();
|
||||
if (empty($row)) {
|
||||
$row = new static;
|
||||
if ($insert instanceof \Closure) {
|
||||
$insert = $insert();
|
||||
}
|
||||
if (empty($insert)) {
|
||||
if ($update instanceof \Closure) {
|
||||
$update = $update();
|
||||
}
|
||||
$insert = $update;
|
||||
}
|
||||
$array = array_merge($where, $insert);
|
||||
$array = array_merge($where, $insert ?: $update);
|
||||
if (isset($array[$row->primaryKey])) {
|
||||
unset($array[$row->primaryKey]);
|
||||
}
|
||||
$row->updateInstance($array);
|
||||
$isInsert = true;
|
||||
} elseif ($update) {
|
||||
if ($update instanceof \Closure) {
|
||||
$update = $update();
|
||||
}
|
||||
$row->updateInstance($update);
|
||||
$isInsert = false;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
28
app/Models/ApproveProcMsg.php
Normal file
28
app/Models/ApproveProcMsg.php
Normal 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
|
||||
{
|
||||
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
}
|
||||
@ -12,15 +12,9 @@ use Carbon\Carbon;
|
||||
* @property int|null $did 删除的数据ID
|
||||
* @property int|null $userid 关系会员ID
|
||||
* @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 newQuery()
|
||||
* @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 whereDid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Deleted whereId($value)
|
||||
|
||||
@ -3,11 +3,8 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Request;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Tasks\PushTask;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
use App\Observers\AbstractObserver;
|
||||
use App\Exceptions\ApiException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
@ -26,30 +23,20 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property int|null $size 大小(B)
|
||||
* @property int|null $userid 拥有者ID
|
||||
* @property int|null $share 是否共享
|
||||
* @property int|null $guest_access 是否允许游客访问
|
||||
* @property int|null $pshare 所属分享ID
|
||||
* @property int|null $created_id 创建者
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_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 newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File onlyTrashed()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File sharedToUser(int $userid)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereCid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedId($value)
|
||||
* @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 whereGuestAccess($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 wherePid($value)
|
||||
@ -86,28 +73,9 @@ class File extends AbstractModel
|
||||
* office文件
|
||||
*/
|
||||
const officeExt = [
|
||||
// 文本文件
|
||||
'doc', 'docx', // Microsoft Word 文档
|
||||
'dot', 'dotx', // Word 模板
|
||||
'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 演示文稿模板
|
||||
'doc', 'docx',
|
||||
'xls', 'xlsx',
|
||||
'ppt', 'pptx',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -124,7 +92,7 @@ class File extends AbstractModel
|
||||
'jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'ico', 'raw',
|
||||
'tif', 'tiff',
|
||||
'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
|
||||
|
||||
/**
|
||||
* 按关键词搜索文件(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 int $pid
|
||||
* @param string $type
|
||||
* @param bool $isGetparent
|
||||
* @return array
|
||||
*/
|
||||
public function getFileList($user, int $pid, $type = "all", $isGetparent = true)
|
||||
@ -185,7 +112,7 @@ class File extends AbstractModel
|
||||
$permission = 1000;
|
||||
$userids = $user->isTemp() ? [$user->userid] : [0, $user->userid];
|
||||
$builder = File::wherePid($pid)
|
||||
->when($type == 'dir', function ($q) {
|
||||
->when($type=='dir',function($q){
|
||||
$q->whereType('folder');
|
||||
});
|
||||
if ($pid > 0) {
|
||||
@ -201,7 +128,7 @@ class File extends AbstractModel
|
||||
//
|
||||
if ($pid > 0) {
|
||||
// 遍历获取父级
|
||||
if ($isGetparent) {
|
||||
if($isGetparent){
|
||||
while ($pid > 0) {
|
||||
$file = File::whereId($pid)->first();
|
||||
if (empty($file)) {
|
||||
@ -239,8 +166,8 @@ class File extends AbstractModel
|
||||
->whereIn('file_users.userid', $userids)
|
||||
->groupBy('files.id')
|
||||
->take(100)
|
||||
->when($type == 'dir', function ($q) {
|
||||
$q->where('files.type', 'folder');
|
||||
->when($type=='dir',function($q){
|
||||
$q->where('files.type','folder');
|
||||
})
|
||||
->get();
|
||||
if ($list->isNotEmpty()) {
|
||||
@ -263,10 +190,9 @@ class File extends AbstractModel
|
||||
* @param user $user
|
||||
* @param int $pid
|
||||
* @param string $webkitRelativePath
|
||||
* @param bool $overwrite
|
||||
* @return array
|
||||
*/
|
||||
public function contentUpload($user, int $pid, $webkitRelativePath, $overwrite = false)
|
||||
public function contentUpload($user, int $pid, $webkitRelativePath)
|
||||
{
|
||||
$userid = $user->userid;
|
||||
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([
|
||||
"file" => Request::file('files'),
|
||||
"type" => 'more',
|
||||
"autoThumb" => false,
|
||||
"path" => $path,
|
||||
"quality" => true
|
||||
"size" => ($setting['file_upload_limit'] ?: 0) * 1024
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
@ -329,9 +256,9 @@ class File extends AbstractModel
|
||||
'text', 'md', 'markdown' => 'document',
|
||||
'drawio' => 'drawio',
|
||||
'mind' => 'mind',
|
||||
'doc', 'docx', 'dot', 'dotx', 'odt', 'ott', 'rtf' => "word",
|
||||
'xls', 'xlsx', 'xlsm', 'xlt', 'xltx', 'ods', 'ots', 'csv', 'tsv' => "excel",
|
||||
'ppt', 'pptx', 'pps', 'ppsx', 'pot', 'potx', 'odp', 'otp' => "ppt",
|
||||
'doc', 'docx' => "word",
|
||||
'xls', 'xlsx' => "excel",
|
||||
'ppt', 'pptx' => "ppt",
|
||||
'wps' => "wps",
|
||||
'jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'ico', 'raw', 'svg' => "picture",
|
||||
'rar', 'zip', 'jar', '7-zip', 'tar', 'gzip', '7z', 'gz', 'apk', 'dmg' => "archive",
|
||||
@ -356,25 +283,17 @@ class File extends AbstractModel
|
||||
if ($data['ext'] == 'markdown') {
|
||||
$data['ext'] = 'md';
|
||||
}
|
||||
$file = null;
|
||||
$params = [
|
||||
$file = File::createInstance([
|
||||
'pid' => $pid,
|
||||
'name' => Base::rightDelete($data['name'], '.' . $data['ext']),
|
||||
'type' => $type,
|
||||
'ext' => $data['ext'],
|
||||
'userid' => $userid,
|
||||
'created_id' => $user->userid,
|
||||
];
|
||||
if ($overwrite) {
|
||||
$file = self::wherePid($params['pid'])->whereExt($params['ext'])->whereName($params['name'])->first();
|
||||
}
|
||||
if (!$file) {
|
||||
$overwrite = false;
|
||||
$file = File::createInstance($params);
|
||||
$file->handleDuplicateName();
|
||||
}
|
||||
]);
|
||||
$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->saveBeforePP();
|
||||
//
|
||||
@ -402,12 +321,11 @@ class File extends AbstractModel
|
||||
$tmpRow->pushMsg('add', $tmpRow);
|
||||
//
|
||||
$data = File::handleImageUrl($tmpRow->toArray());
|
||||
$data['full_name'] = $webkitRelativePath ?: ($data['name'] . '.' . $data['ext']);
|
||||
$data['overwrite'] = $overwrite ? 1 : 0;
|
||||
$data['full_name'] = $webkitRelativePath ?: $data['name'];
|
||||
//
|
||||
$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)
|
||||
{
|
||||
$validUserIds = array_filter($userids);
|
||||
if (in_array($this->userid, $validUserIds) || in_array($this->created_id, $validUserIds)) {
|
||||
if (in_array($this->userid, $userids) || in_array($this->created_id, $userids)) {
|
||||
// ① 自己的文件夹 或 自己创建的文件夹
|
||||
return 1000;
|
||||
}
|
||||
@ -627,26 +544,6 @@ class File extends AbstractModel
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新子文件的 userid 并同步到 Manticore
|
||||
* @param int $userid 新的 userid
|
||||
* @return int 更新的文件数量
|
||||
*/
|
||||
public function updateChildFilesUserid(int $userid): int
|
||||
{
|
||||
self::where('pids', 'like', "%,{$this->id},%")->update(['userid' => $userid]);
|
||||
|
||||
// 批量 update 绕过 Observer,手动触发 Manticore 同步
|
||||
$childFileIds = self::where('pids', 'like', "%,{$this->id},%")
|
||||
->where('type', '!=', 'folder')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
foreach ($childFileIds as $childFileId) {
|
||||
AbstractObserver::taskDeliver(new ManticoreSyncTask('file_sync', ['id' => $childFileId]));
|
||||
}
|
||||
return count($childFileIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件分享链接
|
||||
* @param $userid
|
||||
@ -707,29 +604,6 @@ class File extends AbstractModel
|
||||
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
|
||||
@ -773,7 +647,7 @@ class File extends AbstractModel
|
||||
/**
|
||||
* code获取文件ID、名称
|
||||
* @param $code
|
||||
* @return File|null
|
||||
* @return File
|
||||
*/
|
||||
public static function code2IdName($code) {
|
||||
$arr = explode(",", base64_decode($code));
|
||||
@ -814,9 +688,9 @@ class File extends AbstractModel
|
||||
* @param int $permission
|
||||
* @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)) {
|
||||
throw new ApiException('文件不存在或已被删除');
|
||||
}
|
||||
@ -1045,39 +919,26 @@ class File extends AbstractModel
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件类型判断是否需要安装应用
|
||||
* @param $type
|
||||
* @return void
|
||||
* 文件推送消息
|
||||
* @param $action
|
||||
* @param array|null $data 发送内容
|
||||
* @param array $userid 会员ID
|
||||
*/
|
||||
public static function isNeedInstallApp($type): void
|
||||
public static function filePushMsg($action, $data = null, $userid = null)
|
||||
{
|
||||
// 文件类型与应用的映射配置
|
||||
$fileTypeAppMapping = [
|
||||
// Office 应用映射
|
||||
[
|
||||
'types' => ['word', 'excel', 'ppt', 'docx', 'xlsx', 'pptx'],
|
||||
'app_id' => 'office',
|
||||
'app_name' => 'OnlyOffice'
|
||||
],
|
||||
// Drawio 应用映射
|
||||
[
|
||||
'types' => ['drawio'],
|
||||
'app_id' => 'drawio',
|
||||
'app_name' => 'Drawio'
|
||||
],
|
||||
// Minder 应用映射
|
||||
[
|
||||
'types' => ['mind'],
|
||||
'app_id' => 'minder',
|
||||
'app_name' => 'Minder'
|
||||
]
|
||||
];
|
||||
|
||||
// 遍历配置检查是否需要安装应用
|
||||
foreach ($fileTypeAppMapping as $config) {
|
||||
if (in_array($type, $config['types'])) {
|
||||
Apps::isInstalledThrow($config['app_id']);
|
||||
}
|
||||
$userid = User::auth()->userid();
|
||||
if (empty($userid)) {
|
||||
return;
|
||||
}
|
||||
$msg = [
|
||||
'type' => 'file',
|
||||
'action' => $action,
|
||||
'data' => $data,
|
||||
];
|
||||
$params = [
|
||||
'userid' => $userid,
|
||||
'msg' => $msg
|
||||
];
|
||||
Task::deliver(new PushTask($params));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,9 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
|
||||
use App\Module\Base;
|
||||
use App\Module\Timer;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* App\Models\FileContent
|
||||
@ -19,16 +18,10 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_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 newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|FileContent onlyTrashed()
|
||||
* @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 whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|FileContent whereDeletedAt($value)
|
||||
@ -77,7 +70,7 @@ class FileContent extends AbstractModel
|
||||
'name' => $name,
|
||||
'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 $download
|
||||
* @return array|StreamedResponse
|
||||
* @return array|\Symfony\Component\HttpFoundation\StreamedResponse
|
||||
*/
|
||||
public static function formatContent($file, $content, $download = false)
|
||||
{
|
||||
@ -119,7 +112,7 @@ class FileContent extends AbstractModel
|
||||
} else {
|
||||
$filePath = public_path($content['url']);
|
||||
}
|
||||
return Base::DownloadFileResponse($filePath, $name);
|
||||
return Base::streamDownload($filePath, $name);
|
||||
}
|
||||
if (empty($content)) {
|
||||
$content = match ($file->type) {
|
||||
@ -129,7 +122,9 @@ class FileContent extends AbstractModel
|
||||
],
|
||||
default => json_decode('{}'),
|
||||
};
|
||||
abort_if($download, 403, "This file is empty.");
|
||||
if ($download) {
|
||||
abort(403, "This file is empty.");
|
||||
}
|
||||
} else {
|
||||
$path = $content['url'];
|
||||
if ($file->ext) {
|
||||
@ -145,51 +140,13 @@ class FileContent extends AbstractModel
|
||||
}
|
||||
if ($download) {
|
||||
$filePath = public_path($path);
|
||||
abort_if(!isset($filePath),403, "This file not support download.");
|
||||
return Base::DownloadFileResponse($filePath, $name);
|
||||
if (isset($filePath)) {
|
||||
return Base::streamDownload($filePath, $name);
|
||||
} else {
|
||||
abort(403, "This file not support download.");
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,15 +15,9 @@ use App\Module\Base;
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @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 newQuery()
|
||||
* @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 whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|FileLink whereFileId($value)
|
||||
|
||||
@ -12,15 +12,9 @@ namespace App\Models;
|
||||
* @property int|null $permission 权限:0只读,1读写
|
||||
* @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|FileUser newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|FileUser newQuery()
|
||||
* @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 whereFileId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|FileUser whereId($value)
|
||||
@ -45,7 +39,7 @@ class FileUser extends AbstractModel
|
||||
} else {
|
||||
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) {
|
||||
FileLink::whereFileId($file_id)->whereUserid($userid)->delete();
|
||||
return self::whereFileId($file_id)->whereUserid($userid)->remove();
|
||||
return self::whereFileId($file_id)->whereUserid($userid)->delete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -16,17 +16,11 @@ use Illuminate\Support\Carbon;
|
||||
* @property int|null $userid 创建人
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $end_at
|
||||
* @property string|null $end_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 newQuery()
|
||||
* @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 whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Meeting whereDeletedAt($value)
|
||||
@ -50,12 +44,12 @@ class Meeting extends AbstractModel
|
||||
public function getShareLink()
|
||||
{
|
||||
$code = base64_encode("{$this->meetingid}" . Base::generatePassword());
|
||||
Cache::put(self::CACHE_KEY . '_' . $code, [
|
||||
Cache::put(self::CACHE_KEY.'_'.$code, [
|
||||
'id' => $this->id,
|
||||
'meetingid' => $this->meetingid,
|
||||
'channel' => $this->channel,
|
||||
], 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)
|
||||
{
|
||||
if (Cache::has(self::CACHE_KEY . '_' . $code)) {
|
||||
return Cache::get(self::CACHE_KEY . '_' . $code);
|
||||
if(Cache::has(self::CACHE_KEY.'_'.$code)){
|
||||
return Cache::get(self::CACHE_KEY.'_'.$code);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存访客信息
|
||||
* @return void
|
||||
* @return mixed
|
||||
*/
|
||||
public static function setTouristInfo($data)
|
||||
{
|
||||
Cache::put(Meeting::CACHE_KEY . '_' . $data['uid'], [
|
||||
Cache::put(Meeting::CACHE_KEY.'_'.$data['uid'], [
|
||||
'uid' => $data['uid'],
|
||||
'userimg' => $data['userimg'],
|
||||
'nickname' => $data['nickname'],
|
||||
@ -89,8 +83,8 @@ class Meeting extends AbstractModel
|
||||
*/
|
||||
public static function getTouristInfo($touristId)
|
||||
{
|
||||
if (Cache::has(Meeting::CACHE_KEY . '_' . $touristId)) {
|
||||
return Cache::get(Meeting::CACHE_KEY . '_' . $touristId);
|
||||
if(Cache::has(Meeting::CACHE_KEY.'_'.$touristId)){
|
||||
return Cache::get(Meeting::CACHE_KEY.'_'.$touristId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -20,15 +20,11 @@ use Request;
|
||||
* @property string|null $desc 描述、备注
|
||||
* @property int|null $userid 创建人
|
||||
* @property int|null $personal 是否个人项目
|
||||
* @property string|null $archive_method 自动归档方式
|
||||
* @property int|null $archive_days 自动归档天数
|
||||
* @property string|null $ai_auto_analyze AI自动分析
|
||||
* @property string|null $task_template_share 共享模板开关
|
||||
* @property string|null $department_owner_view 部门负责人视角可见开关
|
||||
* @property string|null $user_simple 成员总数|1,2,3
|
||||
* @property int|null $dialog_id 聊天会话ID
|
||||
* @property \Illuminate\Support\Carbon|null $archived_at 归档时间
|
||||
* @property string|null $archived_at 归档时间
|
||||
* @property int|null $archived_userid 归档会员
|
||||
* @property int|null $is_fixed 是否固定
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property \Illuminate\Support\Carbon|null $deleted_at
|
||||
@ -41,19 +37,10 @@ use Request;
|
||||
* @property-read int|null $project_user_count
|
||||
* @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|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 newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project onlyTrashed()
|
||||
* @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 whereArchivedUserid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereCreatedAt($value)
|
||||
@ -61,6 +48,7 @@ use Request;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereDesc($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereDialogId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereIsFixed($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereName($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project wherePersonal($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereUpdatedAt($value)
|
||||
@ -68,10 +56,6 @@ use Request;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereUserid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project withTrashed()
|
||||
* @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
|
||||
*/
|
||||
class Project extends AbstractModel
|
||||
@ -84,7 +68,6 @@ class Project extends AbstractModel
|
||||
|
||||
protected $appends = [
|
||||
'owner_userid',
|
||||
'deputy_userids',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -100,58 +83,6 @@ class Project extends AbstractModel
|
||||
return $this->appendattrs['owner_userid'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目管理员 userid 列表
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyUseridsAttribute(): array
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
return [];
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目负责人(与 project_users.owner=1 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereUserid($userid)
|
||||
->whereOwner(ProjectUser::OWNER_PRIMARY)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目管理员(与 project_users.owner=2 一致)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereUserid($userid)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(含项目管理员)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
@ -190,7 +121,6 @@ class Project extends AbstractModel
|
||||
'projects.*',
|
||||
'project_users.owner',
|
||||
'project_users.top_at',
|
||||
'project_users.sort',
|
||||
])
|
||||
->leftJoin('project_users', function ($leftJoin) use ($userid) {
|
||||
$leftJoin
|
||||
@ -215,7 +145,6 @@ class Project extends AbstractModel
|
||||
'projects.*',
|
||||
'project_users.owner',
|
||||
'project_users.top_at',
|
||||
'project_users.sort',
|
||||
])
|
||||
->join('project_users', 'projects.id', '=', 'project_users.project_id')
|
||||
->where('project_users.userid', $userid);
|
||||
@ -225,18 +154,6 @@ class Project extends AbstractModel
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词搜索项目(Scope)
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
return $query->where("projects.name", "like", "%{$keyword}%");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务统计数据
|
||||
* @param $userid
|
||||
@ -287,40 +204,16 @@ class Project extends AbstractModel
|
||||
return;
|
||||
}
|
||||
AbstractModel::transaction(function() {
|
||||
// 拉所有项目成员 + 各自 owner 值
|
||||
$userOwnerMap = ProjectUser::whereProjectId($this->id)
|
||||
->pluck('owner', 'userid');
|
||||
$userids = $userOwnerMap->keys()->map(fn($v) => (int)$v)->toArray();
|
||||
$userids = $this->relationUserids();
|
||||
foreach ($userids as $userid) {
|
||||
$owner = (int)$userOwnerMap[$userid];
|
||||
// 巧合:编码完全一致 owner 0/1/2 → role 0/1/2
|
||||
$role = $owner;
|
||||
WebSocketDialogUser::updateInsert([
|
||||
'dialog_id' => $this->dialog_id,
|
||||
'userid' => $userid,
|
||||
], [
|
||||
'important' => 1,
|
||||
'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]);
|
||||
'important' => 1
|
||||
]);
|
||||
}
|
||||
WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove();
|
||||
});
|
||||
}
|
||||
|
||||
@ -428,65 +321,44 @@ class Project extends AbstractModel
|
||||
/**
|
||||
* 推送消息
|
||||
* @param string $action
|
||||
* @param array|self $data 推送内容
|
||||
* @param array|self $data 发送内容,默认为[id=>项目ID]
|
||||
* @param array $userid 指定会员,默认为项目所有成员
|
||||
*/
|
||||
public function pushMsg($action, $data = null, $userid = null)
|
||||
{
|
||||
// 处理数据
|
||||
if ($data instanceof self) {
|
||||
if ($data === null) {
|
||||
$data = ['id' => $this->id];
|
||||
} elseif ($data instanceof self) {
|
||||
$data = $data->toArray();
|
||||
}
|
||||
|
||||
$data = is_array($data) ? $data : [];
|
||||
$data['id'] = $this->id;
|
||||
$data['name'] = $this->name;
|
||||
$data['desc'] = $this->desc;
|
||||
|
||||
// 处理接收用户
|
||||
$recipients = [$userid, []];
|
||||
//
|
||||
$array = [$userid, []];
|
||||
if ($userid === null) {
|
||||
$recipients[0] = $this->relationUserids();
|
||||
$array[0] = $this->relationUserids();
|
||||
} elseif (!is_array($userid)) {
|
||||
$recipients[0] = [$userid];
|
||||
$array[0] = [$userid];
|
||||
}
|
||||
|
||||
// 移除不需要的字段
|
||||
unset($data['top_at']);
|
||||
|
||||
// 处理所有者权限
|
||||
//
|
||||
if (isset($data['owner'])) {
|
||||
$owners = ProjectUser::whereProjectId($data['id'])
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
$recipients = [
|
||||
array_intersect($recipients[0], $owners),
|
||||
array_diff($recipients[0], $owners)
|
||||
];
|
||||
$owners = ProjectUser::whereProjectId($data['id'])->whereOwner(1)->pluck('userid')->toArray();
|
||||
$array = [array_intersect($array[0], $owners), array_diff($array[0], $owners)];
|
||||
}
|
||||
|
||||
// 发送推送
|
||||
foreach ($recipients as $index => $userids) {
|
||||
if (empty($userids)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//
|
||||
foreach ($array as $index => $item) {
|
||||
if ($index > 0) {
|
||||
$data['owner'] = 0;
|
||||
}
|
||||
|
||||
$params = [
|
||||
'ignoreFd' => Request::header('fd'),
|
||||
'userid' => array_values($userids),
|
||||
'userid' => array_values($item),
|
||||
'msg' => [
|
||||
'type' => 'project',
|
||||
'action' => $action,
|
||||
'data' => $data,
|
||||
]
|
||||
];
|
||||
|
||||
Task::deliver(new PushTask($params, false));
|
||||
$task = new PushTask($params, false);
|
||||
Task::deliver($task);
|
||||
}
|
||||
}
|
||||
|
||||
@ -514,38 +386,29 @@ class Project extends AbstractModel
|
||||
$hasStart = false;
|
||||
$hasEnd = false;
|
||||
$upTaskList = [];
|
||||
$projectUserids = $this->relationUserids();
|
||||
foreach ($flows as $item) {
|
||||
$id = intval($item['id']);
|
||||
$name = trim(str_replace('|', '·', $item['name']));
|
||||
$turns = Base::arrayRetainInt($item['turns'] ?: [], true);
|
||||
$userids = Base::arrayRetainInt($item['userids'] ?: [], true);
|
||||
$usertype = trim($item['usertype']);
|
||||
$userlimit = intval($item['userlimit']);
|
||||
$columnid = intval($item['columnid']);
|
||||
if ($usertype == 'replace' && empty($userids)) {
|
||||
throw new ApiException("状态[{$name}]设置错误,设置流转模式时必须填写状态负责人");
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,设置流转模式时必须填写状态负责人");
|
||||
}
|
||||
if ($usertype == 'merge' && empty($userids)) {
|
||||
throw new ApiException("状态[{$name}]设置错误,设置剔除模式时必须填写状态负责人");
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,设置剔除模式时必须填写状态负责人");
|
||||
}
|
||||
if ($userlimit && empty($userids)) {
|
||||
throw new ApiException("状态[{$name}]设置错误,设置限制负责人时必须填写状态负责人");
|
||||
}
|
||||
foreach ($userids as $userid) {
|
||||
if (!in_array($userid, $projectUserids)) {
|
||||
$nickname = User::userid2nickname($userid);
|
||||
throw new ApiException("状态[{$name}]设置错误,状态负责人[{$nickname}]不在项目成员内");
|
||||
}
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,设置限制负责人时必须填写状态负责人");
|
||||
}
|
||||
$flow = ProjectFlowItem::updateInsert([
|
||||
'id' => $id,
|
||||
'project_id' => $this->id,
|
||||
'flow_id' => $projectFlow->id,
|
||||
], [
|
||||
'name' => $name,
|
||||
'name' => trim($item['name']),
|
||||
'status' => trim($item['status']),
|
||||
'color' => trim($item['color']),
|
||||
'sort' => intval($item['sort']),
|
||||
'turns' => $turns,
|
||||
'userids' => $userids,
|
||||
@ -565,7 +428,7 @@ class Project extends AbstractModel
|
||||
$hasEnd = true;
|
||||
}
|
||||
if (!$isInsert) {
|
||||
$upTaskList[$flow->id] = $flow->status . "|" . $flow->name . "|" . $flow->color;
|
||||
$upTaskList[$flow->id] = $flow->status . "|" . $flow->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -609,38 +472,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
|
||||
@ -657,10 +488,6 @@ class Project extends AbstractModel
|
||||
$desc = trim(Arr::get($params, 'desc', ''));
|
||||
$flow = trim(Arr::get($params, 'flow', 'close'));
|
||||
$isPersonal = intval(Arr::get($params, 'personal'));
|
||||
// 个人项目为系统自动创建,不受创建权限限制
|
||||
if (!$isPersonal && !self::userCanCreate($userid)) {
|
||||
return Base::retError('当前仅指定人员可以创建项目');
|
||||
}
|
||||
if (mb_strlen($name) < 2) {
|
||||
return Base::retError('项目名称不可以少于2个字');
|
||||
} elseif (mb_strlen($name) > 32) {
|
||||
@ -714,7 +541,7 @@ class Project extends AbstractModel
|
||||
$column['project_id'] = $project->id;
|
||||
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)) {
|
||||
throw new ApiException('创建项目聊天室失败');
|
||||
}
|
||||
@ -722,7 +549,7 @@ class Project extends AbstractModel
|
||||
$project->save();
|
||||
//
|
||||
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 +563,7 @@ class Project extends AbstractModel
|
||||
* 获取项目信息(用于判断会员是否存在项目内)
|
||||
* @param int $project_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool|string $mustOwner true:负责人或项目管理员都可(共享操作);
|
||||
* 'primary':仅负责人(转让/删除/任命项目管理员等独占操作);
|
||||
* false:仅限非负责人;null:不限制
|
||||
* @param null|bool $mustOwner true:仅限项目负责人, false:仅限非项目负责人, null:不限制
|
||||
* @return self
|
||||
*/
|
||||
public static function userProject($project_id, $archived = true, $mustOwner = null)
|
||||
@ -756,39 +581,9 @@ class Project extends AbstractModel
|
||||
if ($mustOwner === true && !$project->owner) {
|
||||
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
if ($mustOwner === 'primary' && (int)$project->owner !== 1) {
|
||||
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
if ($mustOwner === false && $project->owner) {
|
||||
throw new ApiException('禁止项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目(含部门负责人只读视角兜底)
|
||||
* @param int $project_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool|string $mustOwner 仅限 null 时尝试部门只读视角
|
||||
* @return self
|
||||
*/
|
||||
public static function findForDepartmentView($project_id, $archived = true, $mustOwner = null)
|
||||
{
|
||||
$user = User::auth();
|
||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
||||
if (UserDepartment::isDepartmentReadonlyProject($departmentView, intval($project_id)) && $mustOwner === null) {
|
||||
$project = self::allData()->where('projects.id', intval($project_id))->first();
|
||||
if (empty($project)) {
|
||||
throw new ApiException('项目不存在或已被删除', [ 'project_id' => $project_id ], -4001);
|
||||
}
|
||||
if ($archived === true && $project->archived_at != null) {
|
||||
throw new ApiException('项目已归档', [ 'project_id' => $project_id ], -4001);
|
||||
}
|
||||
if ($archived === false && $project->archived_at == null) {
|
||||
throw new ApiException('项目未归档', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
return $project;
|
||||
}
|
||||
return self::userProject($project_id, $archived, $mustOwner);
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,16 +22,10 @@ use Request;
|
||||
* @property-read \App\Models\Project|null $project
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ProjectTask> $projectTask
|
||||
* @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 newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn onlyTrashed()
|
||||
* @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 whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectColumn whereDeletedAt($value)
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Module\Base;
|
||||
|
||||
/**
|
||||
* App\Models\ProjectFlow
|
||||
*
|
||||
@ -12,15 +14,9 @@ namespace App\Models;
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ProjectFlowItem> $projectFlowItem
|
||||
* @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 newQuery()
|
||||
* @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 whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlow whereName($value)
|
||||
|
||||
@ -12,7 +12,6 @@ use App\Module\Base;
|
||||
* @property int|null $flow_id 流程ID
|
||||
* @property string|null $name 名称
|
||||
* @property string|null $status 状态
|
||||
* @property string|null $color 自定义颜色
|
||||
* @property array $turns 可流转
|
||||
* @property array $userids 状态负责人ID
|
||||
* @property string|null $usertype 流转模式
|
||||
@ -22,16 +21,9 @@ use App\Module\Base;
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @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 newQuery()
|
||||
* @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 whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereFlowId($value)
|
||||
|
||||
@ -13,15 +13,9 @@ namespace App\Models;
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read bool $already
|
||||
* @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 newQuery()
|
||||
* @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 whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereId($value)
|
||||
|
||||
@ -11,7 +11,6 @@ use App\Module\Base;
|
||||
* @property int|null $project_id 项目ID
|
||||
* @property int|null $column_id 列表ID
|
||||
* @property int|null $task_id 任务ID
|
||||
* @property int|null $task_only 仅任务日志:0否,1是
|
||||
* @property int|null $userid 会员ID
|
||||
* @property string|null $detail 详细信息
|
||||
* @property array $record 记录数据
|
||||
@ -19,15 +18,9 @@ use App\Module\Base;
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\ProjectTask|null $projectTask
|
||||
* @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 newQuery()
|
||||
* @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 whereCreatedAt($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 whereRecord($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 whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class ProjectLog extends AbstractModel
|
||||
{
|
||||
protected $hidden = [
|
||||
'task_only',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param $value
|
||||
|
||||
@ -4,25 +4,19 @@ 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 string|null $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()
|
||||
* @property-read \App\Models\Project|null $project
|
||||
* @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)
|
||||
@ -39,7 +33,6 @@ class ProjectPermission extends AbstractModel
|
||||
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'; // 任务归档
|
||||
@ -71,7 +64,7 @@ class ProjectPermission extends AbstractModel
|
||||
/**
|
||||
* 权限
|
||||
* @param $value
|
||||
* @return array
|
||||
* @return string
|
||||
*/
|
||||
public function getPermissionsAttribute($value)
|
||||
{
|
||||
@ -112,8 +105,7 @@ class ProjectPermission extends AbstractModel
|
||||
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_UPDATE => [self::PERMISSIONS['project_leader'], self::PERMISSIONS['task_leader'], self::PERMISSIONS['task_assist']],
|
||||
self::TASK_STATUS => $taskStatus = [self::PERMISSIONS['project_leader'], self::PERMISSIONS['task_leader']],
|
||||
self::TASK_REMOVE => $taskStatus,
|
||||
self::TASK_ARCHIVED => $taskStatus,
|
||||
@ -128,8 +120,8 @@ class ProjectPermission extends AbstractModel
|
||||
/**
|
||||
* 更新项目权限
|
||||
*
|
||||
* @param int $projectId
|
||||
* @param $newPermissions
|
||||
* @param int $projectId
|
||||
* @param array $permissions
|
||||
* @return ProjectPermission
|
||||
*/
|
||||
public static function updatePermissions($projectId, $newPermissions)
|
||||
@ -146,9 +138,9 @@ class ProjectPermission extends AbstractModel
|
||||
|
||||
/**
|
||||
* 检查用户是否有执行特定动作的权限
|
||||
* @param Project $project 项目实例
|
||||
* @param string $action 动作名称
|
||||
* @param ProjectTask|null $task 任务实例
|
||||
* @param Project $project 项目实例
|
||||
* @param ProjectTask $task 任务实例
|
||||
* @return bool
|
||||
*/
|
||||
public static function userTaskPermission(Project $project, $action, ProjectTask $task = null)
|
||||
@ -156,14 +148,13 @@ class ProjectPermission extends AbstractModel
|
||||
$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:
|
||||
@ -199,7 +190,7 @@ class ProjectPermission extends AbstractModel
|
||||
$desc = [];
|
||||
rsort($permissions);
|
||||
foreach ($permissions as $permission) {
|
||||
$desc[] = Doo::translate(self::PERMISSIONS_DESC[$permission]);
|
||||
$desc[] = self::PERMISSIONS_DESC[$permission];
|
||||
}
|
||||
$desc = array_reverse($desc);
|
||||
throw new ApiException(sprintf("仅限%s操作", implode('、', $desc)));
|
||||
|
||||
@ -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
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -11,33 +11,24 @@ use App\Exceptions\ApiException;
|
||||
* @property int $id
|
||||
* @property int|null $project_id 项目ID
|
||||
* @property int|null $task_id 任务ID
|
||||
* @property int|null $userid 用户ID
|
||||
* @property string|null $desc 内容描述
|
||||
* @property string|null $content 内容
|
||||
* @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|ProjectTaskContent newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskContent newQuery()
|
||||
* @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 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 whereProjectId($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 whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class ProjectTaskContent extends AbstractModel
|
||||
{
|
||||
protected $hidden = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
@ -53,8 +44,8 @@ class ProjectTaskContent extends AbstractModel
|
||||
$array = $this->toArray();
|
||||
$array['content'] = file_get_contents($filePath) ?: '';
|
||||
if ($array['content']) {
|
||||
$replace = Base::fillUrl('uploads');
|
||||
$array['content'] = str_replace('{{RemoteURL}}uploads', $replace, $array['content']);
|
||||
$replace = Base::fillUrl('uploads/task');
|
||||
$array['content'] = str_replace('{{RemoteURL}}uploads/task', $replace, $array['content']);
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
@ -74,7 +65,7 @@ class ProjectTaskContent extends AbstractModel
|
||||
$oldContent = $content;
|
||||
$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) {
|
||||
$tmpPath = $path . 'attached/';
|
||||
Base::makeDir(public_path($tmpPath));
|
||||
@ -84,14 +75,8 @@ class ProjectTaskContent extends AbstractModel
|
||||
$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);
|
||||
foreach ($matches[0] as $key => $fullMatch) {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
$pattern = '/<img(.*?)src=("|\')https*:\/\/(.*?)\/(uploads\/task\/content\/(.*?))\2/is';
|
||||
$content = preg_replace($pattern, '<img$1src=$2{{RemoteURL}}$4$2', $content);
|
||||
//
|
||||
$filePath = $path . md5($content);
|
||||
$publicPath = public_path($filePath);
|
||||
|
||||
@ -22,15 +22,9 @@ use Cache;
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read int $height
|
||||
* @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 newQuery()
|
||||
* @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 whereDownload($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFile whereExt($value)
|
||||
|
||||
@ -14,15 +14,9 @@ namespace App\Models;
|
||||
* @property string|null $after_flow_item_name (变化后)工作流状态名称
|
||||
* @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|ProjectTaskFlowChange newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange newQuery()
|
||||
* @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 whereAfterFlowItemName($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskFlowChange whereBeforeFlowItemId($value)
|
||||
|
||||
@ -17,16 +17,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_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 newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskPushLog onlyTrashed()
|
||||
* @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 whereDeletedAt($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
Loading…
x
Reference in New Issue
Block a user