mirror of
https://github.com/kuaifan/dootask.git
synced 2026-07-20 13:07:58 +00:00
Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1ea0ed3dc | ||
|
|
7b25f243ab | ||
|
|
9b14120055 | ||
|
|
e72758f313 | ||
|
|
746a0bf9a1 | ||
|
|
18163e1e82 | ||
|
|
9a3d9d3b9c | ||
|
|
c9f5296b73 | ||
|
|
f45f86601e | ||
|
|
184fb27680 | ||
|
|
c04187fe47 | ||
|
|
c067991e3e | ||
|
|
b589307ebb | ||
|
|
4a18ff0315 | ||
|
|
09439b555c | ||
|
|
420d46d5cc | ||
|
|
eb672eaef1 | ||
|
|
bc0fe9c748 | ||
|
|
c4904bdbe2 | ||
|
|
b2d54576d0 | ||
|
|
da36d3b319 | ||
|
|
e5a88c2957 | ||
|
|
0896f09878 | ||
|
|
7ca85bfe6b | ||
|
|
0c75d52be0 | ||
|
|
bc8a2c9ded | ||
|
|
889aca311a | ||
|
|
fa9e56944a | ||
|
|
87e05ef9c9 | ||
|
|
7c6b8ce6f4 | ||
|
|
7c6dfe8a25 | ||
|
|
389cb6d709 | ||
|
|
4ca7fc10d1 | ||
|
|
6a4f815d5a | ||
|
|
31729933be | ||
|
|
4a6403d17f | ||
|
|
f8ad608c54 | ||
|
|
97718bc22a | ||
|
|
aa872773f5 | ||
|
|
8e591503dd | ||
|
|
7453b79c8d | ||
|
|
7844f5500b | ||
|
|
f0e48d98e4 | ||
|
|
333f9caa5e | ||
|
|
135b419572 | ||
|
|
a19822a617 | ||
|
|
ac2d4c6c4f | ||
|
|
a3d5bdf93c | ||
|
|
b0a4fe6646 | ||
|
|
97bd58312e | ||
|
|
2f8dee44c2 | ||
|
|
468abe9902 | ||
|
|
27c65cc582 | ||
|
|
3f5078ec9b | ||
|
|
cf6f6041b6 | ||
|
|
e17a520599 | ||
|
|
b544c9d7f6 | ||
|
|
8d9082f7a1 | ||
|
|
6bbcb702dc | ||
|
|
53dadabca0 | ||
|
|
5d2701b0be | ||
|
|
c3ebfcefd1 | ||
|
|
04aa60b574 | ||
|
|
383664aef7 | ||
|
|
8fb6d331f8 | ||
|
|
cbe00f1284 | ||
|
|
645cb02757 | ||
|
|
bfa0920579 | ||
|
|
88fed0744c | ||
|
|
e74142e58d | ||
|
|
da095a1a80 | ||
|
|
9b41330413 | ||
|
|
39b9a72b16 | ||
|
|
4de6c69972 | ||
|
|
e6ef85e176 | ||
|
|
ec1ab31b0e | ||
|
|
af206480fb | ||
|
|
f6067d1bd5 | ||
|
|
20c3fa91fb | ||
|
|
c03867304e | ||
|
|
b595120d62 | ||
|
|
8e66f0bfb3 | ||
|
|
e9ea1adc5d | ||
|
|
2eee171a50 | ||
|
|
fd6a8a3650 | ||
|
|
84a90b7760 | ||
|
|
7335c59b68 | ||
|
|
035c9d9d3d | ||
|
|
36da18af79 | ||
|
|
363badbc97 | ||
|
|
9be6265220 | ||
|
|
be53e6c6ac | ||
|
|
4eab130313 | ||
|
|
c706c515ee | ||
|
|
8a576595ce | ||
|
|
8c809bbff1 | ||
|
|
08ed396444 | ||
|
|
f5eb84589f | ||
|
|
daca384822 | ||
|
|
0a6e944a9a | ||
|
|
e0d1b08e89 | ||
|
|
6b54b7b1c5 | ||
|
|
adc7fb0d07 | ||
|
|
f969c8145c | ||
|
|
20b5daba50 | ||
|
|
aa2e0acaba | ||
|
|
e57736bcc1 | ||
|
|
a8db8dde7b | ||
|
|
635f6e5d5a | ||
|
|
4875574c6e |
53
.claude/hooks/php-stan-check.sh
Executable file
53
.claude/hooks/php-stan-check.sh
Executable file
@ -0,0 +1,53 @@
|
|||||||
|
#!/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
|
||||||
16
.claude/settings.json
Normal file
16
.claude/settings.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/php-stan-check.sh",
|
||||||
|
"timeout": 120
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
119
.claude/skills/dootask-backup/SKILL.md
Normal file
119
.claude/skills/dootask-backup/SKILL.md
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
description: 备份 DooTask 数据:数据库(必须)+ public/uploads(排除 tmp,可选)+ docker/appstore/config(可选)。汇总到临时目录并附 README 说明,打包到 backup/ 按日期命名。只读取源数据、绝不删改,失败即停。
|
||||||
|
---
|
||||||
|
|
||||||
|
# DooTask 数据备份
|
||||||
|
|
||||||
|
**刚性技能**——前置检查 → 选可选项 → 确认 → 执行 → 报告。只读取源数据生成归档,**绝不删除或修改任何源数据/既有备份**。任何一步失败立即停止。
|
||||||
|
|
||||||
|
## 备份范围
|
||||||
|
|
||||||
|
| 项 | 来源 | 是否必须 | 说明 |
|
||||||
|
|----|------|---------|------|
|
||||||
|
| 数据库 | `./cmd mysql backup` 产出的 `.sql.gz` | **必须** | 脚本内部用 mysqldump 导出当前库 |
|
||||||
|
| 上传文件 | `public/uploads`(**排除 `public/uploads/tmp`**) | 可选 | 头像/聊天/任务/文件等真实上传数据;`tmp` 是临时目录,可重建,不备份 |
|
||||||
|
| 应用配置 | `docker/appstore/config` | 可选 | 应用市场各应用的配置;含 **root 属主子目录**,收集时可能需 sudo |
|
||||||
|
|
||||||
|
> `docker/appstore/apps` **不在备份范围**——可从应用市场重新安装,无需备份。
|
||||||
|
|
||||||
|
## 前置检查(全部通过才能继续)
|
||||||
|
|
||||||
|
1. **工作目录**:在项目根(存在 `cmd`、`docker-compose.yml`)
|
||||||
|
2. **数据库容器**:`mariadb` 容器在跑(DB 备份依赖它;不在则提示用户先 `./cmd up` 起服务)
|
||||||
|
3. **磁盘空间**:确认 `backup/` 所在盘空间足够(数据库 dump 可能较大)
|
||||||
|
4. **选可选项**:询问用户本次是否包含 `public/uploads` 和 `docker/appstore/config`(**默认两个都含**)
|
||||||
|
|
||||||
|
检查通过、可选项确定后,汇报本次将备份哪些项,**向用户确认一次**再执行。
|
||||||
|
|
||||||
|
## 执行
|
||||||
|
|
||||||
|
用一个统一时间戳贯穿全程:`TS=$(date +%Y%m%d_%H%M%S)`,临时目录 `WORK="tmp/dootask-backup-${TS}"`。
|
||||||
|
|
||||||
|
### 1) 建临时工作目录
|
||||||
|
```shell
|
||||||
|
mkdir -p "$WORK"
|
||||||
|
```
|
||||||
|
(`tmp/` 已被 gitignore,安全)
|
||||||
|
|
||||||
|
### 2) 数据库(必须)
|
||||||
|
```shell
|
||||||
|
./cmd mysql backup
|
||||||
|
```
|
||||||
|
脚本会把 dump 写到 `docker/mysql/backup/<库名>_<时间戳>.sql.gz` 并打印「备份文件:...」。**取该次产出的最新 dump** 复制进工作目录(不用关心它原始落在哪):
|
||||||
|
```shell
|
||||||
|
DB_FILE=$(ls -t docker/mysql/backup/*.sql.gz | head -1)
|
||||||
|
cp "$DB_FILE" "$WORK/"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) public/uploads(可选,排除 tmp)
|
||||||
|
```shell
|
||||||
|
rsync -a --exclude='tmp' public/uploads/ "$WORK/uploads/"
|
||||||
|
```
|
||||||
|
> 无 rsync 时用 tar 管道:`mkdir -p "$WORK/uploads" && tar cf - --exclude='./tmp' -C public/uploads . | tar xf - -C "$WORK/uploads"`
|
||||||
|
|
||||||
|
### 4) docker/appstore/config(可选)
|
||||||
|
```shell
|
||||||
|
cp -a docker/appstore/config "$WORK/appstore-config"
|
||||||
|
```
|
||||||
|
> 含 root 属主子目录,若报 `permission denied`:改用 `sudo cp -a ...`,随后把整个工作目录属主归还当前用户,保证后续打包/清理不受阻:
|
||||||
|
> ```shell
|
||||||
|
> sudo chown -R "$(id -u):$(id -g)" "$WORK"
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 5) 写 README.md(备份说明)
|
||||||
|
在 `$WORK/README.md` 写明本次备份信息,便于日后识别与还原。模板:
|
||||||
|
```markdown
|
||||||
|
# DooTask 备份 — <TS>
|
||||||
|
|
||||||
|
- 备份时间:<人类可读时间>
|
||||||
|
- DooTask 版本:<取自 package.json 的 version>
|
||||||
|
- 包含内容:
|
||||||
|
- 数据库:<DB dump 文件名>(来源 mysqldump 当前库)
|
||||||
|
- 上传文件:uploads/(来源 public/uploads,已排除 tmp) ← 未选则写「未包含」
|
||||||
|
- 应用配置:appstore-config/(来源 docker/appstore/config) ← 未选则写「未包含」
|
||||||
|
- 各项大小:<du -sh 列出工作目录内各项>
|
||||||
|
|
||||||
|
## 还原提示
|
||||||
|
- 数据库:`gunzip < <db>.sql.gz | mysql -u<user> -p<pass> <库名>`,或用 `./cmd mysql recovery` 选对应文件还原。
|
||||||
|
- 上传文件:将 uploads/ 内容覆盖回项目 public/uploads/。
|
||||||
|
- 应用配置:将 appstore-config/ 覆盖回 docker/appstore/config/。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6) 打包到 backup/,清理临时目录
|
||||||
|
```shell
|
||||||
|
mkdir -p backup
|
||||||
|
tar czf "backup/dootask_backup_${TS}.tar.gz" -C tmp "dootask-backup-${TS}"
|
||||||
|
rm -rf "$WORK"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 报告
|
||||||
|
|
||||||
|
向用户报告:
|
||||||
|
- 最终归档路径:`backup/dootask_backup_<TS>.tar.gz`
|
||||||
|
- 归档大小(`ls -lh`)
|
||||||
|
- 实际包含了哪些项(数据库 + 视选择含/不含 uploads、appstore-config)
|
||||||
|
|
||||||
|
## 失败处理
|
||||||
|
|
||||||
|
- 任何步骤失败立即停止,原样报告错误
|
||||||
|
- **不要**自动重试、不要静默跳过某一项(可选项是否包含由前置确认决定,不在执行中临时变更)
|
||||||
|
- DB 备份失败(如 mariadb 未运行)→ 停止,提示用户起服务后重试
|
||||||
|
- 打包前若工作目录有 root 属主残留导致 tar/rm 失败 → `sudo chown` 归还属主后继续,不要删源数据
|
||||||
|
|
||||||
|
## 禁止项
|
||||||
|
|
||||||
|
| 错误做法 | 正确做法 |
|
||||||
|
|---------|---------|
|
||||||
|
| 为"省空间"删除源数据或既有备份 | 只读取源数据生成归档,源数据一律不动 |
|
||||||
|
| 备份 `public/uploads/tmp` | 排除 tmp(临时、可重建) |
|
||||||
|
| 把 `docker/appstore/apps` 也打进去 | 不在范围,可从应用市场重装 |
|
||||||
|
| 遇 config 的 root 子目录就跳过该项 | `sudo` 收集后 chown 归还,完整备份 |
|
||||||
|
| 不写 README 直接打包 | 每个归档自带 README,便于日后识别还原 |
|
||||||
|
| 把归档写进 git | 归档放 `backup/`(已 gitignore),不提交 |
|
||||||
|
|
||||||
|
## Red Flags —— 出现这些念头立即停下
|
||||||
|
|
||||||
|
- "源数据太大,删点旧的再备份" → 不,备份只读不删
|
||||||
|
- "config 有 root 目录,跳过算了" → 不,sudo 收集后归还属主
|
||||||
|
- "apps 也一起备了更全" → 不,apps 不在范围
|
||||||
|
- "tmp 里临时文件顺手也备了" → 不,明确排除 `public/uploads/tmp`
|
||||||
76
.claude/skills/dootask-fix-permission/SKILL.md
Normal file
76
.claude/skills/dootask-fix-permission/SKILL.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: dootask-fix-permission
|
||||||
|
description: 修复 DooTask 可写目录(bootstrap/cache、docker、public、storage)的属主/权限:chown 回当前用户 + 目录 chmod 775,对齐 install 的赋权逻辑,赋权不删数据。
|
||||||
|
---
|
||||||
|
|
||||||
|
# DooTask 目录权限修复
|
||||||
|
|
||||||
|
容器内进程常以 **root** 写入挂载目录(`storage`、`public/uploads`、`bootstrap/cache` 等),导致宿主机当前用户对这些文件**没有写权限**,进而触发:
|
||||||
|
|
||||||
|
- `./cmd install` 报「目录【xxx】权限不足」/ 目录权限检测失败
|
||||||
|
- `./cmd build`(vite)报 `EACCES: permission denied, copyfile`(复制 `public/uploads/...` 时)
|
||||||
|
- Laravel 运行时写 `storage`/`bootstrap/cache` 失败
|
||||||
|
|
||||||
|
本技能**对齐 `./cmd install` 的目录赋权逻辑**:对四个可写目录做 `chmod 775`(目录)+ `chown` 回当前用户。
|
||||||
|
|
||||||
|
## 适用目录
|
||||||
|
|
||||||
|
与 install 一致的四个:
|
||||||
|
|
||||||
|
```
|
||||||
|
bootstrap/cache
|
||||||
|
docker
|
||||||
|
public # 含 public/uploads(真实上传数据)
|
||||||
|
storage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心原则:赋权,不删数据
|
||||||
|
|
||||||
|
`public/uploads` 含真实上传文件(头像、附件等)。**永远优先 `chown` 改属主,不要删数据。** 即便用户说"清理一下",也只允许清临时目录 `public/uploads/tmp`,**切勿**删 uploads 下其他内容。
|
||||||
|
|
||||||
|
## 前置检查
|
||||||
|
|
||||||
|
1. **工作目录**:在项目根(存在 `cmd` 且这四个目录在)
|
||||||
|
2. **sudo**:改属主需 root(当前文件多为 root 属主)。本机一般可免密 sudo;不行则经 docker 以 root 改权限
|
||||||
|
3. 确认要修的范围:默认四个目录全修;若用户只想解 build 报错,也可只针对 `public`(含 `public/uploads`)
|
||||||
|
|
||||||
|
检查通过后汇报将执行的命令,**向用户确认一次**再执行。
|
||||||
|
|
||||||
|
## 执行
|
||||||
|
|
||||||
|
确认后执行(属主修回当前用户,目录权限 775):
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# 1) 属主修回当前用户(递归)
|
||||||
|
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
|
||||||
|
|
||||||
|
# 2) 目录权限 775(仅目录,对齐 install 的 `find -type d -exec chmod 775`)
|
||||||
|
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
|
||||||
|
```
|
||||||
|
|
||||||
|
> 只想解 build 的 uploads 报错时,可只对 `public`:
|
||||||
|
> ```shell
|
||||||
|
> sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||||
|
> ```
|
||||||
|
|
||||||
|
执行后报告:改了哪些目录、属主/权限现状(可 `ls -ld` 抽查),并提示用户可重试之前失败的 install/build/update。
|
||||||
|
|
||||||
|
## 失败处理
|
||||||
|
|
||||||
|
- `chown` 报权限不足 → 当前用户无 sudo 权限,提示用户用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过
|
||||||
|
- 任何步骤失败立即停止报告,不自动重试
|
||||||
|
|
||||||
|
## 禁止项
|
||||||
|
|
||||||
|
| 错误做法 | 正确做法 |
|
||||||
|
|---------|---------|
|
||||||
|
| build 报 uploads EACCES 就 `rm` 删文件 | `chown` 修属主,保留数据 |
|
||||||
|
| 删整个 `public/uploads` 清场 | 最多清 `public/uploads/tmp`,别碰真实上传数据 |
|
||||||
|
| 对文件无差别 `chmod 777` | 目录 `chmod 775` + `chown` 回当前用户即可 |
|
||||||
|
| 不加 sudo 直接 chown root 文件 | 改属主需 root |
|
||||||
|
|
||||||
|
## Red Flags —— 出现这些念头立即停下
|
||||||
|
|
||||||
|
- "uploads 复制失败,删掉再 build" → 不,`chown` 赋权,不丢数据
|
||||||
|
- "777 一把梭最省事" → 不,按 install 的 775(目录)+ chown
|
||||||
|
- "权限不够就跳过这个目录" → 不,报告交用户处理 sudo
|
||||||
74
.claude/skills/dootask-install/SKILL.md
Normal file
74
.claude/skills/dootask-install/SKILL.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: dootask-install
|
||||||
|
description: 首次部署 DooTask:前置检查后执行 `sudo ./cmd install`(建库 + migrate --seed 的重操作),刚性流程、单次确认、失败即停。
|
||||||
|
---
|
||||||
|
|
||||||
|
# DooTask 安装流程
|
||||||
|
|
||||||
|
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并步骤,不要为"省事"绕过 sudo 或确认。
|
||||||
|
|
||||||
|
`./cmd install` 已把整套安装封装为单条命令(赋权→起容器→`composer install`→`key:generate`→`migrate --seed`→`up -d`)。本技能的职责是**安装前把关、选对参数、执行前确认、已知失败处理**,而不是把脚本逻辑拆开重做。
|
||||||
|
|
||||||
|
## 前置检查(全部通过才能继续)
|
||||||
|
|
||||||
|
执行前依次确认:
|
||||||
|
|
||||||
|
1. **工作目录**:必须在项目根(存在 `cmd`、`docker-compose.yml`、`.env.docker`)
|
||||||
|
2. **Docker**:`docker` 与 `docker-compose`/`docker compose`(v2+) 可用且 daemon 在跑(脚本 `check_docker` 也会查,但提前确认能更早报错)
|
||||||
|
3. **Node.js ≥ 20**(脚本 `check_node` 会查)
|
||||||
|
4. **APP_ID 不冲突**:若 `.env` 已有 `APP_ID` 且被其他实例占用,脚本 `check_instance` 会报错——此时**停止**,提示用户先清空 `.env` 里的 `APP_ID` 和 `APP_IPPR` 再装
|
||||||
|
5. **sudo**:`./cmd install` 需 root(`check_sudo`),用 `sudo ./cmd install` 执行
|
||||||
|
|
||||||
|
⚠️ **这是重操作**:会创建数据库并执行 `migrate --seed`(灌入种子数据)。在已有数据的环境上重装前务必和用户确认,避免覆盖。
|
||||||
|
|
||||||
|
检查通过后汇报结果,**向用户确认一次**再执行。
|
||||||
|
|
||||||
|
## 参数选择
|
||||||
|
|
||||||
|
| 参数 | 作用 | 何时用 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `--port <端口>` | 指定 HTTP 端口(脚本会做端口占用检测) | 用户要自定义端口,或默认端口被占 |
|
||||||
|
| `--relock` | 删除 `node_modules`/`package-lock.json`/`vendor`/`composer.lock` 后重装 | **谨慎**:仅在依赖锁损坏、用户明确要求重建锁时用,会拖慢安装 |
|
||||||
|
|
||||||
|
不确定时不要自作主张加参数,按需询问用户。
|
||||||
|
|
||||||
|
## 执行
|
||||||
|
|
||||||
|
确认后执行(按用户选择带上参数):
|
||||||
|
|
||||||
|
```shell
|
||||||
|
sudo ./cmd install
|
||||||
|
# 或: sudo ./cmd install --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后脚本会输出访问地址并调用 `repassword.sh`。执行完向用户报告:访问地址(`http://127.0.0.1:<APP_PORT>`)、以及数据库密码提示。
|
||||||
|
|
||||||
|
## 失败处理
|
||||||
|
|
||||||
|
- 任何步骤失败立即停止,原样报告错误信息
|
||||||
|
- **不要**自动重试,**不要**自动跳过
|
||||||
|
- 常见失败与对应处理:
|
||||||
|
- `APP_ID(xxx)已被其他实例使用` → 停止,让用户清空 `.env` 的 `APP_ID`/`APP_IPPR` 再装
|
||||||
|
- `端口 xxx 已被占用` → 停止,让用户换 `--port`
|
||||||
|
- `目录【xxx】权限不足` / 目录权限检测失败 → 这是目录属主/权限问题,引导用户用 **dootask-fix-permission** 技能修复后重装
|
||||||
|
- `安装依赖失败`(composer)→ 报告,交用户决定(常因网络/镜像源)
|
||||||
|
|
||||||
|
## 禁止项
|
||||||
|
|
||||||
|
| 错误做法 | 正确做法 |
|
||||||
|
|---------|---------|
|
||||||
|
| 不加 sudo 直接 `./cmd install` | 用 `sudo ./cmd install`(脚本强制 root) |
|
||||||
|
| 失败后"我再试一次"或自动跳过 | 立即停止,交还用户 |
|
||||||
|
| 在已有数据环境上不问就重装 | 先确认会 `migrate --seed`,可能影响现有数据 |
|
||||||
|
| 遇权限报错自己乱 `chmod`/`chown` | 走 dootask-fix-permission 技能统一处理 |
|
||||||
|
| 不问就加 `--relock` | 默认不加;仅用户明确要求或锁损坏时用 |
|
||||||
|
|
||||||
|
## Red Flags —— 出现这些念头立即停下
|
||||||
|
|
||||||
|
- "端口/权限报错了我顺手帮 TA 改一下别的" → 停下,只处理本次报的问题,按指引走对应技能
|
||||||
|
- "种子数据应该没事,直接重装" → 不,先确认是否会覆盖现有数据
|
||||||
|
- "sudo 麻烦,先试试不加" → 不,install 必须 root
|
||||||
204
.claude/skills/dootask-release/SKILL.md
Normal file
204
.claude/skills/dootask-release/SKILL.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
---
|
||||||
|
name: dootask-release
|
||||||
|
description: 从 `pro` 分支发布 DooTask 前端新版本:翻译 → 版本号/更新日志 → 构建 → 提交推送,刚性顺序、每步确认、失败即停。
|
||||||
|
---
|
||||||
|
|
||||||
|
# DooTask 发布流程
|
||||||
|
|
||||||
|
**刚性技能**——严格按顺序执行,每步向用户确认,任何一步失败立即停止。
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
按固定顺序执行,不增删、合并或重排步骤。翻译(Step 1)和更新日志(Step 2)由你直接产出;脚本只做确定性机械工作(算版本号、检测差异、字节级生成语言文件)。
|
||||||
|
|
||||||
|
## 前置检查(全部通过才能继续)
|
||||||
|
|
||||||
|
执行任何发布步骤前,依次检查:
|
||||||
|
|
||||||
|
1. **分支**:必须是 `pro`,否则停止,提示用户切换
|
||||||
|
2. **工作区**:`git status` 必须干净(无未提交变更、无未跟踪文件),否则**停止**并交由用户处理
|
||||||
|
3. **Node.js**:`node --version` 必须 ≥ 20
|
||||||
|
4. **PHP**:`php --version` 必须可用(Step 1 的脚本依赖本地 php,无需容器)。若 host 无 php,停止并提示用户
|
||||||
|
|
||||||
|
检查通过后汇报结果,用户确认后再开始执行。
|
||||||
|
|
||||||
|
## 发布步骤
|
||||||
|
|
||||||
|
**每步执行前**向用户确认;**每步执行后**报告结果。
|
||||||
|
|
||||||
|
开始前先把这份清单复制到你的回复里,逐项勾选、跟踪进度:
|
||||||
|
|
||||||
|
```
|
||||||
|
发布进度:
|
||||||
|
- [ ] 前置检查(分支 pro / 工作区干净 / node≥20 / php 可用)
|
||||||
|
- [ ] Step 1 翻译(diff → 翻译 → apply → generate)
|
||||||
|
- [ ] Step 2 版本号 + CHANGELOG
|
||||||
|
- [ ] Step 3 构建(./cmd prod)
|
||||||
|
- [ ] 汇总变更 → 用户确认 → commit + push
|
||||||
|
- [ ] 确认 GitHub Actions Publish 工作流 success
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1: 翻译
|
||||||
|
|
||||||
|
多语言数据流:`language/original-{web,api}.txt`(原文/简体中文)→ 经翻译写入 `language/translate.json`(含 9 种语言)→ 生成 `public/language/{web,api}/*`。
|
||||||
|
|
||||||
|
**1.1 检测差异**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
php .claude/skills/dootask-release/scripts/language.php diff
|
||||||
|
```
|
||||||
|
|
||||||
|
输出 JSON:
|
||||||
|
- `regexErrorCount > 0`:translate.json **已有条目**的占位符与某语言值不一致 → **停止**,报告 `regexErrors`,交用户修复(这是历史数据问题,不要自行猜测修改)
|
||||||
|
- `redundantCount > 0`:translate.json 里有、但原文已删除的条目 → 仅作提示(apply 时会自动剔除,不致命)
|
||||||
|
- `needsCount == 0`:无新文案 → **跳到 1.4 直接生成**
|
||||||
|
- `needsCount > 0`:`needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`)→ 进入 1.2
|
||||||
|
|
||||||
|
**1.2 翻译**
|
||||||
|
|
||||||
|
对 `needs` 里的每个 `key`,翻成 8 种语言(`zh` 留空、`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`。
|
||||||
|
|
||||||
|
要求:贴合「项目任务管理系统」语境;占位符 `(%T1)`/`(%M1)` 等原样保留、不可增删改,位置可随目标语言语序调整:
|
||||||
|
|
||||||
|
| 原文 | 翻成英语 |
|
||||||
|
|---|---|
|
||||||
|
| (%T1)的周报[(%T2)][(%T3)月第(%T4)周] | Weekly report of (%T1) [(%T2)] [Week (%T4) of month (%T3)] |
|
||||||
|
| (%T1)提交的「(%M2)」待你审批 | '(%M2)' submitted by (%T1) is waiting for your approval |
|
||||||
|
|
||||||
|
把结果写成一个 JSON 数组文件(建议放 `/tmp/dootask-release-translated.json`,避免污染工作区),每个元素含全部 10 个字段,顺序为:
|
||||||
|
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru`(`zh` 写 `""`)。
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"key":"...(%T1)...","zh":"","zh-CHT":"...","en":"...","ko":"...","ja":"...","de":"...","fr":"...","id":"...","ru":"..."}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**1.3 合并进 translate.json**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-release-translated.json
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会校验字段完整性与占位符完整性、追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。任一条不合格会报错停止,按提示修正翻译后重试。
|
||||||
|
|
||||||
|
**1.4 生成前端/后端语言文件**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
php .claude/skills/dootask-release/scripts/language.php generate
|
||||||
|
```
|
||||||
|
|
||||||
|
由 `translate.json` 字节级重新生成 `public/language/web/*.js` 与 `public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。
|
||||||
|
|
||||||
|
**1.5 报告**:用 `git status --short language public/language` 汇总本步改动,向用户报告新增了多少条翻译。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: 版本号 + 更新日志
|
||||||
|
|
||||||
|
**2.1 计算并写入版本号**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
node .claude/skills/dootask-release/scripts/version_bump.js
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本据 git 历史算出新 `version` 与 `codeVerson` 并写入 `package.json`,输出 JSON 含:`version`、`prevVersion`、`changelogRange`(如 `<上次release提交>..HEAD`,用于下一步圈定本次更新范围)。
|
||||||
|
|
||||||
|
**2.2 撰写 CHANGELOG**
|
||||||
|
|
||||||
|
读取本次区间的提交:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
git log <changelogRange> --stat
|
||||||
|
```
|
||||||
|
|
||||||
|
`--stat` 会带上每个提交的完整描述正文 + 改动文件清单;光看标题不够时用 `git show <hash>` 看具体代码改动。
|
||||||
|
|
||||||
|
按 `CHANGELOG.md` 现有格式,在文件顶部 `# Changelog` 说明段之后、紧挨上一个 `## [...]` 之前,插入新版本区段:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [<version>]
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
撰写要求(对齐项目历史风格):
|
||||||
|
- 小节标题用**英文 Title Case**:`Features` / `Bug Fixes` / `Performance` / `Documentation` / `Security` / `Miscellaneous`,**不要译成中文**;**没有内容的小节整段省略**。
|
||||||
|
- 条目正文用**通俗友好的简体中文**,面向**普通用户**描述更新带来的直接好处,**避免技术术语**(如 refactor、merge branch、commit lint、bump deps 等)。
|
||||||
|
- 过滤掉对用户无意义的提交(纯构建/依赖/CI/合并提交、本技能自身的脚手架改动等)。
|
||||||
|
- 仅凭提交标题无法判断是否对用户有价值时,结合提交的完整描述正文和实际代码改动(`git show <hash>`)再决定,不要只看一行就下结论。
|
||||||
|
- 合并相似项;每个小节内**按用户价值与影响范围排序,重要的在前**。
|
||||||
|
|
||||||
|
**2.3 报告**:展示新版本号与你写的 changelog 区段,请用户过目。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: 构建前端
|
||||||
|
|
||||||
|
```shell
|
||||||
|
./cmd prod
|
||||||
|
```
|
||||||
|
|
||||||
|
构建前端生产版本。用 `./cmd prod`,不要换成裸跑 vite(它还负责 node 检查、清 `public/js/build`、debug 切换)。
|
||||||
|
|
||||||
|
> **已知失败**:build 报 `public/uploads/...` 的 `EACCES: permission denied, copyfile`,是 vite 复制 `public/` 时撞到 root 属主的运行时上传文件(不限于 `tmp`,`avatar` 等都可能)。补救是赋权、不是删数据——把 uploads 属主改回当前用户后重试:
|
||||||
|
> ```shell
|
||||||
|
> sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||||
|
> ```
|
||||||
|
> `public/uploads` 是真实上传数据,**不要删**;即便要清也只清 `public/uploads/tmp`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最终:提交并推送
|
||||||
|
|
||||||
|
所有步骤完成后:
|
||||||
|
|
||||||
|
1. 通过 `git diff` + `git status` 汇总所有变更,向用户报告摘要
|
||||||
|
2. **询问用户是否提交并推送**
|
||||||
|
3. 用户明确确认后才执行 `git add`、`git commit`、`git push`
|
||||||
|
4. 未确认一律不执行
|
||||||
|
|
||||||
|
提交规范:
|
||||||
|
- 提交信息使用 `release: v<新版本号>`(与历史一致,参见 `git log --oneline | grep '^release:'`)
|
||||||
|
- **只 add 本次发布相关改动**,按文件名/目录显式添加(例如 `git add package.json CHANGELOG.md language/translate.json public/language public/js`),不要用 `git add -A` / `git add .`,以免卷入未跟踪的本地实验文件
|
||||||
|
- 不打 git tag(现行发布流程不使用 tag)
|
||||||
|
- 确认前先核对:`/tmp/dootask-release-translated.json` 等临时文件不在仓库内,工作区不应残留发布无关的未跟踪文件
|
||||||
|
|
||||||
|
## push 之后:确认发布工作流(CI 才是真正出包)
|
||||||
|
|
||||||
|
push 到 `pro` 只是触发器,真正的构建/出包由 GitHub Actions 完成——**push 成功 ≠ 发布完成**:
|
||||||
|
|
||||||
|
- **Publish**(`.github/workflows/publish.yml`,push→pro 触发)跑完才算出包;成功后会自动触发 **Sync to Gitee**(镜像同步)。
|
||||||
|
- push 完成后**主动确认** Publish 工作流 `conclusion=success`。优先用 `gh`(未装可临时装;公开仓库也可用 GitHub REST API 免鉴权读取 runs):
|
||||||
|
```shell
|
||||||
|
gh run list --workflow=publish.yml -R kuaifan/dootask -L 1
|
||||||
|
gh run view <run-id> -R kuaifan/dootask --json status,conclusion,url
|
||||||
|
```
|
||||||
|
- 工作流仍在跑时,挂后台轮询、结束即通知用户,**不要在前台死等**。
|
||||||
|
|
||||||
|
### iOS 发布(询问后决定)
|
||||||
|
|
||||||
|
`ios-publish.yml` 是**独立的手动工作流**(`workflow_dispatch`),不随 push 触发。Publish 成功后,用 options 或 AskUserQuestion 形式提问是否同时发布 iOS(选项:发布 iOS / 不发布):
|
||||||
|
|
||||||
|
- 选「发布 iOS」才执行:
|
||||||
|
```shell
|
||||||
|
gh workflow run ios-publish.yml --ref pro -R kuaifan/dootask
|
||||||
|
```
|
||||||
|
需 `gh` 已登录且 token 含 `workflow` 权限;触发后可挂后台轮询结果。
|
||||||
|
- 选「不发布」则结束。
|
||||||
|
|
||||||
|
## 失败处理
|
||||||
|
|
||||||
|
任何步骤失败立即停止、报告错误信息,交用户决定;不要自动重试或跳过。
|
||||||
239
.claude/skills/dootask-release/scripts/language.php
Normal file
239
.claude/skills/dootask-release/scripts/language.php
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
<?php
|
||||||
|
// DooTask 发布——翻译流水线(纯本地 php,host 直接跑,不进容器、不调 OpenAI、不需 autoload)。
|
||||||
|
// 逐行对齐 language/translate.php 的检测/保存/生成逻辑,唯独把"调用外部模型翻译"那一段抽走,
|
||||||
|
// 翻译改在技能流程内完成。用 php 而非 node 的唯一原因:array_multisort + json_encode
|
||||||
|
// 的逐字节产物必须与项目原生工具一致,否则每次发版都会产生大面积排序/转义噪声 diff(已验证 host php 可字节级复现)。
|
||||||
|
//
|
||||||
|
// 子命令:
|
||||||
|
// language.php diff
|
||||||
|
// —— 输出 JSON:needs(待翻译,key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示) / regexErrors(占位符错乱,致命)
|
||||||
|
// language.php apply <translated.json>
|
||||||
|
// —— 把新翻译合并进 translate.json(追加 + 剔除冗余),不生成 public 文件
|
||||||
|
// language.php generate
|
||||||
|
// —— 由 translate.json 重新生成 public/language/{web,api}/*
|
||||||
|
//
|
||||||
|
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
|
||||||
|
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||||
|
|
||||||
|
$ROOT = dirname(__DIR__, 4);
|
||||||
|
$LANG_DIR = $ROOT . '/language';
|
||||||
|
$LANG_FIELDS = ['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru'];
|
||||||
|
|
||||||
|
if (!is_dir($LANG_DIR)) {
|
||||||
|
fwrite(STDERR, "未找到 language 目录($LANG_DIR)。\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
chdir($LANG_DIR);
|
||||||
|
|
||||||
|
$cmd = $argv[1] ?? '';
|
||||||
|
|
||||||
|
// ---- 公共:读取 original-*.txt ----
|
||||||
|
function read_generateds(): array
|
||||||
|
{
|
||||||
|
$originals = [];
|
||||||
|
$generateds = [];
|
||||||
|
foreach (['web', 'api'] as $type) {
|
||||||
|
$content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : "";
|
||||||
|
$array = array_values(array_filter(array_unique(explode("\n", $content))));
|
||||||
|
$generateds[$type] = $array;
|
||||||
|
$originals = array_merge($originals, $array);
|
||||||
|
}
|
||||||
|
return [$originals, $generateds];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 公共:构建 translations 映射(normalizedKey -> obj),并收集冗余/占位符错乱 ----
|
||||||
|
function build_translations(array $originals): array
|
||||||
|
{
|
||||||
|
$translations = [];
|
||||||
|
$redundants = [];
|
||||||
|
$regrror = [];
|
||||||
|
if (!file_exists("translate.json")) {
|
||||||
|
fwrite(STDERR, "translate.json not exists\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$tmps = json_decode(file_get_contents("translate.json"), true);
|
||||||
|
foreach ($tmps as $obj) {
|
||||||
|
if (!isset($obj['key'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$currentKey = $obj['key'];
|
||||||
|
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
|
||||||
|
if (!in_array($originalKey, $originals)) {
|
||||||
|
$redundants[$originalKey] = $obj;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$translations[$originalKey] = $obj;
|
||||||
|
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
|
||||||
|
foreach ($matches[0] as $match) {
|
||||||
|
foreach ($obj as $k => $v) {
|
||||||
|
if (empty($v)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!str_contains($v, $match)) {
|
||||||
|
$regrror[$originalKey] = ['key' => $currentKey, 'field' => $k, 'value' => $v, 'match' => $match];
|
||||||
|
continue 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [$translations, $redundants, $regrror];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 公共:由 translate.json + originals 重新生成 public 文件 ----
|
||||||
|
function generate(array $generateds, array $translations): void
|
||||||
|
{
|
||||||
|
foreach ($generateds as $type => $array) {
|
||||||
|
$datas = [];
|
||||||
|
foreach ($array as $text) {
|
||||||
|
$text = trim($text);
|
||||||
|
if (isset($translations[$text])) {
|
||||||
|
$datas[] = $translations[$text];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$inOrder = [];
|
||||||
|
foreach ($datas as $index => $item) {
|
||||||
|
if (preg_match('/\(%[TM]\d+\)/', $item['key'])) {
|
||||||
|
$inOrder[$index] = strlen($item['key']);
|
||||||
|
} else {
|
||||||
|
$inOrder[$index] = strlen($item['key']) + 10000000000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
array_multisort($inOrder, SORT_DESC, $datas);
|
||||||
|
$results = [];
|
||||||
|
foreach ($datas as $items) {
|
||||||
|
foreach ($items as $kk => $item) {
|
||||||
|
$results[$kk][] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($type === 'api') {
|
||||||
|
if (!is_dir("../public/language/api")) {
|
||||||
|
mkdir("../public/language/api", 0777, true);
|
||||||
|
}
|
||||||
|
foreach ($results as $kk => $item) {
|
||||||
|
file_put_contents("../public/language/api/$kk.json", json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
} elseif ($type === 'web') {
|
||||||
|
if (!is_dir("../public/language/web")) {
|
||||||
|
mkdir("../public/language/web", 0777, true);
|
||||||
|
}
|
||||||
|
foreach ($results as $kk => $item) {
|
||||||
|
file_put_contents("../public/language/web/$kk.js", "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "[$type] total: " . count($results['key']) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'diff') {
|
||||||
|
[$originals, $generateds] = read_generateds();
|
||||||
|
[$translations, $redundants, $regrror] = build_translations($originals);
|
||||||
|
|
||||||
|
// 需要翻译的数据(对齐 translate.php 150-169:占位符按单一计数器编号)
|
||||||
|
$needs = [];
|
||||||
|
foreach ($originals as $text) {
|
||||||
|
$key = trim($text);
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($translations[$key])) {
|
||||||
|
$needs[$key] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$needsOut = [];
|
||||||
|
foreach ($needs as $key) {
|
||||||
|
$c = 1;
|
||||||
|
$converted = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) {
|
||||||
|
$label = strlen($m[1]) > 1 ? "M" : "T";
|
||||||
|
return "(%" . $label . $c++ . ")";
|
||||||
|
}, $key);
|
||||||
|
$needsOut[] = ['key' => $converted];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'needsCount' => count($needsOut),
|
||||||
|
'redundantCount' => count($redundants),
|
||||||
|
'regexErrorCount' => count($regrror),
|
||||||
|
'needs' => $needsOut,
|
||||||
|
'redundants' => array_keys($redundants),
|
||||||
|
'regexErrors' => array_values($regrror),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||||
|
|
||||||
|
if (count($regrror) > 0) {
|
||||||
|
exit(2); // 已有数据占位符错乱,需先修复
|
||||||
|
}
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'apply') {
|
||||||
|
$file = $argv[2] ?? '';
|
||||||
|
if ($file === '' || !file_exists($file)) {
|
||||||
|
fwrite(STDERR, "用法:apply <translated.json>(文件不存在)\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
[$originals, $generateds] = read_generateds();
|
||||||
|
[$translations, $redundants, $regrror] = build_translations($originals);
|
||||||
|
if (count($regrror) > 0) {
|
||||||
|
fwrite(STDERR, "translate.json 已有条目占位符错乱,请先修复再发版。\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$incoming = json_decode(file_get_contents($file), true);
|
||||||
|
if (!is_array($incoming)) {
|
||||||
|
fwrite(STDERR, "translated.json 必须是数组\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$added = 0;
|
||||||
|
foreach ($incoming as $raw) {
|
||||||
|
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||||
|
if (!array_key_exists($f, $raw)) {
|
||||||
|
fwrite(STDERR, "新翻译缺字段 \"$f\":" . json_encode($raw, JSON_UNESCAPED_UNICODE) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 占位符完整性:key 里每个 (%T1)/(%M1) 必须出现在每个非空语言值里
|
||||||
|
if (preg_match_all('/\(%[TM]\d+\)/', $raw['key'], $m)) {
|
||||||
|
foreach ($m[0] as $match) {
|
||||||
|
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||||
|
if ($f === 'key' || $f === 'zh') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (empty($raw[$f])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!str_contains($raw[$f], $match)) {
|
||||||
|
fwrite(STDERR, "占位符 $match 在字段 \"$f\" 缺失:{$raw['key']}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 规范化:固定字段顺序 + zh 置空
|
||||||
|
$item = [];
|
||||||
|
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||||
|
$item[$f] = $f === 'zh' ? '' : $raw[$f];
|
||||||
|
}
|
||||||
|
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $item['key']);
|
||||||
|
$translations[$originalKey] = $item;
|
||||||
|
$added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// array_values:现有条目(去冗余)在前,新条目追加在后
|
||||||
|
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
|
echo json_encode([
|
||||||
|
'added' => $added,
|
||||||
|
'total' => count($translations),
|
||||||
|
'droppedRedundant' => count($redundants),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'generate') {
|
||||||
|
[$originals, $generateds] = read_generateds();
|
||||||
|
[$translations] = build_translations($originals);
|
||||||
|
generate($generateds, $translations);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "未知子命令:'$cmd'。可用:diff | apply <file> | generate\n");
|
||||||
|
exit(1);
|
||||||
47
.claude/skills/dootask-release/scripts/version_bump.js
vendored
Normal file
47
.claude/skills/dootask-release/scripts/version_bump.js
vendored
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// 计算并写入新版本号到 package.json(version + codeVerson),算法对齐 bin/version.js。
|
||||||
|
// 不生成 CHANGELOG(在技能流程内撰写),只输出版本号与 changelog 的提交区间。
|
||||||
|
//
|
||||||
|
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '../../../..');
|
||||||
|
const pkgFile = path.join(ROOT, 'package.json');
|
||||||
|
const verOffset = 6394; // 版本号偏移量(与 bin/version.js 一致)
|
||||||
|
const codeOffset = 35; // 代码版本号偏移量
|
||||||
|
|
||||||
|
function git(cmd) {
|
||||||
|
return execSync(cmd, { cwd: ROOT, maxBuffer: 1024 * 1024 * 10 }).toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const verCount = parseInt(git('git rev-list --count HEAD'), 10);
|
||||||
|
const codeCount = parseInt(git("git tag --merged pro -l 'v*' | wc -l"), 10);
|
||||||
|
const num = verOffset + verCount;
|
||||||
|
if (Number.isNaN(num)) {
|
||||||
|
console.error(`版本计算失败:rev-list count=${verCount}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
|
||||||
|
const codeVersion = codeOffset + codeCount;
|
||||||
|
|
||||||
|
let pkg = fs.readFileSync(pkgFile, 'utf8');
|
||||||
|
const prevVersion = (pkg.match(/"version":\s*"(.*?)"/) || [])[1] || '';
|
||||||
|
pkg = pkg.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
|
||||||
|
pkg = pkg.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
|
||||||
|
fs.writeFileSync(pkgFile, pkg, 'utf8');
|
||||||
|
|
||||||
|
// 上一个 release 提交作为 changelog 区间下界
|
||||||
|
let prevReleaseCommit = '';
|
||||||
|
try {
|
||||||
|
prevReleaseCommit = git("git log --grep='^release: v' -n 1 --pretty=format:%H");
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
version,
|
||||||
|
codeVersion,
|
||||||
|
prevVersion,
|
||||||
|
prevReleaseCommit,
|
||||||
|
changelogRange: prevReleaseCommit ? `${prevReleaseCommit}..HEAD` : '(未找到上一个 release 提交,需人工确定区间)',
|
||||||
|
}, null, 2));
|
||||||
83
.claude/skills/dootask-update/SKILL.md
Normal file
83
.claude/skills/dootask-update/SKILL.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: dootask-update
|
||||||
|
description: 更新已部署的 DooTask:前置检查后执行 `sudo ./cmd update`(拉代码 + composer + 迁移 + 重启),本地有改动时停下交用户决定,不自动强制、失败即停。
|
||||||
|
---
|
||||||
|
|
||||||
|
# DooTask 更新流程
|
||||||
|
|
||||||
|
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
**违反字面规则 = 违反流程精神。** 不要擅自加步骤、绕过 sudo/确认,**尤其不要替用户决定强制更新**(会丢本地改动)。
|
||||||
|
|
||||||
|
`./cmd update` 已封装整套更新(检测本地改动→`git fetch`→必要时备份库→`git pull/reset`→`composer install`→`migrate`→重启 php+nginx→写 `UPDATE_TIME`)。本技能职责是**更新前把关、选对参数、处理本地改动这一关键岔路、执行前确认**。
|
||||||
|
|
||||||
|
## 前置检查(全部通过才能继续)
|
||||||
|
|
||||||
|
1. **已安装**:必须存在 `vendor/autoload.php`(脚本会查,没装则报"请先执行安装命令"——此时引导用户走 dootask-install)
|
||||||
|
2. **工作目录**:在项目根
|
||||||
|
3. **当前分支 / 目标分支**:默认更新当前分支;用户要切分支用 `--branch <分支>`。若用户没说,确认是否就更新当前分支
|
||||||
|
4. **本地改动**(关键):`git status` 看是否有未提交改动
|
||||||
|
5. **sudo**:`sudo ./cmd update` 需 root
|
||||||
|
|
||||||
|
检查通过后汇报结果,**向用户确认一次**再执行。
|
||||||
|
|
||||||
|
## 关键岔路:本地有改动
|
||||||
|
|
||||||
|
脚本检测到本地改动时会询问是否强制更新。**强制更新 = `git reset --hard origin/<分支>`,会丢弃所有本地改动。**
|
||||||
|
|
||||||
|
- 发现本地有改动 → **停下**,把改动清单报告用户,让**用户决定**:先提交/暂存改动,还是确认强制更新
|
||||||
|
- **不要**替用户选 `--force`
|
||||||
|
- 只有用户明确说"丢掉改动强制更新"时,才带 `--force`
|
||||||
|
|
||||||
|
## 参数选择
|
||||||
|
|
||||||
|
| 参数 | 作用 | 何时用 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `--branch <分支>` | 切到指定分支再更新 | 用户要换分支(如切 `dev`/`pro`) |
|
||||||
|
| `--force` | 强制更新:`git checkout -f` + `git reset --hard` | **危险**:仅用户明确接受"丢弃本地改动"后 |
|
||||||
|
| `--local` | 本地更新模式:只备份库 + `migrate` + 重启,不拉远程代码 | 代码已就位(如手动改过/CI 拉过),只需迁移+重启 |
|
||||||
|
|
||||||
|
## 数据库
|
||||||
|
|
||||||
|
- 远程模式下,脚本检测到 `database/` 目录有迁移变动会**自动备份数据库**再继续——这是脚本内置的,无需手动。
|
||||||
|
- 但若是大版本升级或用户在意数据,执行前提醒用户:本次可能含库迁移,已有自动备份兜底;如需可先 `./cmd mysql backup` 额外备份。
|
||||||
|
|
||||||
|
## 执行
|
||||||
|
|
||||||
|
确认(含本地改动决策)后执行:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
sudo ./cmd update
|
||||||
|
# 切分支: sudo ./cmd update --branch pro
|
||||||
|
# 强制(丢改动,用户确认后): sudo ./cmd update --force
|
||||||
|
# 本地模式: sudo ./cmd update --local
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后报告:更新到的分支、是否做了库备份/迁移、服务是否重启完成。
|
||||||
|
|
||||||
|
## 失败处理
|
||||||
|
|
||||||
|
- 任何步骤失败立即停止,原样报告错误
|
||||||
|
- **不要**自动重试、不要自动跳过、不要因为 `git pull` 失败就自己改成 `--force`
|
||||||
|
- 常见失败:
|
||||||
|
- `请先执行安装命令` → 走 dootask-install
|
||||||
|
- `代码拉取失败,可能存在冲突` → 报告,让用户决定是否 `--force`(丢改动)或先处理冲突
|
||||||
|
- 重启服务失败 → 脚本会尝试 `down` 后重起;若仍失败,报告交用户
|
||||||
|
|
||||||
|
## 禁止项
|
||||||
|
|
||||||
|
| 错误做法 | 正确做法 |
|
||||||
|
|---------|---------|
|
||||||
|
| 检测到本地改动就自动 `--force` | 停下,报告改动,交用户决定 |
|
||||||
|
| `git pull` 失败就自动改用 `--force` | 报告冲突,交用户 |
|
||||||
|
| 不加 sudo | `sudo ./cmd update` |
|
||||||
|
| 未装就更新 | 先走 dootask-install |
|
||||||
|
| 失败后自动重试/跳过 | 立即停止 |
|
||||||
|
|
||||||
|
## Red Flags —— 出现这些念头立即停下
|
||||||
|
|
||||||
|
- "有点本地改动,强制更新一下就好了" → 不,`--force` 会丢改动,必须用户拍板
|
||||||
|
- "拉取冲突了,我 reset 一下" → 不,交用户决定
|
||||||
|
- "已经装过了吧,直接更新" → 先确认 `vendor/autoload.php` 在
|
||||||
@ -1,83 +0,0 @@
|
|||||||
---
|
|
||||||
name: release
|
|
||||||
description: Use when releasing a new DooTask frontend version from the `pro` branch. Rigid sequential workflow (translate → version → build → commit → push) with strict pre-checks (branch, clean worktree, Node 20+) and per-step user confirmation. Use when user says "发布新版本", "release", "出新版本", "打版本". Stop on any failure; do NOT auto-fix dirty worktree, do NOT add tag step, do NOT use `git add -A`.
|
|
||||||
---
|
|
||||||
|
|
||||||
# DooTask 发布流程
|
|
||||||
|
|
||||||
**刚性技能**——严格按顺序执行,每步向用户确认,任何一步失败立即停止。
|
|
||||||
|
|
||||||
## 核心原则
|
|
||||||
|
|
||||||
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并或重排步骤。
|
|
||||||
|
|
||||||
## 前置检查(全部通过才能继续)
|
|
||||||
|
|
||||||
执行任何发布步骤前,依次检查:
|
|
||||||
|
|
||||||
1. **分支**:必须是 `pro`,否则停止,提示用户切换
|
|
||||||
2. **工作区**:`git status` 必须干净(无未提交变更、无未跟踪文件),否则**停止**并交由用户处理
|
|
||||||
3. **Node.js**:必须 ≥ 20,否则停止
|
|
||||||
|
|
||||||
检查通过后汇报结果,用户确认后再开始执行。
|
|
||||||
|
|
||||||
## 发布步骤
|
|
||||||
|
|
||||||
**每步执行前**向用户确认;**每步执行后**报告结果。
|
|
||||||
|
|
||||||
### Step 1: 翻译
|
|
||||||
```shell
|
|
||||||
npm run translate
|
|
||||||
```
|
|
||||||
更新多语言翻译文件。
|
|
||||||
|
|
||||||
### Step 2: 版本号
|
|
||||||
```shell
|
|
||||||
npm run version
|
|
||||||
```
|
|
||||||
更新版本号。
|
|
||||||
|
|
||||||
### Step 3: 构建前端
|
|
||||||
```shell
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
构建前端生产版本。
|
|
||||||
|
|
||||||
## 最终:提交并推送
|
|
||||||
|
|
||||||
所有步骤完成后:
|
|
||||||
|
|
||||||
1. 通过 `git diff` + `git status` 汇总所有变更,向用户报告摘要
|
|
||||||
2. **询问用户是否提交并推送**
|
|
||||||
3. 用户明确确认后才执行 `git add`、`git commit`、`git push`
|
|
||||||
4. 未确认一律不执行
|
|
||||||
|
|
||||||
提交规范:
|
|
||||||
- 提交信息使用 `release: v<新版本号>`(与历史提交风格一致,参见 `git log --oneline | grep '^release:'`)
|
|
||||||
- **只 add 本次发布相关改动**,按文件名显式添加(例如 `git add package.json public/js/...`),**不要用 `git add -A` 或 `git add .`**,以免卷入未跟踪的本地实验文件
|
|
||||||
|
|
||||||
## 失败处理
|
|
||||||
|
|
||||||
- 任何步骤失败立即停止,报告错误信息
|
|
||||||
- **不要**自动重试
|
|
||||||
- **不要**自动跳过失败步骤
|
|
||||||
- 由用户决定如何处理
|
|
||||||
|
|
||||||
## 禁止项(基线测试暴露的反模式)
|
|
||||||
|
|
||||||
| 错误做法 | 正确做法 |
|
|
||||||
|---------|---------|
|
|
||||||
| 遇到脏工作区主动提出修复方案(加 `.gitignore`、先 push 等) | **停下**,报告脏工作区事实,交用户决定 |
|
|
||||||
| 增加 `git tag v1.7.xx` 步骤 | DooTask 现行发布流程**不打 tag**,不要擅自添加 |
|
|
||||||
| `git add -A` / `git add .` | 按文件名显式添加发布相关改动 |
|
|
||||||
| 一次性 add + commit + push,不给确认机会 | 摘要 → 问确认 → 再 add/commit/push 三步分离 |
|
|
||||||
| 把 translate/version/build 顺序自作主张调整 | 顺序固定为 translate → version → build |
|
|
||||||
| 失败后"我再试一次"或"跳过这步" | 立即停止,交还给用户 |
|
|
||||||
|
|
||||||
## Red Flags —— 出现这些念头立即停下
|
|
||||||
|
|
||||||
- "这个脏工作区我来帮 TA 搞定一下" → 停下,交用户
|
|
||||||
- "顺便打个 tag 吧" → 不,没有这一步
|
|
||||||
- "`git add -A` 省事" → 不,显式 add
|
|
||||||
- "翻译这步没改动可以跳" → 不,按顺序执行、执行后报告结果即可
|
|
||||||
- "一起 commit + push 一气呵成" → 必须先让用户确认
|
|
||||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@ -92,7 +92,7 @@ jobs:
|
|||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.0'
|
php-version: '8.4'
|
||||||
extensions: mbstring, intl, gd, xml, zip, swoole
|
extensions: mbstring, intl, gd, xml, zip, swoole
|
||||||
tools: composer:v2
|
tools: composer:v2
|
||||||
|
|
||||||
|
|||||||
81
.github/workflows/tests.yml
vendored
Normal file
81
.github/workflows/tests.yml
vendored
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
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
|
||||||
|
'
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -7,6 +7,7 @@
|
|||||||
/public/hot
|
/public/hot
|
||||||
/public/tmp
|
/public/tmp
|
||||||
/tmp
|
/tmp
|
||||||
|
/backup
|
||||||
|
|
||||||
# Uploads and user-generated content
|
# Uploads and user-generated content
|
||||||
/public/summary
|
/public/summary
|
||||||
@ -64,3 +65,4 @@ README_LOCAL.md
|
|||||||
|
|
||||||
# playwright
|
# playwright
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
/.phpunit.cache
|
||||||
|
|||||||
@ -158,11 +158,6 @@ drawio/webapp/js/app.min.js
|
|||||||
drawio/webapp/js/extensions.min.js
|
drawio/webapp/js/extensions.min.js
|
||||||
drawio/webapp/js/shapes-14-6-5.min.js
|
drawio/webapp/js/shapes-14-6-5.min.js
|
||||||
drawio/webapp/js/stencils.min.js
|
drawio/webapp/js/stencils.min.js
|
||||||
drawio/webapp/math/es5/core.js
|
|
||||||
drawio/webapp/math/es5/input/asciimath.js
|
|
||||||
drawio/webapp/math/es5/input/tex.js
|
|
||||||
drawio/webapp/math/es5/output/svg.js
|
|
||||||
drawio/webapp/math/es5/output/svg/fonts/tex.js
|
|
||||||
drawio/webapp/styles/grapheditor.css
|
drawio/webapp/styles/grapheditor.css
|
||||||
|
|
||||||
minder/css/chunk-vendors.fe9c56c6.css
|
minder/css/chunk-vendors.fe9c56c6.css
|
||||||
|
|||||||
103
CHANGELOG.md
103
CHANGELOG.md
@ -2,6 +2,109 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [1.8.69]
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- 文件页全新分区:拆分「我的文件」与「共享文件」两个板块,共享板块还能进一步筛选「我共享的」和「共享给我的」;上方工具栏合并为一行,查找和切换更清爽。
|
||||||
|
- AI 助手支持隐藏模型:可将部分模型对普通用户隐藏,选择列表更简洁;当正在使用的模型被隐藏时,会自动切换到可用模型,避免选择异常。
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- 修复自定义应用「全员可见」设置有时不生效、以及再次编辑时可见范围被重置为「仅管理员」的问题。
|
||||||
|
- 修复已删除 / 已归档的项目仍会继续生成周期任务的问题;对这些项目下遗留的任务,相关成员现在也能正常打开详情并删除。
|
||||||
|
|
||||||
|
## [1.8.64]
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- 大文件上传全面升级:上传单个文件不再受 1G 限制,文件柜、聊天、任务附件、头像与系统图片、编辑器粘贴等场景统一支持;相同文件可「秒传」无需重复上传,上传中断后也能从断点继续,更快更稳。
|
||||||
|
- 新增应用菜单角标:插件与微应用可在菜单入口显示数字或红点提醒,应用未打开也能收到,打开后自动清除,手机端底部导航同样支持。
|
||||||
|
- 登录支持多授权选择:当账号名下有多个可用授权时,登录时可弹窗选择本次要使用的授权。
|
||||||
|
- 授权页面重新排版:正常状态只展示核心信息,出现到期提醒、已过期或设备不匹配时会浮出醒目提示,授权详情可展开查看,状态更清晰。
|
||||||
|
- 新增一键安装 / 升级脚本:一行命令即可完成部署或升级,命令行提示同时支持中文与英文。
|
||||||
|
- AI 助手选择模型时,将官方「Doo AI」分组排到下拉框最前,选择更方便。
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- 修复部分环境下登录验证码图片无法显示的问题。
|
||||||
|
- 修复只读成员仍能看到并点击文件「删除」入口的问题,删除权限现与实际权限保持一致。
|
||||||
|
- 修复手机端 AI 助手在弹窗模式下显示异常的问题,恢复为居中弹框。
|
||||||
|
- 修复安装后网页服务偶尔未自动启动、卸载后残留数据、以管理员身份更新被拦截等部署问题。
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- 优化应用菜单角标的计算方式,减少重复读取,加载更快。
|
||||||
|
|
||||||
|
## [1.8.45]
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- AI 助手全面升级:现在能直接带你跳转页面、协助完成操作;回复可一键复制、查看时间、点赞或点踩反馈;较长的提问支持点击展开查看全部内容;对话浮窗支持手势返回 / ESC 快捷关闭,并完善了手机端适配与流式回复体验。
|
||||||
|
- AI 助手接入产品知识库,回答更贴合 DooTask 的实际功能与使用方法。
|
||||||
|
- 新增官方 AI 服务「Doo AI」,并支持为不同模型设置思考深度,按需选择更合适的模型。
|
||||||
|
- 新增「在线授权」:通过邮箱验证码即可自助开通或申请试用,到期自动续期;在线授权与离线授权可一键切换、互不冲突,授权状态一目了然。
|
||||||
|
- 手机端聊天默认显示发送按钮,发送消息更顺手。
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- 修复使用中文、日文等输入法打字时,回车或删除键偶尔会误触发送 / 提交的问题。
|
||||||
|
- 修复深色主题下部分微应用自定义背景色显示异常的问题。
|
||||||
|
- 修复在独立窗口打开的微应用,关闭应用后窗口未一同关闭的问题。
|
||||||
|
- 修复个别微应用打开时报错、无法正常加载的问题。
|
||||||
|
- 修复部分反向代理 / HTTPS 环境下访问地址协议识别错误的问题。
|
||||||
|
- 优化标签输入框,支持多种分隔符录入,输入更顺畅。
|
||||||
|
- 优化邮件发送的稳定性,修复发信超时判断不准确的问题。
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- 全面升级底层运行框架(Laravel 13 + PHP 8.4),整体运行更快、更稳定、更安全。
|
||||||
|
|
||||||
|
### Miscellaneous
|
||||||
|
|
||||||
|
- 内置审批功能已从主程序移除,改由应用中心的审批应用 / 微应用提供;原有审批能力可通过安装对应应用继续使用。
|
||||||
|
|
||||||
|
## [1.7.90]
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- 系统设置新增「创建项目」权限开关,可指定由所有人、部门负责人或特定人员创建项目,未授权时自动隐藏新建入口,管理更清晰。
|
||||||
|
- 会员卡片新增「项目与任务」入口,可直接查看该成员参与的项目、待办与已完成任务,团队协作一目了然。
|
||||||
|
- 审批详情支持删除已结束的审批,由发起人或管理员清理无用记录更方便。
|
||||||
|
- 管理员现在可以设置全员群的群名称,便于统一团队群组的展示。
|
||||||
|
|
||||||
|
## [1.7.81]
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- 团队管理中可标记成员邮箱认证状态,成员信息更易管理。
|
||||||
|
- 系统管理员可在任意群组中设置或取消他人的待办,协作管理更灵活。
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- 修复 AI 助手消息推送中发送者身份不完整的问题。
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- 优化大文件下载方式,下载更稳定、更高效。
|
||||||
|
|
||||||
|
## [1.7.67]
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- 聊天待办现在可以设置提醒时间,到点会引用原消息并提醒相关人员,避免遗漏重要事项。
|
||||||
|
- 团队管理支持管理员创建或批量导入员工账号,并可填写部门、职位等信息,添加成员更方便。
|
||||||
|
- 系统设置新增聊天待办权限控制,可限制其他人员设置或取消聊天待办。
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- 设置内容没有变化时不再重复保存,减少无效操作,让使用更稳定。
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- 补充路由使用限制说明,帮助使用者更清楚地了解规则。
|
||||||
|
- 统一回复语言偏好说明,确保整段回复使用简体中文。
|
||||||
|
|
||||||
## [1.7.55]
|
## [1.7.55]
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
35
CLAUDE.md
35
CLAUDE.md
@ -1,6 +1,6 @@
|
|||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
|
Laravel 13 (LaravelS/Swoole, PHP 8.4) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
@ -17,18 +17,39 @@ Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管
|
|||||||
|
|
||||||
前端代码改动只做 Edit/Write,不要为了"验证"启动 dev server。用户明确说"跑一下 / 出包"时除外。
|
前端代码改动只做 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
|
## Gotchas
|
||||||
|
|
||||||
### LaravelS/Swoole
|
### LaravelS/Swoole
|
||||||
|
|
||||||
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
|
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
|
||||||
|
- 要存请求级状态,用 `RequestContext::save('key', $value)` / `RequestContext::get('key')`(参考 `User::authInfo()` 的用法,见 `app/Services/RequestContext.php`)
|
||||||
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
|
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
|
||||||
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
|
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
|
||||||
- 长生命周期逻辑(WebSocket、定时器)应复用现有模式,避免阻塞协程/事件循环
|
- 长生命周期逻辑(WebSocket、定时器)应复用现有模式,避免阻塞协程/事件循环
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
|
|
||||||
- **非 REST 路由**:所有 API 通过 `Route::any('api/{resource}/{method}')` 路由到 `InvokeController`,URL 段映射为控制器方法(如 `api/project/lists` → `lists()`,带 action 则用双下划线:`api/project/invite/join` → `invite__join()`)
|
- **非 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()`
|
- **响应格式**:统一使用 `Base::retSuccess($msg, $data)` / `Base::retError($msg)`,返回 `{"ret": 1, "msg": "...", "data": {...}}`——不要用 `response()->json()`
|
||||||
- 业务异常通过 `App\Exceptions\ApiException` 抛出,不要用通用 Exception
|
- 业务异常通过 `App\Exceptions\ApiException` 抛出,不要用通用 Exception
|
||||||
- 模型继承 `AbstractModel`,使用 `Model::createInstance($params)` 创建——不要用 `new Model()` 或 `Model::create()`
|
- 模型继承 `AbstractModel`,使用 `Model::createInstance($params)` 创建——不要用 `new Model()` 或 `Model::create()`
|
||||||
@ -48,6 +69,14 @@ Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管
|
|||||||
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
|
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
|
||||||
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
|
- 前端翻译用 `$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 测试
|
||||||
|
|
||||||
- Playwright 测试结果放在 `tests/playwright-results/`,包含测试环境、测试用例、结果截图等信息
|
- Playwright 测试结果放在 `tests/playwright-results/`,包含测试环境、测试用例、结果截图等信息
|
||||||
@ -58,4 +87,4 @@ Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管
|
|||||||
|
|
||||||
## 语言偏好
|
## 语言偏好
|
||||||
|
|
||||||
- 技术总结和关键结论优先使用简体中文,除非用户明确要求其他语言
|
- 回复一律使用简体中文,除非用户明确要求其他语言
|
||||||
|
|||||||
32
README.md
32
README.md
@ -9,23 +9,26 @@ English | **[中文文档](./README_CN.md)**
|
|||||||
|
|
||||||
- Group Number: `546574618`
|
- Group Number: `546574618`
|
||||||
|
|
||||||
## 📍 Migration from 0.x to 1.x
|
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Installation Requirements
|
## Installation Requirements
|
||||||
|
|
||||||
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
|
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
|
||||||
- Supported Systems: `CentOS/Debian/Ubuntu/macOS` and other Linux/Unix systems
|
- Supported Systems: `CentOS/Debian/Ubuntu/macOS` and other Linux/Unix systems
|
||||||
- Hardware Recommendation: 2+ cores, 4GB+ memory
|
- 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.
|
- Special Note: Windows users can install Linux environment using WSL2 before installing DooTask.
|
||||||
|
|
||||||
### Deploy Project
|
### Deploy Project
|
||||||
|
|
||||||
|
**Option 1: One-line script (recommended)**
|
||||||
|
|
||||||
|
Run it in an empty directory to clone and install automatically; run it inside an existing installation to check and upgrade:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Manual deployment**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1、Clone the project to your local machine or server
|
# 1、Clone the project to your local machine or server
|
||||||
|
|
||||||
@ -104,24 +107,33 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|||||||
|
|
||||||
**Note: Please backup your data before upgrading!**
|
**Note: Please backup your data before upgrading!**
|
||||||
|
|
||||||
|
Recommended: use the one-line script (run it inside an existing installation; it pulls the latest code and finishes the upgrade in a single run):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the local command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./cmd update
|
./cmd update
|
||||||
```
|
```
|
||||||
|
|
||||||
* Please retry if upgrade fails across major versions.
|
|
||||||
* If you encounter 502 errors after upgrade, run `./cmd reup` to restart services.
|
* If you encounter 502 errors after upgrade, run `./cmd reup` to restart services.
|
||||||
|
|
||||||
## Project Migration
|
## Project Migration
|
||||||
|
|
||||||
After installing the new project, follow these steps to complete migration:
|
After installing the new project, follow these steps to complete migration:
|
||||||
|
|
||||||
1、Backup original database
|
1、Backup the MariaDB database
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run command in the old project
|
# Run command in the old project
|
||||||
./cmd mysql backup
|
./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
|
2、Copy the following files and directories from old project to the same paths in new project
|
||||||
|
|
||||||
- `Database backup file`
|
- `Database backup file`
|
||||||
|
|||||||
32
README_CN.md
32
README_CN.md
@ -9,23 +9,26 @@
|
|||||||
|
|
||||||
- QQ群号: `546574618`
|
- QQ群号: `546574618`
|
||||||
|
|
||||||
## 📍 0.x 迁移到 1.x
|
|
||||||
|
|
||||||
- 升级时请务必备份好数据!
|
|
||||||
- 如果升级失败请尝试执行 `./cmd update` 重试几次。
|
|
||||||
- 如果升级中出现 `没有找到 xxx 容器` 的提示,请运行 `./cmd reup` 后再执行 `./cmd update`。
|
|
||||||
- 如果升级后出现502错误请运行 `./cmd reup` 重启服务即可。
|
|
||||||
- 如果升级后出现 `应用「xxx」未安装` 的提示,请使用管理员账号进入应用商店安装相关应用。
|
|
||||||
|
|
||||||
## 安装程序
|
## 安装程序
|
||||||
|
|
||||||
- 必须安装:`Docker v20.10+` 和 `Docker Compose v2.0+`
|
- 必须安装:`Docker v20.10+` 和 `Docker Compose v2.0+`
|
||||||
- 支持环境:`Centos/Debian/Ubuntu/macOS` 等 linux/unix 系统
|
- 支持环境:`Centos/Debian/Ubuntu/macOS` 等 linux/unix 系统
|
||||||
- 硬件建议:2核4G以上
|
- 硬件建议:2核4G以上
|
||||||
|
- 数据库:MariaDB(默认 Docker Compose 中的 `mariadb` 服务)
|
||||||
- 特别说明:Windows 可以使用 WSL2 安装 Linux 环境后再安装 DooTask。
|
- 特别说明:Windows 可以使用 WSL2 安装 Linux 环境后再安装 DooTask。
|
||||||
|
|
||||||
### 部署项目
|
### 部署项目
|
||||||
|
|
||||||
|
**方式一:一键脚本(推荐)**
|
||||||
|
|
||||||
|
在空目录中执行即自动克隆并安装;在已安装目录中执行则自动检查并升级:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式二:手动部署**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1、克隆项目到您的本地或服务器
|
# 1、克隆项目到您的本地或服务器
|
||||||
|
|
||||||
@ -104,24 +107,33 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|||||||
|
|
||||||
**注意:在升级之前请备份好你的数据!**
|
**注意:在升级之前请备份好你的数据!**
|
||||||
|
|
||||||
|
推荐使用一键脚本升级(在已安装目录中执行,自动拉取最新代码并完成升级,无需重复执行):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用本地命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./cmd update
|
./cmd update
|
||||||
```
|
```
|
||||||
|
|
||||||
* 跨越大版本升级失败时请重试执行一次。
|
|
||||||
* 如果升级后出现502请运行 `./cmd reup` 重启服务即可。
|
* 如果升级后出现502请运行 `./cmd reup` 重启服务即可。
|
||||||
|
|
||||||
## 迁移项目
|
## 迁移项目
|
||||||
|
|
||||||
在新项目安装好之后按照以下步骤完成项目迁移:
|
在新项目安装好之后按照以下步骤完成项目迁移:
|
||||||
|
|
||||||
1、备份原数据库
|
1、备份 MariaDB 数据库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在旧的项目下执行指令
|
# 在旧的项目下执行指令
|
||||||
./cmd mysql backup
|
./cmd mysql backup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `./cmd mysql` 为 CLI 子命令名称,实际操作的是 MariaDB 容器。
|
||||||
|
|
||||||
2、将旧项目以下文件和目录拷贝至新项目同路径位置
|
2、将旧项目以下文件和目录拷贝至新项目同路径位置
|
||||||
|
|
||||||
- `数据库备份文件`
|
- `数据库备份文件`
|
||||||
|
|||||||
@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
## 发布版本
|
## 发布版本
|
||||||
|
|
||||||
|
> 翻译、版本号、更新日志改由 `dootask-release` 技能完成(见 `.claude/skills/dootask-release/`)。
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
npm run translate # 翻译(可选)
|
|
||||||
npm run version # 生成版本
|
|
||||||
npm run build # 编译前端
|
npm run build # 编译前端
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
15726
_ide_helper.php
15726
_ide_helper.php
File diff suppressed because it is too large
Load Diff
143
app/Console/Commands/DocApiMap.php
Normal file
143
app/Console/Commands/DocApiMap.php
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Console/Commands/OnlineLicenseRenew.php
Normal file
29
app/Console/Commands/OnlineLicenseRenew.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -52,9 +52,18 @@ trait ManticoreSyncLock
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 信号处理器(SIGINT/SIGTERM)
|
* 信号处理器(SIGINT/SIGTERM),签名须兼容 Symfony Console 的 Command::handleSignal
|
||||||
*/
|
*/
|
||||||
public function handleSignal(int $signal): void
|
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->info("\n收到信号,将在当前批次完成后退出...");
|
||||||
$this->shouldStop = true;
|
$this->shouldStop = true;
|
||||||
@ -67,8 +76,8 @@ trait ManticoreSyncLock
|
|||||||
{
|
{
|
||||||
if (extension_loaded('pcntl')) {
|
if (extension_loaded('pcntl')) {
|
||||||
pcntl_async_signals(true);
|
pcntl_async_signals(true);
|
||||||
pcntl_signal(SIGINT, [$this, 'handleSignal']);
|
pcntl_signal(SIGINT, fn () => $this->markShouldStop());
|
||||||
pcntl_signal(SIGTERM, [$this, 'handleSignal']);
|
pcntl_signal(SIGTERM, fn () => $this->markShouldStop());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,94 +4,18 @@ namespace App\Exceptions;
|
|||||||
|
|
||||||
use App\Module\Base;
|
use App\Module\Base;
|
||||||
use App\Module\Image;
|
use App\Module\Image;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
||||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class Handler extends ExceptionHandler
|
/**
|
||||||
|
* 图片路径处理(原 Exceptions\Handler::ImagePathHandler,新结构下由 bootstrap/app.php
|
||||||
|
* 的 withExceptions 在 NotFoundHttpException 时调用)
|
||||||
|
*/
|
||||||
|
class ImagePathHandler
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* A list of the exception types that are not reported.
|
* @param \Illuminate\Http\Request $request
|
||||||
*
|
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse|null 命中返回图片响应,未命中返回 null(继续默认 404)
|
||||||
* @var array
|
|
||||||
*/
|
*/
|
||||||
protected $dontReport = [
|
public static function render($request)
|
||||||
//
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 NotFoundHttpException) {
|
|
||||||
if ($result = $this->ImagePathHandler($request)) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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->isWriteLog()) {
|
|
||||||
Log::error($e->getMessage(), [
|
|
||||||
'code' => $e->getCode(),
|
|
||||||
'data' => $e->getData(),
|
|
||||||
'exception' => ' at ' . $e->getFile() . ':' . $e->getLine()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parent::report($e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 图片路径处理
|
|
||||||
* @param $request
|
|
||||||
* @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\BinaryFileResponse|null
|
|
||||||
*/
|
|
||||||
private function ImagePathHandler($request)
|
|
||||||
{
|
{
|
||||||
$path = $request->path();
|
$path = $request->path();
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
94
app/Http/Controllers/Api/AppsController.php
Normal file
94
app/Http/Controllers/Api/AppsController.php
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Module\Badge;
|
||||||
|
use App\Module\Base;
|
||||||
|
use Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件 / 微应用相关接口。
|
||||||
|
*
|
||||||
|
* 动态路由(routes/web.php):
|
||||||
|
* api/apps/badge/set -> badge__set() 应用密钥鉴权,绝对设置/清除角标
|
||||||
|
* api/apps/badge/clear -> badge__clear() 当前用户 token 鉴权,清除自己的角标
|
||||||
|
* api/apps/badge/list -> badge__list() 当前用户 token 鉴权,拉取自己全部角标(初始同步)
|
||||||
|
*/
|
||||||
|
class AppsController extends AbstractController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @api {post} api/apps/badge/set 设置角标(应用密钥鉴权)
|
||||||
|
*
|
||||||
|
* @apiDescription 由插件服务端使用 APP_SECRET 调用,对 (appid, 菜单, 每个 userid) 绝对设置角标(幂等覆盖)。
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup apps
|
||||||
|
* @apiName badge__set
|
||||||
|
*
|
||||||
|
* @apiParam {String} appid 应用ID
|
||||||
|
* @apiParam {String} secret 应用密钥(APP_SECRET)
|
||||||
|
* @apiParam {Number|Number[]} userid 目标用户ID(单个或数组)
|
||||||
|
* @apiParam {String} [menu_key] 菜单稳定标识;留空表示该应用第一个菜单
|
||||||
|
* @apiParam {Number} [count=0] 角标数字
|
||||||
|
* @apiParam {Boolean} [dot=false] 是否显示红点(count=0 且 dot=false 即清除)
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||||
|
* @apiSuccess {String} msg 返回信息
|
||||||
|
* @apiSuccess {Object} data 返回数据
|
||||||
|
*/
|
||||||
|
public function badge__set()
|
||||||
|
{
|
||||||
|
return Base::retSuccess('success', Badge::set(
|
||||||
|
trim(Request::input('appid', '')),
|
||||||
|
trim(Request::input('secret', '')),
|
||||||
|
Request::input('userid'),
|
||||||
|
trim(Request::input('menu_key', '')),
|
||||||
|
Request::input('count', 0),
|
||||||
|
Request::input('dot', false)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} api/apps/badge/clear 清除角标(当前用户 token 鉴权)
|
||||||
|
*
|
||||||
|
* @apiDescription 供前端在 badge_clear_on_open=true 的菜单打开时调用,清除当前用户在该应用该菜单的角标。
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup apps
|
||||||
|
* @apiName badge__clear
|
||||||
|
*
|
||||||
|
* @apiParam {String} appid 应用ID
|
||||||
|
* @apiParam {String} [menu_key] 菜单稳定标识;留空表示该应用第一个菜单
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||||
|
* @apiSuccess {String} msg 返回信息
|
||||||
|
* @apiSuccess {Object} data 返回数据
|
||||||
|
*/
|
||||||
|
public function badge__clear()
|
||||||
|
{
|
||||||
|
$user = User::auth();
|
||||||
|
return Base::retSuccess('success', Badge::clearForUser(
|
||||||
|
(int)$user->userid,
|
||||||
|
trim(Request::input('appid', '')),
|
||||||
|
trim(Request::input('menu_key', ''))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {get} api/apps/badge/list 拉取自己全部角标
|
||||||
|
*
|
||||||
|
* @apiDescription 供前端初始同步:返回当前用户全部应用(插件 + 自定义微应用)的角标快照。
|
||||||
|
* 数据结构 app_id => menu_key => {count, dot},与前端 store 的 map 结构一致。
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup apps
|
||||||
|
* @apiName badge__list
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||||
|
* @apiSuccess {String} msg 返回信息
|
||||||
|
* @apiSuccess {Object} data 返回数据
|
||||||
|
*/
|
||||||
|
public function badge__list()
|
||||||
|
{
|
||||||
|
$user = User::auth();
|
||||||
|
return Base::retSuccess('success', Badge::userBadges((int)$user->userid));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Models\AiAssistantFeedback;
|
||||||
|
use App\Models\AiAssistantSearchLog;
|
||||||
use App\Models\AiAssistantSession;
|
use App\Models\AiAssistantSession;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WebSocket;
|
||||||
use App\Module\AI;
|
use App\Module\AI;
|
||||||
use App\Module\Apps;
|
use App\Module\Apps;
|
||||||
use App\Module\Base;
|
use App\Module\Base;
|
||||||
|
use App\Tasks\PushTask;
|
||||||
|
use Cache;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Request;
|
use Request;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,6 +38,8 @@ class AssistantController extends AbstractController
|
|||||||
* @apiParam {String} model_type 模型类型
|
* @apiParam {String} model_type 模型类型
|
||||||
* @apiParam {String} model_name 模型名称
|
* @apiParam {String} model_name 模型名称
|
||||||
* @apiParam {JSON} context 上下文数组
|
* @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 {Number} ret 返回状态码(1正确、0错误)
|
||||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||||
@ -46,8 +54,21 @@ class AssistantController extends AbstractController
|
|||||||
$modelType = trim(Request::input('model_type', ''));
|
$modelType = trim(Request::input('model_type', ''));
|
||||||
$modelName = trim(Request::input('model_name', ''));
|
$modelName = trim(Request::input('model_name', ''));
|
||||||
$contextInput = Request::input('context', []);
|
$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);
|
||||||
|
|
||||||
return AI::createStreamKey($modelType, $modelName, $contextInput);
|
// 当前用户 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -157,6 +178,247 @@ class AssistantController extends AbstractController
|
|||||||
return $dotProduct / $denominator;
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取会话列表
|
* 获取会话列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1257,6 +1257,51 @@ class DialogController extends AbstractController
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} api/dialog/msg/sendapprove 发送审批通知卡片
|
||||||
|
*
|
||||||
|
* @apiDescription 需要token身份。以「审批助手」机器人身份向指定用户发送审批模板卡片
|
||||||
|
* (由 approve 插件调用,卡片仅展示、不与旧审批系统有数据关联)。
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup dialog
|
||||||
|
* @apiName msg__sendapprove
|
||||||
|
*
|
||||||
|
* @apiParam {Number} to_userid 接收用户ID
|
||||||
|
* @apiParam {String} type 卡片类型:approve_reviewer / approve_notifier / approve_submitter / approve_comment_notifier
|
||||||
|
* @apiParam {String} [action] 动作:start / pass / refuse / withdraw(按类型取用)
|
||||||
|
* @apiParam {Number} [is_finished] 是否已结束(0/1)
|
||||||
|
* @apiParam {Object} data 卡片数据
|
||||||
|
* @apiParam {String} [title] 消息标题(会话列表预览用)
|
||||||
|
*/
|
||||||
|
public function msg__sendapprove()
|
||||||
|
{
|
||||||
|
$user = User::auth();
|
||||||
|
$toUserid = intval(Request::input('to_userid'));
|
||||||
|
$type = trim(Request::input('type'));
|
||||||
|
$action = trim(Request::input('action'));
|
||||||
|
$isFinished = intval(Request::input('is_finished'));
|
||||||
|
$data = Base::json2array(Request::input('data'));
|
||||||
|
$title = trim(Request::input('title'));
|
||||||
|
//
|
||||||
|
$allow = ['approve_reviewer', 'approve_notifier', 'approve_submitter', 'approve_comment_notifier'];
|
||||||
|
if ($toUserid <= 0 || !in_array($type, $allow)) {
|
||||||
|
return Base::retError('参数错误');
|
||||||
|
}
|
||||||
|
$botUser = User::botGetOrCreate('approval-alert');
|
||||||
|
$dialog = WebSocketDialog::checkUserDialog($botUser, $toUserid);
|
||||||
|
if (empty($dialog)) {
|
||||||
|
return Base::retError('无法创建对话');
|
||||||
|
}
|
||||||
|
$msgData = [
|
||||||
|
'type' => $type,
|
||||||
|
'action' => $action ?: null,
|
||||||
|
'is_finished' => $isFinished,
|
||||||
|
'data' => $data,
|
||||||
|
'title' => $title,
|
||||||
|
];
|
||||||
|
return WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', $msgData, $botUser->userid, false, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api {post} api/dialog/msg/sendrecord 发送语音
|
* @api {post} api/dialog/msg/sendrecord 发送语音
|
||||||
*
|
*
|
||||||
@ -1670,6 +1715,7 @@ class DialogController extends AbstractController
|
|||||||
if (!in_array($botType, [
|
if (!in_array($botType, [
|
||||||
'system-msg',
|
'system-msg',
|
||||||
'task-alert',
|
'task-alert',
|
||||||
|
'todo-alert',
|
||||||
'check-in',
|
'check-in',
|
||||||
'approval-alert',
|
'approval-alert',
|
||||||
'meeting-alert',
|
'meeting-alert',
|
||||||
@ -1715,6 +1761,7 @@ class DialogController extends AbstractController
|
|||||||
* @apiParam {String} text 消息内容
|
* @apiParam {String} text 消息内容
|
||||||
* @apiParam {String} [text_type=md] 消息格式:md 或 html
|
* @apiParam {String} [text_type=md] 消息格式:md 或 html
|
||||||
* @apiParam {String} [silence=no] 是否静默发送:yes/no
|
* @apiParam {String} [silence=no] 是否静默发送:yes/no
|
||||||
|
* @apiParam {String} [nickname] 自定义发送者昵称(最多20字,留空则显示"AI 助手")
|
||||||
*
|
*
|
||||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||||
@ -1729,6 +1776,7 @@ class DialogController extends AbstractController
|
|||||||
$text = trim(Request::input('text'));
|
$text = trim(Request::input('text'));
|
||||||
$text_type = strtolower(trim(Request::input('text_type'))) ?: 'md';
|
$text_type = strtolower(trim(Request::input('text_type'))) ?: 'md';
|
||||||
$silence = in_array(strtolower(trim(Request::input('silence'))), ['yes', 'true', '1']);
|
$silence = in_array(strtolower(trim(Request::input('silence'))), ['yes', 'true', '1']);
|
||||||
|
$nickname = trim(Request::input('nickname'));
|
||||||
$markdown = in_array($text_type, ['md', 'markdown']);
|
$markdown = in_array($text_type, ['md', 'markdown']);
|
||||||
//
|
//
|
||||||
if (empty($dialog_id) && empty($task_id)) {
|
if (empty($dialog_id) && empty($task_id)) {
|
||||||
@ -1740,6 +1788,9 @@ class DialogController extends AbstractController
|
|||||||
if (mb_strlen($text) > 200000) {
|
if (mb_strlen($text) > 200000) {
|
||||||
return Base::retError('消息内容最大不能超过200000字');
|
return Base::retError('消息内容最大不能超过200000字');
|
||||||
}
|
}
|
||||||
|
if (mb_strlen($nickname) > 20) {
|
||||||
|
return Base::retError('发送者昵称最多不能超过20字');
|
||||||
|
}
|
||||||
//
|
//
|
||||||
if ($dialog_id) {
|
if ($dialog_id) {
|
||||||
// Direct dialog mode: verify user is a member
|
// Direct dialog mode: verify user is a member
|
||||||
@ -1786,6 +1837,9 @@ class DialogController extends AbstractController
|
|||||||
if ($markdown) {
|
if ($markdown) {
|
||||||
$msgData['type'] = 'md';
|
$msgData['type'] = 'md';
|
||||||
}
|
}
|
||||||
|
if ($nickname !== '') {
|
||||||
|
$msgData['nickname'] = $nickname;
|
||||||
|
}
|
||||||
//
|
//
|
||||||
$result = WebSocketDialogMsg::sendMsg(
|
$result = WebSocketDialogMsg::sendMsg(
|
||||||
null,
|
null,
|
||||||
@ -2116,6 +2170,9 @@ class DialogController extends AbstractController
|
|||||||
$msg_id = intval(Request::input("msg_id"));
|
$msg_id = intval(Request::input("msg_id"));
|
||||||
$force = intval(Request::input("force"));
|
$force = intval(Request::input("force"));
|
||||||
$language = Base::inputOrHeader('language');
|
$language = Base::inputOrHeader('language');
|
||||||
|
if (empty($language)) {
|
||||||
|
return Base::retError("参数错误");
|
||||||
|
}
|
||||||
$targetLanguage = Doo::getLanguages($language);
|
$targetLanguage = Doo::getLanguages($language);
|
||||||
//
|
//
|
||||||
if (empty($targetLanguage)) {
|
if (empty($targetLanguage)) {
|
||||||
@ -2571,7 +2628,8 @@ class DialogController extends AbstractController
|
|||||||
} else {
|
} else {
|
||||||
$userids = is_array($userids) ? $userids : [];
|
$userids = is_array($userids) ? $userids : [];
|
||||||
}
|
}
|
||||||
return $msg->toggleTodoMsg($user->userid, $userids);
|
$remindAt = Request::exists('remind_at') ? (trim(Request::input('remind_at', '')) ?: null) : false;
|
||||||
|
return $msg->toggleTodoMsg($user->userid, $userids, $remindAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -2604,6 +2662,64 @@ class DialogController extends AbstractController
|
|||||||
return Base::retSuccess('success', $todo ?: []);
|
return Base::retSuccess('success', $todo ?: []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} api/dialog/msg/todoremind 设置/修改/取消待办提醒时间
|
||||||
|
*
|
||||||
|
* @apiDescription 需要token身份
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup dialog
|
||||||
|
* @apiName msg__todoremind
|
||||||
|
*
|
||||||
|
* @apiParam {Number} msg_id 消息ID
|
||||||
|
* @apiParam {Array} userids 目标成员ID组
|
||||||
|
* @apiParam {String} remind_at 提醒时间(空表示取消提醒)
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||||
|
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||||
|
* @apiSuccess {Object} data 返回数据
|
||||||
|
*/
|
||||||
|
public function msg__todoremind()
|
||||||
|
{
|
||||||
|
$user = User::auth();
|
||||||
|
//
|
||||||
|
$msg_id = intval(Request::input("msg_id"));
|
||||||
|
$userids = Request::input('userids');
|
||||||
|
$userids = is_array($userids) ? array_values(array_filter(array_map('intval', $userids))) : [];
|
||||||
|
$remindAt = trim(Request::input('remind_at', '')) ?: null;
|
||||||
|
//
|
||||||
|
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
|
||||||
|
if (empty($msg)) {
|
||||||
|
return Base::retError("消息不存在或已被删除");
|
||||||
|
}
|
||||||
|
if (in_array($msg->type, ['tag', 'todo', 'notice'])) {
|
||||||
|
return Base::retError('此消息不支持设待办');
|
||||||
|
}
|
||||||
|
$dialog = WebSocketDialog::checkDialog($msg->dialog_id);
|
||||||
|
//
|
||||||
|
if (empty($userids)) {
|
||||||
|
return Base::retError("请选择成员");
|
||||||
|
}
|
||||||
|
// 权限管控(与设/取消待办同一开关与放行规则)
|
||||||
|
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
|
||||||
|
$others = array_diff($userids, [$user->userid]);
|
||||||
|
if ($others && !$dialog->checkTodoOwnerPermission($user->userid)) {
|
||||||
|
return Base::retError('仅群主、项目/任务负责人或系统管理员可设置或取消他人待办');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//
|
||||||
|
$msg->setTodoRemind($userids, $remindAt);
|
||||||
|
//
|
||||||
|
$upData = [
|
||||||
|
'id' => $msg->id,
|
||||||
|
'todo' => $msg->todo,
|
||||||
|
'todo_done' => $msg->isTodoDone(true),
|
||||||
|
'dialog_id' => $msg->dialog_id,
|
||||||
|
];
|
||||||
|
$dialog->pushMsg('update', $upData);
|
||||||
|
//
|
||||||
|
return Base::retSuccess($remindAt ? '设置成功' : '取消成功', $upData);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api {get} api/dialog/msg/done 完成待办
|
* @api {get} api/dialog/msg/done 完成待办
|
||||||
*
|
*
|
||||||
@ -2848,7 +2964,9 @@ class DialogController extends AbstractController
|
|||||||
$data['avatar'] = Base::fillUrl($array['avatar'] = $avatar);
|
$data['avatar'] = Base::fillUrl($array['avatar'] = $avatar);
|
||||||
}
|
}
|
||||||
$existName = Request::exists('chat_name') || Request::exists('name');
|
$existName = Request::exists('chat_name') || Request::exists('name');
|
||||||
if ($existName && $dialog->group_type === 'user') {
|
// 个人群组群主可改名;全员群仅系统管理员可改名
|
||||||
|
$canEditName = $dialog->group_type === 'user' || ($dialog->group_type === 'all' && $admin === 1);
|
||||||
|
if ($existName && $canEditName) {
|
||||||
$chatName = trim(Request::input('chat_name') ?: Request::input('name'));
|
$chatName = trim(Request::input('chat_name') ?: Request::input('name'));
|
||||||
if (mb_strlen($chatName) < 2) {
|
if (mb_strlen($chatName) < 2) {
|
||||||
return Base::retError('群名称至少2个字');
|
return Base::retError('群名称至少2个字');
|
||||||
@ -2972,7 +3090,7 @@ class DialogController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
public function group__transfer()
|
public function group__transfer()
|
||||||
{
|
{
|
||||||
if (!Base::is_internal_ip(Base::getIp()) || Request::input("key") !== env('APP_KEY')) {
|
if (!Base::is_internal_ip(Base::getIp()) || Request::input("key") !== config('app.key')) {
|
||||||
$user = User::auth();
|
$user = User::auth();
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
@ -3282,7 +3400,7 @@ class DialogController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
public function okr__push()
|
public function okr__push()
|
||||||
{
|
{
|
||||||
if (!Base::is_internal_ip(Base::getIp()) || Request::input("key") !== env('APP_KEY')) {
|
if (!Base::is_internal_ip(Base::getIp()) || Request::input("key") !== config('app.key')) {
|
||||||
User::auth();
|
User::auth();
|
||||||
}
|
}
|
||||||
$text = trim(Request::input('text'));
|
$text = trim(Request::input('text'));
|
||||||
|
|||||||
@ -41,6 +41,7 @@ class FileController extends AbstractController
|
|||||||
* @apiName lists
|
* @apiName lists
|
||||||
*
|
*
|
||||||
* @apiParam {Number} [pid] 父级ID
|
* @apiParam {Number} [pid] 父级ID
|
||||||
|
* @apiParam {String} [scope] 板块范围(根目录生效):mine=我的文件、shared=共享文件、all=全部(默认)
|
||||||
*
|
*
|
||||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||||
@ -51,8 +52,12 @@ class FileController extends AbstractController
|
|||||||
$user = User::auth();
|
$user = User::auth();
|
||||||
//
|
//
|
||||||
$pid = intval(Request::input('pid'));
|
$pid = intval(Request::input('pid'));
|
||||||
|
$scope = Request::input('scope', 'all');
|
||||||
|
if (!in_array($scope, ['mine', 'shared', 'all'])) {
|
||||||
|
$scope = 'all';
|
||||||
|
}
|
||||||
//
|
//
|
||||||
return Base::retSuccess('success', (new File)->getFileList($user, $pid));
|
return Base::retSuccess('success', (new File)->getFileList($user, $pid, 'all', true, $scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -737,7 +742,10 @@ class FileController extends AbstractController
|
|||||||
File::isNeedInstallApp('office');
|
File::isNeedInstallApp('office');
|
||||||
//
|
//
|
||||||
$config = Request::input('config');
|
$config = Request::input('config');
|
||||||
$token = \Firebase\JWT\JWT::encode($config, env('APP_KEY') ,'HS256');
|
if (!is_array($config)) {
|
||||||
|
return Base::retError('参数错误');
|
||||||
|
}
|
||||||
|
$token = \Firebase\JWT\JWT::encode($config, config('app.key') ,'HS256');
|
||||||
return Base::retSuccess('成功', [
|
return Base::retSuccess('成功', [
|
||||||
'token' => $token
|
'token' => $token
|
||||||
]);
|
]);
|
||||||
|
|||||||
115
app/Http/Controllers/Api/LicenseController.php
Normal file
115
app/Http/Controllers/Api/LicenseController.php
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<?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/login/confirm -> login__confirm()
|
||||||
|
* 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 login__confirm()
|
||||||
|
{
|
||||||
|
User::auth('admin');
|
||||||
|
$email = trim(Request::input('email'));
|
||||||
|
$code = trim(Request::input('code'));
|
||||||
|
$entitlementId = (int)Request::input('entitlement_id');
|
||||||
|
if ($email === '' || $code === '') {
|
||||||
|
return Base::retError('请输入邮箱和验证码');
|
||||||
|
}
|
||||||
|
if ($entitlementId <= 0) {
|
||||||
|
return Base::retError('请选择要使用的授权');
|
||||||
|
}
|
||||||
|
$data = OnlineLicense::loginConfirm($email, $code, $entitlementId);
|
||||||
|
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('已退出在线授权');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,6 @@ namespace App\Http\Controllers\Api;
|
|||||||
use Request;
|
use Request;
|
||||||
use Redirect;
|
use Redirect;
|
||||||
use Response;
|
use Response;
|
||||||
use Madzipper;
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use App\Module\Down;
|
use App\Module\Down;
|
||||||
use App\Module\Doo;
|
use App\Module\Doo;
|
||||||
@ -1490,6 +1489,214 @@ class ProjectController extends AbstractController
|
|||||||
return Base::retSuccess('success', $data);
|
return Base::retSuccess('success', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {get} api/project/user/projects 会员参与的项目列表
|
||||||
|
*
|
||||||
|
* @apiDescription 需要token身份。用于会员卡片查看「该会员参与的项目」。
|
||||||
|
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup project
|
||||||
|
* @apiName user__projects
|
||||||
|
*
|
||||||
|
* @apiParam {Number} userid 目标会员ID
|
||||||
|
* @apiParam {String} [archived] 是否归档(all/yes/no),默认no
|
||||||
|
* @apiParam {Object} [keys] 搜索条件(keys.name 项目名称)
|
||||||
|
* @apiParam {Number} [page] 当前页,默认1
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||||
|
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||||
|
* @apiSuccess {Object} data 返回数据
|
||||||
|
*/
|
||||||
|
public function user__projects()
|
||||||
|
{
|
||||||
|
$viewer = User::auth();
|
||||||
|
$targetId = intval(Request::input('userid'));
|
||||||
|
$context = UserDepartment::userWorksContext($viewer, $targetId);
|
||||||
|
if (!$context['allowed']) {
|
||||||
|
return Base::retError('没有查看权限');
|
||||||
|
}
|
||||||
|
$readonly = !$context['is_self'] && !$context['is_admin'];
|
||||||
|
//
|
||||||
|
$archived = Request::input('archived', 'no');
|
||||||
|
$keys = Request::input('keys');
|
||||||
|
//
|
||||||
|
$builder = Project::select(['projects.*', 'project_users.owner', 'project_users.top_at', 'project_users.sort'])
|
||||||
|
->join('project_users', function ($join) use ($targetId) {
|
||||||
|
$join->on('projects.id', '=', 'project_users.project_id')
|
||||||
|
->where('project_users.userid', '=', $targetId);
|
||||||
|
});
|
||||||
|
// 部门负责人视角:限定在允许可见的项目集合内
|
||||||
|
if ($readonly) {
|
||||||
|
$builder->whereIn('projects.id', $context['project_ids'] ?: [0]);
|
||||||
|
}
|
||||||
|
//
|
||||||
|
if ($archived == 'yes') {
|
||||||
|
$builder->whereNotNull('projects.archived_at');
|
||||||
|
} elseif ($archived == 'no') {
|
||||||
|
$builder->whereNull('projects.archived_at');
|
||||||
|
}
|
||||||
|
if (is_array($keys) && !empty($keys['name'])) {
|
||||||
|
$builder->where('projects.name', 'like', "%{$keys['name']}%");
|
||||||
|
}
|
||||||
|
//
|
||||||
|
$list = $builder
|
||||||
|
->orderByDesc('project_users.top_at')
|
||||||
|
->orderBy('project_users.sort')
|
||||||
|
->orderByDesc('projects.id')
|
||||||
|
->paginate(Base::getPaginate(100, 50));
|
||||||
|
$list->transform(function (Project $project) use ($targetId, $readonly) {
|
||||||
|
$array = $project->toArray();
|
||||||
|
$array['department_readonly'] = $readonly;
|
||||||
|
$array = array_merge($array, $project->getTaskStatistics($targetId));
|
||||||
|
return $array;
|
||||||
|
});
|
||||||
|
//
|
||||||
|
return Base::retSuccess('success', $list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {get} api/project/user/tasks 会员参与的任务列表
|
||||||
|
*
|
||||||
|
* @apiDescription 需要token身份。用于会员卡片查看「该会员参与的任务」(负责的 / 协作的)。
|
||||||
|
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup project
|
||||||
|
* @apiName user__tasks
|
||||||
|
*
|
||||||
|
* @apiParam {Number} userid 目标会员ID
|
||||||
|
* @apiParam {Number} [owner] 任务身份筛选:1=负责的,0=协作的,不传=全部
|
||||||
|
* @apiParam {Number} [project_id] 仅查询指定项目
|
||||||
|
* @apiParam {Object} [keys] 搜索条件(keys.name 任务名称,keys.status completed/uncompleted)
|
||||||
|
* @apiParam {Number} [page] 当前页,默认1
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||||
|
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||||
|
* @apiSuccess {Object} data 返回数据
|
||||||
|
*/
|
||||||
|
public function user__tasks()
|
||||||
|
{
|
||||||
|
$viewer = User::auth();
|
||||||
|
$targetId = intval(Request::input('userid'));
|
||||||
|
$context = UserDepartment::userWorksContext($viewer, $targetId);
|
||||||
|
if (!$context['allowed']) {
|
||||||
|
return Base::retError('没有查看权限');
|
||||||
|
}
|
||||||
|
$readonly = !$context['is_self'] && !$context['is_admin'];
|
||||||
|
//
|
||||||
|
$owner = Request::input('owner');
|
||||||
|
$owner = is_numeric($owner) ? intval($owner) : null;
|
||||||
|
$project_id = intval(Request::input('project_id'));
|
||||||
|
$keys = Request::input('keys');
|
||||||
|
$keys = is_array($keys) ? $keys : [];
|
||||||
|
//
|
||||||
|
$builder = ProjectTask::with(['taskUser', 'taskTag', 'project:id,name'])
|
||||||
|
->select(['project_tasks.*', 'project_task_users.owner'])
|
||||||
|
->join('project_task_users', function ($join) use ($targetId) {
|
||||||
|
$join->on('project_tasks.id', '=', 'project_task_users.task_id')
|
||||||
|
->where('project_task_users.userid', '=', $targetId);
|
||||||
|
});
|
||||||
|
if ($owner !== null) {
|
||||||
|
$builder->where('project_task_users.owner', $owner);
|
||||||
|
}
|
||||||
|
// 部门负责人视角:限定可见项目集合,且仅"全员可见"(visibility=1)的任务(与 findForDepartmentView 一致,避免列出打不开的任务)
|
||||||
|
if ($readonly) {
|
||||||
|
$builder->whereIn('project_tasks.project_id', $context['project_ids'] ?: [0]);
|
||||||
|
$builder->where('project_tasks.visibility', 1);
|
||||||
|
}
|
||||||
|
if ($project_id > 0) {
|
||||||
|
$builder->where('project_tasks.project_id', $project_id);
|
||||||
|
}
|
||||||
|
if (!empty($keys['name'])) {
|
||||||
|
$builder->where(function ($query) use ($keys) {
|
||||||
|
$query->where('project_tasks.name', 'like', "%{$keys['name']}%")
|
||||||
|
->orWhere('project_tasks.desc', 'like', "%{$keys['name']}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!empty($keys['status'])) {
|
||||||
|
if ($keys['status'] == 'completed') {
|
||||||
|
$builder->whereNotNull('project_tasks.complete_at');
|
||||||
|
} elseif ($keys['status'] == 'uncompleted') {
|
||||||
|
$builder->whereNull('project_tasks.complete_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$builder->whereNull('project_tasks.archived_at');
|
||||||
|
//
|
||||||
|
$list = $builder->orderByDesc('project_tasks.id')->paginate(Base::getPaginate(100, 50));
|
||||||
|
$list->transform(function (ProjectTask $task) use ($readonly) {
|
||||||
|
$task->setAppends(['today', 'overdue']);
|
||||||
|
$array = $task->toArray();
|
||||||
|
$array['project_name'] = $array['project']['name'] ?? '';
|
||||||
|
$array['department_readonly'] = $readonly;
|
||||||
|
unset($array['project']);
|
||||||
|
return $array;
|
||||||
|
});
|
||||||
|
//
|
||||||
|
return Base::retSuccess('success', $list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {get} api/project/user/counts 会员参与的项目/任务数量
|
||||||
|
*
|
||||||
|
* @apiDescription 需要token身份。用于会员卡片「项目与任务」弹窗的 Tab 角标,仅返回数量(轻量)。
|
||||||
|
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup project
|
||||||
|
* @apiName user__counts
|
||||||
|
*
|
||||||
|
* @apiParam {Number} userid 目标会员ID
|
||||||
|
* @apiParam {Number} [owner] 任务身份筛选:1=负责的,0=协作的,不传=全部(仅影响任务数量)
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||||
|
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||||
|
* @apiSuccess {Object} data {project, todo, done}
|
||||||
|
*/
|
||||||
|
public function user__counts()
|
||||||
|
{
|
||||||
|
$viewer = User::auth();
|
||||||
|
$targetId = intval(Request::input('userid'));
|
||||||
|
$context = UserDepartment::userWorksContext($viewer, $targetId);
|
||||||
|
if (!$context['allowed']) {
|
||||||
|
return Base::retError('没有查看权限');
|
||||||
|
}
|
||||||
|
$readonly = !$context['is_self'] && !$context['is_admin'];
|
||||||
|
$owner = Request::input('owner');
|
||||||
|
$owner = is_numeric($owner) ? intval($owner) : null;
|
||||||
|
//
|
||||||
|
$projectBuilder = Project::join('project_users', function ($join) use ($targetId) {
|
||||||
|
$join->on('projects.id', '=', 'project_users.project_id')
|
||||||
|
->where('project_users.userid', '=', $targetId);
|
||||||
|
})
|
||||||
|
->whereNull('projects.archived_at');
|
||||||
|
if ($readonly) {
|
||||||
|
$projectBuilder->whereIn('projects.id', $context['project_ids'] ?: [0]);
|
||||||
|
}
|
||||||
|
$projectCount = $projectBuilder->distinct()->count('projects.id');
|
||||||
|
//
|
||||||
|
$taskBuilder = function () use ($targetId, $owner, $readonly, $context) {
|
||||||
|
$builder = ProjectTask::join('project_task_users', function ($join) use ($targetId) {
|
||||||
|
$join->on('project_tasks.id', '=', 'project_task_users.task_id')
|
||||||
|
->where('project_task_users.userid', '=', $targetId);
|
||||||
|
})
|
||||||
|
->whereNull('project_tasks.archived_at');
|
||||||
|
if ($owner !== null) {
|
||||||
|
$builder->where('project_task_users.owner', $owner);
|
||||||
|
}
|
||||||
|
if ($readonly) {
|
||||||
|
$builder->whereIn('project_tasks.project_id', $context['project_ids'] ?: [0]);
|
||||||
|
$builder->where('project_tasks.visibility', 1);
|
||||||
|
}
|
||||||
|
return $builder;
|
||||||
|
};
|
||||||
|
$todoCount = $taskBuilder()->whereNull('project_tasks.complete_at')->count();
|
||||||
|
$doneCount = $taskBuilder()->whereNotNull('project_tasks.complete_at')->count();
|
||||||
|
//
|
||||||
|
return Base::retSuccess('success', [
|
||||||
|
'project' => $projectCount,
|
||||||
|
'todo' => $todoCount,
|
||||||
|
'done' => $doneCount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api {get} api/project/task/easylists 任务列表-简单的
|
* @api {get} api/project/task/easylists 任务列表-简单的
|
||||||
*
|
*
|
||||||
@ -1795,7 +2002,7 @@ class ProjectController extends AbstractController
|
|||||||
Base::deleteDirAndFile($zipPath, true);
|
Base::deleteDirAndFile($zipPath, true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Madzipper::make($zipPath)->add($xlsPath)->close();
|
Base::zipAddFiles($zipPath, $xlsPath);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
@ -1963,7 +2170,7 @@ class ProjectController extends AbstractController
|
|||||||
Base::deleteDirAndFile($zipPath, true);
|
Base::deleteDirAndFile($zipPath, true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Madzipper::make($zipPath)->add($xlsPath)->close();
|
Base::zipAddFiles($zipPath, $xlsPath);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
@ -2064,9 +2271,12 @@ class ProjectController extends AbstractController
|
|||||||
}
|
}
|
||||||
//
|
//
|
||||||
$data = $task->toArray();
|
$data = $task->toArray();
|
||||||
$data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
|
$taskProject = Project::withTrashed()->find($task->project_id);
|
||||||
$data['project_name'] = $task->project?->name;
|
$data['project_name'] = $taskProject?->name;
|
||||||
|
$data['project_deleted'] = (!$taskProject || $taskProject->trashed()) ? 1 : 0;
|
||||||
|
$data['project_archived'] = $taskProject?->archived_at ? 1 : 0;
|
||||||
$data['column_name'] = $task->projectColumn?->name;
|
$data['column_name'] = $task->projectColumn?->name;
|
||||||
|
$data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
|
||||||
$data['visibility_appointor'] = $task->visibility == 1 ? [0] : ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid');
|
$data['visibility_appointor'] = $task->visibility == 1 ? [0] : ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid');
|
||||||
return Base::retSuccess('success', $data);
|
return Base::retSuccess('success', $data);
|
||||||
}
|
}
|
||||||
@ -2998,8 +3208,27 @@ class ProjectController extends AbstractController
|
|||||||
//
|
//
|
||||||
$task = ProjectTask::userTask($task_id, null, $type !== 'recovery');
|
$task = ProjectTask::userTask($task_id, null, $type !== 'recovery');
|
||||||
//
|
//
|
||||||
|
try {
|
||||||
$project = Project::userProject($task->project_id);
|
$project = Project::userProject($task->project_id);
|
||||||
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_REMOVE, $task);
|
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_REMOVE, $task);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($type == 'recovery') {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
// 项目已删除/已归档时放行删除操作(限:管理员、原项目负责人、任务负责人/协助人/创建人)
|
||||||
|
$project = Project::withTrashed()->find($task->project_id);
|
||||||
|
$projectInvalid = !$project || $project->trashed() || $project->archived_at;
|
||||||
|
if (!$projectInvalid) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$isProjectOwner = ProjectUser::whereProjectId($task->project_id)
|
||||||
|
->whereUserid(Doo::userId())
|
||||||
|
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||||
|
->exists();
|
||||||
|
if (!$isProjectOwner && !$task->permission(3)) {
|
||||||
|
throw new ApiException('仅项目负责人或任务相关成员删除');
|
||||||
|
}
|
||||||
|
}
|
||||||
//
|
//
|
||||||
if ($type == 'recovery') {
|
if ($type == 'recovery') {
|
||||||
$task->restoreTask();
|
$task->restoreTask();
|
||||||
|
|||||||
@ -9,21 +9,22 @@ use App\Module\AI;
|
|||||||
use App\Module\Down;
|
use App\Module\Down;
|
||||||
use Request;
|
use Request;
|
||||||
use Response;
|
use Response;
|
||||||
use Madzipper;
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use App\Module\Doo;
|
use App\Module\Doo;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Module\Base;
|
use App\Module\Base;
|
||||||
|
use App\Module\OnlineLicense;
|
||||||
use App\Module\Timer;
|
use App\Module\Timer;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use LdapRecord\Container;
|
use LdapRecord\Container;
|
||||||
use App\Module\BillExport;
|
use App\Module\BillExport;
|
||||||
use Guanguans\Notify\Factory;
|
use Symfony\Component\Mailer\Mailer;
|
||||||
|
use Symfony\Component\Mailer\Transport;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
use App\Models\UserCheckinRecord;
|
use App\Models\UserCheckinRecord;
|
||||||
use App\Module\Apps;
|
use App\Module\Apps;
|
||||||
use App\Module\BillMultipleExport;
|
use App\Module\BillMultipleExport;
|
||||||
use LdapRecord\LdapRecordException;
|
use LdapRecord\LdapRecordException;
|
||||||
use Guanguans\Notify\Messages\EmailMessage;
|
|
||||||
use Swoole\Coroutine;
|
use Swoole\Coroutine;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,7 +55,7 @@ class SystemController extends AbstractController
|
|||||||
{
|
{
|
||||||
$type = trim(Request::input('type'));
|
$type = trim(Request::input('type'));
|
||||||
if ($type == 'save') {
|
if ($type == 'save') {
|
||||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
if (config('dootask.system_setting') == 'disabled') {
|
||||||
return Base::retError('当前环境禁止修改');
|
return Base::retError('当前环境禁止修改');
|
||||||
}
|
}
|
||||||
Base::checkClientVersion('0.41.11');
|
Base::checkClientVersion('0.41.11');
|
||||||
@ -69,6 +70,8 @@ class SystemController extends AbstractController
|
|||||||
'login_code',
|
'login_code',
|
||||||
'password_policy',
|
'password_policy',
|
||||||
'project_invite',
|
'project_invite',
|
||||||
|
'project_add_permission',
|
||||||
|
'project_add_userids',
|
||||||
'chat_information',
|
'chat_information',
|
||||||
'anon_message',
|
'anon_message',
|
||||||
'convert_video',
|
'convert_video',
|
||||||
@ -95,6 +98,7 @@ class SystemController extends AbstractController
|
|||||||
'unclaimed_task_reminder_time',
|
'unclaimed_task_reminder_time',
|
||||||
'task_ai_auto_analyze',
|
'task_ai_auto_analyze',
|
||||||
'department_owner_project_view',
|
'department_owner_project_view',
|
||||||
|
'todo_set_permission',
|
||||||
])) {
|
])) {
|
||||||
unset($all[$key]);
|
unset($all[$key]);
|
||||||
}
|
}
|
||||||
@ -107,7 +111,7 @@ class SystemController extends AbstractController
|
|||||||
return Base::retError('自动归档时间不可大于100天!');
|
return Base::retError('自动归档时间不可大于100天!');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($all['system_alias'] == env('APP_NAME')) {
|
if ($all['system_alias'] == config('app.name')) {
|
||||||
$all['system_alias'] = '';
|
$all['system_alias'] = '';
|
||||||
}
|
}
|
||||||
if ($all['system_welcome'] == '欢迎您,{username}') {
|
if ($all['system_welcome'] == '欢迎您,{username}') {
|
||||||
@ -142,6 +146,7 @@ class SystemController extends AbstractController
|
|||||||
$setting['archived_day'] = floatval($setting['archived_day']) ?: 7;
|
$setting['archived_day'] = floatval($setting['archived_day']) ?: 7;
|
||||||
$setting['task_visible'] = $setting['task_visible'] ?: 'close';
|
$setting['task_visible'] = $setting['task_visible'] ?: 'close';
|
||||||
$setting['all_group_mute'] = $setting['all_group_mute'] ?: 'open';
|
$setting['all_group_mute'] = $setting['all_group_mute'] ?: 'open';
|
||||||
|
$setting['todo_set_permission'] = $setting['todo_set_permission'] ?: 'open';
|
||||||
$setting['all_group_autoin'] = $setting['all_group_autoin'] ?: 'yes';
|
$setting['all_group_autoin'] = $setting['all_group_autoin'] ?: 'yes';
|
||||||
$setting['user_private_chat_mute'] = $setting['user_private_chat_mute'] ?: 'open';
|
$setting['user_private_chat_mute'] = $setting['user_private_chat_mute'] ?: 'open';
|
||||||
$setting['user_group_chat_mute'] = $setting['user_group_chat_mute'] ?: 'open';
|
$setting['user_group_chat_mute'] = $setting['user_group_chat_mute'] ?: 'open';
|
||||||
@ -152,6 +157,10 @@ class SystemController extends AbstractController
|
|||||||
$setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close';
|
$setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close';
|
||||||
$setting['server_timezone'] = config('app.timezone');
|
$setting['server_timezone'] = config('app.timezone');
|
||||||
$setting['server_version'] = Base::getVersion();
|
$setting['server_version'] = Base::getVersion();
|
||||||
|
// 指定人员名单仅管理员可见
|
||||||
|
if ($type != 'all' && $type != 'save') {
|
||||||
|
unset($setting['project_add_userids']);
|
||||||
|
}
|
||||||
//
|
//
|
||||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||||
}
|
}
|
||||||
@ -176,7 +185,7 @@ class SystemController extends AbstractController
|
|||||||
//
|
//
|
||||||
$type = trim(Request::input('type'));
|
$type = trim(Request::input('type'));
|
||||||
if ($type == 'save') {
|
if ($type == 'save') {
|
||||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
if (config('dootask.system_setting') == 'disabled') {
|
||||||
return Base::retError('当前环境禁止修改');
|
return Base::retError('当前环境禁止修改');
|
||||||
}
|
}
|
||||||
$user->identity('admin');
|
$user->identity('admin');
|
||||||
@ -246,7 +255,7 @@ class SystemController extends AbstractController
|
|||||||
//
|
//
|
||||||
$type = trim(Request::input('type'));
|
$type = trim(Request::input('type'));
|
||||||
if ($type == 'save') {
|
if ($type == 'save') {
|
||||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
if (config('dootask.system_setting') == 'disabled') {
|
||||||
return Base::retError('当前环境禁止修改');
|
return Base::retError('当前环境禁止修改');
|
||||||
}
|
}
|
||||||
$all = Request::input();
|
$all = Request::input();
|
||||||
@ -270,7 +279,7 @@ class SystemController extends AbstractController
|
|||||||
}
|
}
|
||||||
//
|
//
|
||||||
$setting['open'] = $setting['open'] ?: 'close';
|
$setting['open'] = $setting['open'] ?: 'close';
|
||||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
if (config('dootask.system_setting') == 'disabled') {
|
||||||
$setting['appid'] = substr($setting['appid'], 0, 4) . str_repeat('*', strlen($setting['appid']) - 8) . substr($setting['appid'], -4);
|
$setting['appid'] = substr($setting['appid'], 0, 4) . str_repeat('*', strlen($setting['appid']) - 8) . substr($setting['appid'], -4);
|
||||||
$setting['app_certificate'] = substr($setting['app_certificate'], 0, 4) . str_repeat('*', strlen($setting['app_certificate']) - 8) . substr($setting['app_certificate'], -4);
|
$setting['app_certificate'] = substr($setting['app_certificate'], 0, 4) . str_repeat('*', strlen($setting['app_certificate']) - 8) . substr($setting['app_certificate'], -4);
|
||||||
$setting['api_key'] = substr($setting['api_key'], 0, 4) . str_repeat('*', strlen($setting['api_key']) - 8) . substr($setting['api_key'], -4);
|
$setting['api_key'] = substr($setting['api_key'], 0, 4) . str_repeat('*', strlen($setting['api_key']) - 8) . substr($setting['api_key'], -4);
|
||||||
@ -316,7 +325,7 @@ class SystemController extends AbstractController
|
|||||||
$filter = trim(Request::input('filter'));
|
$filter = trim(Request::input('filter'));
|
||||||
$setting = Base::setting('aibotSetting');
|
$setting = Base::setting('aibotSetting');
|
||||||
if ($type == 'save') {
|
if ($type == 'save') {
|
||||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
if (config('dootask.system_setting') == 'disabled') {
|
||||||
return Base::retError('当前环境禁止修改');
|
return Base::retError('当前环境禁止修改');
|
||||||
}
|
}
|
||||||
Base::checkClientVersion('0.41.11');
|
Base::checkClientVersion('0.41.11');
|
||||||
@ -334,11 +343,15 @@ class SystemController extends AbstractController
|
|||||||
}, ARRAY_FILTER_USE_BOTH);
|
}, ARRAY_FILTER_USE_BOTH);
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
if (config('dootask.system_setting') == 'disabled') {
|
||||||
foreach ($setting as $key => $item) {
|
foreach ($setting as $key => $item) {
|
||||||
if (empty($item)) {
|
if (empty($item)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// dooai_key 是官方网关 token,需原样返回供鉴权
|
||||||
|
if ($key === 'dooai_key') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (str_ends_with($key, '_key') || str_ends_with($key, '_secret')) {
|
if (str_ends_with($key, '_key') || str_ends_with($key, '_secret')) {
|
||||||
$setting[$key] = substr($item, 0, 4) . str_repeat('*', strlen($item) - 8) . substr($item, -4);
|
$setting[$key] = substr($item, 0, 4) . str_repeat('*', strlen($item) - 8) . substr($item, -4);
|
||||||
}
|
}
|
||||||
@ -388,7 +401,7 @@ class SystemController extends AbstractController
|
|||||||
//
|
//
|
||||||
$type = trim(Request::input('type'));
|
$type = trim(Request::input('type'));
|
||||||
if ($type == 'save') {
|
if ($type == 'save') {
|
||||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
if (config('dootask.system_setting') == 'disabled') {
|
||||||
return Base::retError('当前环境禁止修改');
|
return Base::retError('当前环境禁止修改');
|
||||||
}
|
}
|
||||||
$all = Request::input();
|
$all = Request::input();
|
||||||
@ -537,7 +550,7 @@ class SystemController extends AbstractController
|
|||||||
//
|
//
|
||||||
$type = trim(Request::input('type'));
|
$type = trim(Request::input('type'));
|
||||||
if ($type == 'save') {
|
if ($type == 'save') {
|
||||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
if (config('dootask.system_setting') == 'disabled') {
|
||||||
return Base::retError('当前环境禁止修改');
|
return Base::retError('当前环境禁止修改');
|
||||||
}
|
}
|
||||||
$all = Request::input();
|
$all = Request::input();
|
||||||
@ -602,7 +615,7 @@ class SystemController extends AbstractController
|
|||||||
return Base::retError($e->getMessage() ?: "验证失败:未知错误", config("ldap.connections.default"));
|
return Base::retError($e->getMessage() ?: "验证失败:未知错误", config("ldap.connections.default"));
|
||||||
}
|
}
|
||||||
} elseif ($type == 'save') {
|
} elseif ($type == 'save') {
|
||||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
if (config('dootask.system_setting') == 'disabled') {
|
||||||
return Base::retError('当前环境禁止修改');
|
return Base::retError('当前环境禁止修改');
|
||||||
}
|
}
|
||||||
$all = Base::newTrim(Request::input());
|
$all = Base::newTrim(Request::input());
|
||||||
@ -654,7 +667,7 @@ class SystemController extends AbstractController
|
|||||||
//
|
//
|
||||||
$type = trim(Request::input('type'));
|
$type = trim(Request::input('type'));
|
||||||
if ($type == 'save') {
|
if ($type == 'save') {
|
||||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
if (config('dootask.system_setting') == 'disabled') {
|
||||||
return Base::retError('当前环境禁止修改');
|
return Base::retError('当前环境禁止修改');
|
||||||
}
|
}
|
||||||
$all = Base::newTrim(Request::input());
|
$all = Base::newTrim(Request::input());
|
||||||
@ -687,8 +700,8 @@ class SystemController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
public function demo()
|
public function demo()
|
||||||
{
|
{
|
||||||
$demo_account = env('DEMO_ACCOUNT');
|
$demo_account = config('dootask.demo_account');
|
||||||
$demo_password = env('DEMO_PASSWORD');
|
$demo_password = config('dootask.demo_password');
|
||||||
if (empty($demo_account) || empty($demo_password)) {
|
if (empty($demo_account) || empty($demo_password)) {
|
||||||
return Base::retError('No demo account');
|
return Base::retError('No demo account');
|
||||||
}
|
}
|
||||||
@ -765,14 +778,14 @@ class SystemController extends AbstractController
|
|||||||
}
|
}
|
||||||
$apps = Setting::normalizeCustomMicroApps($list);
|
$apps = Setting::normalizeCustomMicroApps($list);
|
||||||
$setting = Base::setting('microapp_menu', $apps);
|
$setting = Base::setting('microapp_menu', $apps);
|
||||||
$setting = Setting::formatCustomMicroAppsForResponse($setting);
|
$setting = Setting::formatCustomMicroAppsForResponse($setting, true);
|
||||||
} else {
|
} else {
|
||||||
$setting = Base::setting('microapp_menu');
|
$setting = Base::setting('microapp_menu');
|
||||||
if (!is_array($setting)) {
|
if (!is_array($setting)) {
|
||||||
$setting = [];
|
$setting = [];
|
||||||
}
|
}
|
||||||
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
|
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
|
||||||
$setting = Setting::formatCustomMicroAppsForResponse($setting);
|
$setting = Setting::formatCustomMicroAppsForResponse($setting, $user && $user->isAdmin());
|
||||||
}
|
}
|
||||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
|
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
|
||||||
}
|
}
|
||||||
@ -848,7 +861,17 @@ class SystemController extends AbstractController
|
|||||||
$type = trim(Request::input('type'));
|
$type = trim(Request::input('type'));
|
||||||
if ($type == 'save') {
|
if ($type == 'save') {
|
||||||
$license = Request::input('license');
|
$license = Request::input('license');
|
||||||
|
// 解密失败(sn 为空)视为无效 license
|
||||||
|
$decoded = Doo::licenseDecode($license);
|
||||||
|
if ((string)($decoded['sn'] ?? '') === '') {
|
||||||
|
return Base::retError('LICENSE 格式错误');
|
||||||
|
}
|
||||||
|
if ($err = Doo::licenseBindingError($decoded)) {
|
||||||
|
return Base::retError($err);
|
||||||
|
}
|
||||||
Doo::licenseSave($license);
|
Doo::licenseSave($license);
|
||||||
|
// 离线/在线互斥:保存离线 license 即退出在线模式(尽力释放座位+清在线标志,不删除刚写入的文件)
|
||||||
|
OnlineLicense::switchToOffline();
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
$data = [
|
$data = [
|
||||||
@ -860,8 +883,8 @@ class SystemController extends AbstractController
|
|||||||
'user_count' => User::whereBot(0)->whereNull('disable_at')->count(),
|
'user_count' => User::whereBot(0)->whereNull('disable_at')->count(),
|
||||||
'error' => []
|
'error' => []
|
||||||
];
|
];
|
||||||
if ($data['info']['people'] > 3) {
|
if ($data['info']['people'] == 0 || $data['info']['people'] > 3) {
|
||||||
// 小于3人的License不检查
|
// 付费档才检查 SN/MAC
|
||||||
if ($data['info']['sn'] != $data['doo_sn']) {
|
if ($data['info']['sn'] != $data['doo_sn']) {
|
||||||
$data['error'][] = '终端SN与License不匹配';
|
$data['error'][] = '终端SN与License不匹配';
|
||||||
}
|
}
|
||||||
@ -884,6 +907,11 @@ class SystemController extends AbstractController
|
|||||||
if ($data['info']['expired_at'] && strtotime($data['info']['expired_at']) <= Timer::time()) {
|
if ($data['info']['expired_at'] && strtotime($data['info']['expired_at']) <= Timer::time()) {
|
||||||
$data['error'][] = '终端License已过期';
|
$data['error'][] = '终端License已过期';
|
||||||
}
|
}
|
||||||
|
// 在线授权:把状态机提醒并入 error[](dashboard 警告条与本页错误展示自动复用),并附在线状态
|
||||||
|
foreach (OnlineLicense::stageMessages() as $msg) {
|
||||||
|
$data['error'][] = $msg;
|
||||||
|
}
|
||||||
|
$data['online'] = OnlineLicense::status();
|
||||||
//
|
//
|
||||||
if ($type === 'error') {
|
if ($type === 'error') {
|
||||||
$data = [
|
$data = [
|
||||||
@ -909,7 +937,7 @@ class SystemController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
public function get__info()
|
public function get__info()
|
||||||
{
|
{
|
||||||
if (Request::input("key") !== env('APP_KEY')) {
|
if (Request::input("key") !== config('app.key')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return Base::retSuccess('success', [
|
return Base::retSuccess('success', [
|
||||||
@ -1225,21 +1253,19 @@ class SystemController extends AbstractController
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Setting::validateAddr($all['to'], function($to) use ($all) {
|
Setting::validateAddr($all['to'], function($to) use ($all) {
|
||||||
Factory::mailer()
|
$mailer = new Mailer(Transport::fromDsn("smtp://{$all['account']}:{$all['password']}@{$all['smtp_server']}:{$all['port']}?verify_peer=0"));
|
||||||
->setDsn("smtp://{$all['account']}:{$all['password']}@{$all['smtp_server']}:{$all['port']}?verify_peer=0")
|
$mailer->send((new Email())
|
||||||
->setMessage(EmailMessage::create()
|
|
||||||
->from(Base::settingFind('system', 'system_alias', 'Task') . " <{$all['account']}>")
|
->from(Base::settingFind('system', 'system_alias', 'Task') . " <{$all['account']}>")
|
||||||
->to($to)
|
->to($to)
|
||||||
->subject('Mail sending test')
|
->subject('Mail sending test')
|
||||||
->html('<p>' . Doo::translate('收到此电子邮件意味着您的邮箱配置正确。') . '</p>'))
|
->html('<p>' . Doo::translate('收到此电子邮件意味着您的邮箱配置正确。') . '</p>'));
|
||||||
->send();
|
|
||||||
}, function () {
|
}, function () {
|
||||||
throw new \Exception("收件人地址错误或已被忽略");
|
throw new \Exception("收件人地址错误或已被忽略");
|
||||||
});
|
});
|
||||||
return Base::retSuccess('成功发送');
|
return Base::retSuccess('成功发送');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// 一般是请求超时
|
// 一般是请求超时
|
||||||
if (str_contains($e->getMessage(), "Timed Out")) {
|
if (stripos($e->getMessage(), "timed out") !== false) {
|
||||||
return Base::retError("邮件发送超时,请检查邮箱配置是否正确");
|
return Base::retError("邮件发送超时,请检查邮箱配置是否正确");
|
||||||
} elseif ($e->getCode() === 550) {
|
} elseif ($e->getCode() === 550) {
|
||||||
return Base::retError('邮件内容被拒绝,请检查邮箱是否开启接收功能');
|
return Base::retError('邮件内容被拒绝,请检查邮箱是否开启接收功能');
|
||||||
@ -1437,7 +1463,7 @@ class SystemController extends AbstractController
|
|||||||
Base::deleteDirAndFile($zipPath, true);
|
Base::deleteDirAndFile($zipPath, true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Madzipper::make($zipPath)->add($xlsPath)->close();
|
Base::zipAddFiles($zipPath, $xlsPath);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
|
|||||||
129
app/Http/Controllers/Api/UploadController.php
Normal file
129
app/Http/Controllers/Api/UploadController.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Module\Base;
|
||||||
|
use App\Module\ChunkUpload;
|
||||||
|
use Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分片上传统一入口。
|
||||||
|
*
|
||||||
|
* 动态路由(routes/web.php):
|
||||||
|
* api/upload/init -> init() 启动一个上传会话(含秒传 / 续传命中)
|
||||||
|
* api/upload/chunk -> chunk() 接收一个分片
|
||||||
|
* api/upload/merge -> merge() 合并分片并按 scene 入库
|
||||||
|
*
|
||||||
|
* 小文件(<10MB)不走此接口,前端直接调用各 scene 的老接口(透明降级)。
|
||||||
|
*/
|
||||||
|
class UploadController extends AbstractController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @api {post} api/upload/init 启动上传会话
|
||||||
|
*
|
||||||
|
* @apiDescription 提交文件 hash/size/name/scene/scene_params,返回 upload_id 与已收分片列表;
|
||||||
|
* 若同用户曾上传过同 hash 文件,直接返回 done=true(秒传)。
|
||||||
|
* @apiGroup upload
|
||||||
|
* @apiName init
|
||||||
|
*
|
||||||
|
* @apiParam {String} hash 文件 md5(小写 32 字符)
|
||||||
|
* @apiParam {Number} size 文件大小(字节)
|
||||||
|
* @apiParam {String} name 原始文件名(含扩展名)
|
||||||
|
* @apiParam {String} scene 场景:file_cabinet | dialog_file | image | generic_file
|
||||||
|
* @apiParam {Object} [scene_params] 场景参数(如 file_cabinet 需 pid/cover/webkit_relative_path)
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret
|
||||||
|
* @apiSuccess {Object} data 含 done / upload_id / chunk_size / chunk_count / received 或秒传 file
|
||||||
|
*/
|
||||||
|
public function init()
|
||||||
|
{
|
||||||
|
$user = User::auth();
|
||||||
|
$result = ChunkUpload::start($user, [
|
||||||
|
'hash' => Request::input('hash', ''),
|
||||||
|
'size' => Request::input('size', 0),
|
||||||
|
'name' => Request::input('name', ''),
|
||||||
|
'scene' => Request::input('scene', ''),
|
||||||
|
'scene_params' => Request::input('scene_params', []),
|
||||||
|
]);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} api/upload/chunk 上传一个分片
|
||||||
|
*
|
||||||
|
* @apiDescription multipart 请求,blob 字段为分片数据。
|
||||||
|
* @apiGroup upload
|
||||||
|
* @apiName chunk
|
||||||
|
*
|
||||||
|
* @apiParam {String} upload_id init 返回的 upload_id
|
||||||
|
* @apiParam {Number} index 分片序号(0-based)
|
||||||
|
* @apiParam {File} blob 分片数据
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret
|
||||||
|
* @apiSuccess {Object} data 含 upload_id 与最新 received[]
|
||||||
|
*/
|
||||||
|
public function chunk()
|
||||||
|
{
|
||||||
|
$user = User::auth();
|
||||||
|
$uploadId = trim(Request::input('upload_id', ''));
|
||||||
|
$index = intval(Request::input('index', -1));
|
||||||
|
$blob = Request::file('blob');
|
||||||
|
if ($uploadId === '') {
|
||||||
|
return Base::retError('upload_id 不能为空');
|
||||||
|
}
|
||||||
|
return ChunkUpload::receive($user, $uploadId, $index, $blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} api/upload/merge 合并分片并入库
|
||||||
|
*
|
||||||
|
* @apiDescription 全部分片到齐后调用;后端按 scene 路由到对应入库逻辑,返回与该 scene 老接口对齐的数据。
|
||||||
|
* @apiGroup upload
|
||||||
|
* @apiName merge
|
||||||
|
*
|
||||||
|
* @apiParam {String} upload_id init 返回的 upload_id
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret
|
||||||
|
* @apiSuccess {Object} data scene 入库返回数据
|
||||||
|
*/
|
||||||
|
public function merge()
|
||||||
|
{
|
||||||
|
$user = User::auth();
|
||||||
|
$uploadId = trim(Request::input('upload_id', ''));
|
||||||
|
if ($uploadId === '') {
|
||||||
|
return Base::retError('upload_id 不能为空');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return ChunkUpload::merge($user, $uploadId);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
|
||||||
|
return Base::retError('合并繁忙,请稍后再试');
|
||||||
|
}
|
||||||
|
return Base::retError($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} api/upload/cancel 取消上传会话
|
||||||
|
*
|
||||||
|
* @apiDescription 调用方主动放弃一次分片上传时调用:删除 Redis meta/chunks/hash 索引并清掉分片目录。
|
||||||
|
* 会话已过期或归属其他用户时静默成功,避免给前端取消按钮回写"取消失败"。
|
||||||
|
* @apiGroup upload
|
||||||
|
* @apiName cancel
|
||||||
|
*
|
||||||
|
* @apiParam {String} upload_id init 返回的 upload_id
|
||||||
|
*
|
||||||
|
* @apiSuccess {Number} ret
|
||||||
|
*/
|
||||||
|
public function cancel()
|
||||||
|
{
|
||||||
|
$user = User::auth();
|
||||||
|
$uploadId = trim(Request::input('upload_id', ''));
|
||||||
|
if ($uploadId === '') {
|
||||||
|
return Base::retError('upload_id 不能为空');
|
||||||
|
}
|
||||||
|
ChunkUpload::cancelByUser($user, $uploadId);
|
||||||
|
return Base::retSuccess('已取消');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,6 +41,9 @@ use Illuminate\Support\Facades\DB;
|
|||||||
use App\Models\UserEmailVerification;
|
use App\Models\UserEmailVerification;
|
||||||
use App\Module\AgoraIO\AgoraTokenGenerator;
|
use App\Module\AgoraIO\AgoraTokenGenerator;
|
||||||
use Swoole\Coroutine;
|
use Swoole\Coroutine;
|
||||||
|
use App\Module\UserImport;
|
||||||
|
use App\Module\UserImportTemplate;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @apiDefine users
|
* @apiDefine users
|
||||||
@ -273,6 +276,7 @@ class UsersController extends AbstractController
|
|||||||
public function login__codejson()
|
public function login__codejson()
|
||||||
{
|
{
|
||||||
$captcha = Captcha::create('default', true);
|
$captcha = Captcha::create('default', true);
|
||||||
|
$captcha['img'] = (string)$captcha['img'];
|
||||||
return Base::retSuccess('请求成功', $captcha);
|
return Base::retSuccess('请求成功', $captcha);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,7 +323,7 @@ class UsersController extends AbstractController
|
|||||||
$expiredAtCarbon = $expiredAt ? Carbon::parse($expiredAt) : null;
|
$expiredAtCarbon = $expiredAt ? Carbon::parse($expiredAt) : null;
|
||||||
$data = [
|
$data = [
|
||||||
'expired_at' => $expiredAtCarbon?->toDateTimeString(),
|
'expired_at' => $expiredAtCarbon?->toDateTimeString(),
|
||||||
'remaining_seconds' => $expiredAtCarbon ? Carbon::now()->diffInSeconds($expiredAtCarbon, false) : null,
|
'remaining_seconds' => $expiredAtCarbon ? (int)Carbon::now()->diffInSeconds($expiredAtCarbon, false) : null,
|
||||||
'expired' => $expired,
|
'expired' => $expired,
|
||||||
'server_time' => Carbon::now()->toDateTimeString(),
|
'server_time' => Carbon::now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
@ -881,7 +885,8 @@ class UsersController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
public function extra()
|
public function extra()
|
||||||
{
|
{
|
||||||
$user = User::auth();
|
$viewer = User::auth();
|
||||||
|
$user = $viewer;
|
||||||
//
|
//
|
||||||
$userid = intval(Request::input('userid'));
|
$userid = intval(Request::input('userid'));
|
||||||
if ($userid <= 0) {
|
if ($userid <= 0) {
|
||||||
@ -916,6 +921,8 @@ class UsersController extends AbstractController
|
|||||||
|
|
||||||
$tagMeta = UserTag::listWithMeta($userid, $user);
|
$tagMeta = UserTag::listWithMeta($userid, $user);
|
||||||
|
|
||||||
|
$worksContext = UserDepartment::userWorksContext($viewer, $userid);
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'userid' => $userid,
|
'userid' => $userid,
|
||||||
'birthday' => $birthday,
|
'birthday' => $birthday,
|
||||||
@ -923,6 +930,7 @@ class UsersController extends AbstractController
|
|||||||
'introduction' => $introduction,
|
'introduction' => $introduction,
|
||||||
'personal_tags' => $tagMeta['top'],
|
'personal_tags' => $tagMeta['top'],
|
||||||
'personal_tags_total' => $tagMeta['total'],
|
'personal_tags_total' => $tagMeta['total'],
|
||||||
|
'works_visible' => $worksContext['allowed'],
|
||||||
];
|
];
|
||||||
|
|
||||||
return Base::retSuccess('success', $data);
|
return Base::retSuccess('success', $data);
|
||||||
@ -1091,6 +1099,8 @@ class UsersController extends AbstractController
|
|||||||
* - clearadmin 取消管理员
|
* - clearadmin 取消管理员
|
||||||
* - settemp 设为临时帐号
|
* - settemp 设为临时帐号
|
||||||
* - cleartemp 取消临时身份(取消临时帐号)
|
* - cleartemp 取消临时身份(取消临时帐号)
|
||||||
|
* - setverity 标记邮箱为已认证
|
||||||
|
* - clearverity 标记邮箱为未认证
|
||||||
* - checkin_macs 修改自动签到mac地址(需要参数 checkin_macs)
|
* - checkin_macs 修改自动签到mac地址(需要参数 checkin_macs)
|
||||||
* - checkin_face 修改签到人脸图片(需要参数 checkin_face)
|
* - checkin_face 修改签到人脸图片(需要参数 checkin_face)
|
||||||
* - department 修改部门(需要参数 department)
|
* - department 修改部门(需要参数 department)
|
||||||
@ -1154,6 +1164,16 @@ class UsersController extends AbstractController
|
|||||||
$upArray['identity'] = array_diff($userInfo->identity, ['temp']);
|
$upArray['identity'] = array_diff($userInfo->identity, ['temp']);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'setverity':
|
||||||
|
$msg = '设置成功';
|
||||||
|
$upArray['email_verity'] = 1;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'clearverity':
|
||||||
|
$msg = '取消成功';
|
||||||
|
$upArray['email_verity'] = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
case 'checkin_macs':
|
case 'checkin_macs':
|
||||||
$list = is_array($data['checkin_macs']) ? $data['checkin_macs'] : [];
|
$list = is_array($data['checkin_macs']) ? $data['checkin_macs'] : [];
|
||||||
$array = [];
|
$array = [];
|
||||||
@ -1263,7 +1283,7 @@ class UsersController extends AbstractController
|
|||||||
User::passwordPolicy($password);
|
User::passwordPolicy($password);
|
||||||
$upArray['encrypt'] = Base::generatePassword(6);
|
$upArray['encrypt'] = Base::generatePassword(6);
|
||||||
$upArray['password'] = Doo::md5s($password, $upArray['encrypt']);
|
$upArray['password'] = Doo::md5s($password, $upArray['encrypt']);
|
||||||
$upArray['changepass'] = 1;
|
$upArray['changepass'] = intval($data['changepass'] ?? 1) === 1 ? 1 : 0;
|
||||||
$upLdap['userPassword'] = $password;
|
$upLdap['userPassword'] = $password;
|
||||||
}
|
}
|
||||||
// 昵称
|
// 昵称
|
||||||
@ -1340,6 +1360,101 @@ class UsersController extends AbstractController
|
|||||||
return Base::retSuccess($msg, $userInfo);
|
return Base::retSuccess($msg, $userInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} api/users/createuser 创建用户(管理员)
|
||||||
|
*
|
||||||
|
* @apiDescription 需要token身份(管理员)
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup users
|
||||||
|
* @apiName createuser
|
||||||
|
*
|
||||||
|
* @apiParam {String} email 邮箱
|
||||||
|
* @apiParam {String} password 初始密码
|
||||||
|
* @apiParam {String} nickname 昵称
|
||||||
|
* @apiParam {Number} [email_verity] 是否标记邮箱为已认证(1是、0否,默认1)
|
||||||
|
* @apiParam {String} [profession] 职位/职称(可选,2-20字)
|
||||||
|
* @apiParam {Array} [department] 部门ID列表(可选,最多10个)
|
||||||
|
*/
|
||||||
|
public function createuser()
|
||||||
|
{
|
||||||
|
User::auth('admin');
|
||||||
|
$email = trim(Request::input('email'));
|
||||||
|
$password = trim(Request::input('password'));
|
||||||
|
$nickname = trim(Request::input('nickname'));
|
||||||
|
$changePass = intval(Request::input('changepass', 1)) === 1;
|
||||||
|
$emailVerity = intval(Request::input('email_verity', 1)) === 1;
|
||||||
|
$profession = trim((string)Request::input('profession', ''));
|
||||||
|
$department = Request::input('department', []);
|
||||||
|
$user = User::createByAdmin($email, $password, $nickname, [
|
||||||
|
'changePass' => $changePass,
|
||||||
|
'emailVerity' => $emailVerity,
|
||||||
|
'profession' => $profession,
|
||||||
|
'department' => is_array($department) ? $department : [],
|
||||||
|
]);
|
||||||
|
return Base::retSuccess('创建成功', $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} api/users/import/preview 批量导入预览(管理员)
|
||||||
|
*
|
||||||
|
* @apiDescription 需要token身份(管理员)。上传 Excel/CSV(列顺序:邮箱、昵称、初始密码、职位(选填)),仅解析+校验、不创建账号
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup users
|
||||||
|
* @apiName import__preview
|
||||||
|
*/
|
||||||
|
public function import__preview()
|
||||||
|
{
|
||||||
|
User::auth('admin');
|
||||||
|
$file = Request::file('file');
|
||||||
|
if (empty($file)) {
|
||||||
|
return Base::retError('请选择文件');
|
||||||
|
}
|
||||||
|
$ext = strtolower($file->getClientOriginalExtension());
|
||||||
|
if (!in_array($ext, ['xls', 'xlsx', 'csv'])) {
|
||||||
|
return Base::retError('仅支持 xls/xlsx/csv 文件');
|
||||||
|
}
|
||||||
|
$sheets = Excel::toArray(new UserImport, $file);
|
||||||
|
$sheet = $sheets[0] ?? [];
|
||||||
|
$rows = User::parseImportRows($sheet);
|
||||||
|
if (empty($rows)) {
|
||||||
|
return Base::retError('文件中没有可导入的数据');
|
||||||
|
}
|
||||||
|
return Base::retSuccess('解析完成', User::importPreview($rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} api/users/import 批量导入用户(管理员)
|
||||||
|
*
|
||||||
|
* @apiDescription 需要token身份(管理员)。提交预览确认后的行数据 rows(每行 {email,nickname,password,profession},可选 department[]、email_verity(1已认证/0未认证,默认0))进行创建
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup users
|
||||||
|
* @apiName import
|
||||||
|
*/
|
||||||
|
public function import()
|
||||||
|
{
|
||||||
|
User::auth('admin');
|
||||||
|
$rows = Request::input('rows');
|
||||||
|
if (!is_array($rows) || empty($rows)) {
|
||||||
|
return Base::retError('没有可导入的数据');
|
||||||
|
}
|
||||||
|
$changePass = intval(Request::input('changepass', 1)) === 1;
|
||||||
|
$result = User::importUsers($rows, $changePass);
|
||||||
|
return Base::retSuccess('导入完成', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {get} api/users/import/template 下载批量导入模板(管理员)
|
||||||
|
*
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiGroup users
|
||||||
|
* @apiName import__template
|
||||||
|
*/
|
||||||
|
public function import__template()
|
||||||
|
{
|
||||||
|
User::auth('admin');
|
||||||
|
return Excel::download(new UserImportTemplate, 'user_import_template.xlsx');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api {get} api/users/email/verification 邮箱验证
|
* @api {get} api/users/email/verification 邮箱验证
|
||||||
*
|
*
|
||||||
@ -1521,7 +1636,7 @@ class UsersController extends AbstractController
|
|||||||
} elseif ($type === 'create') {
|
} elseif ($type === 'create') {
|
||||||
$meetingid = strtoupper(Base::generatePassword(11, 1));
|
$meetingid = strtoupper(Base::generatePassword(11, 1));
|
||||||
$name = $name ?: Doo::translate("{$user?->nickname} 发起的会议");
|
$name = $name ?: Doo::translate("{$user?->nickname} 发起的会议");
|
||||||
$channel = "DooTask:" . substr(md5($meetingid . env("APP_KEY")), 16);
|
$channel = "DooTask:" . substr(md5($meetingid . config('app.key')), 16);
|
||||||
$meeting = Meeting::createInstance([
|
$meeting = Meeting::createInstance([
|
||||||
'meetingid' => $meetingid,
|
'meetingid' => $meetingid,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
|
|||||||
@ -23,9 +23,10 @@ use App\Tasks\CheckinRemindTask;
|
|||||||
use App\Tasks\CloseMeetingRoomTask;
|
use App\Tasks\CloseMeetingRoomTask;
|
||||||
use App\Tasks\ManticoreSyncTask;
|
use App\Tasks\ManticoreSyncTask;
|
||||||
use App\Tasks\UnclaimedTaskRemindTask;
|
use App\Tasks\UnclaimedTaskRemindTask;
|
||||||
|
use App\Tasks\TodoRemindTask;
|
||||||
use App\Tasks\AiTaskLoopTask;
|
use App\Tasks\AiTaskLoopTask;
|
||||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||||
use Laravolt\Avatar\Avatar;
|
use App\Module\PatchedAvatar as Avatar;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -220,11 +221,13 @@ class IndexController extends InvokeController
|
|||||||
'radius' => 0,
|
'radius' => 0,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
return response($avatar->create($name)->save($file))
|
$avatar->create($name)->save($file);
|
||||||
->header('Pragma', 'public')
|
return response()->file($file, [
|
||||||
->header('Cache-Control', 'max-age=1814400')
|
'Pragma' => 'public',
|
||||||
->header('Content-type', 'image/png')
|
'Cache-Control' => 'max-age=1814400',
|
||||||
->header('Expires', gmdate('D, d M Y H:i:s \G\M\T', time() + 1814400));
|
'Content-type' => 'image/png',
|
||||||
|
'Expires' => gmdate('D, d M Y H:i:s \G\M\T', time() + 1814400),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -270,6 +273,8 @@ class IndexController extends InvokeController
|
|||||||
Task::deliver(new JokeSoupTask());
|
Task::deliver(new JokeSoupTask());
|
||||||
// 未领取任务通知
|
// 未领取任务通知
|
||||||
Task::deliver(new UnclaimedTaskRemindTask());
|
Task::deliver(new UnclaimedTaskRemindTask());
|
||||||
|
// 待办提醒
|
||||||
|
Task::deliver(new TodoRemindTask());
|
||||||
// 关闭会议室
|
// 关闭会议室
|
||||||
Task::deliver(new CloseMeetingRoomTask());
|
Task::deliver(new CloseMeetingRoomTask());
|
||||||
// Manticore Search 同步
|
// Manticore Search 同步
|
||||||
@ -296,7 +301,7 @@ class IndexController extends InvokeController
|
|||||||
if (preg_match("/^\d+\.\d+\.\d+$/", $publishVersion)) {
|
if (preg_match("/^\d+\.\d+\.\d+$/", $publishVersion)) {
|
||||||
// 判断密钥
|
// 判断密钥
|
||||||
$publishKey = Request::header('publish-key');
|
$publishKey = Request::header('publish-key');
|
||||||
if ($publishKey !== env('APP_KEY')) {
|
if ($publishKey !== config('app.key')) {
|
||||||
return Base::retError("key error");
|
return Base::retError("key error");
|
||||||
}
|
}
|
||||||
// 判断版本
|
// 判断版本
|
||||||
|
|||||||
@ -24,8 +24,8 @@ class InvokeController extends BaseController
|
|||||||
if ($action) {
|
if ($action) {
|
||||||
$app .= "__" . $action;
|
$app .= "__" . $action;
|
||||||
}
|
}
|
||||||
// 接口不存在
|
// 接口不存在(仅 public 方法可作为端点,protected/private 为内部方法,不暴露为路由)
|
||||||
if (!method_exists($this, $app)) {
|
if (!method_exists($this, $app) || !(new \ReflectionMethod($this, $app))->isPublic()) {
|
||||||
$msg = "404 not found (" . str_replace("__", "/", $app) . ").";
|
$msg = "404 not found (" . str_replace("__", "/", $app) . ").";
|
||||||
return Base::ajaxError($msg);
|
return Base::ajaxError($msg);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<?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 = [
|
|
||||||
//
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<?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 = [
|
|
||||||
//
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<?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/',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -25,14 +25,6 @@ class WebApi
|
|||||||
RequestContext::set('start_time', microtime(true));
|
RequestContext::set('start_time', microtime(true));
|
||||||
RequestContext::set('header_language', $request->header('language'));
|
RequestContext::set('header_language', $request->header('language'));
|
||||||
|
|
||||||
// 强制 https
|
|
||||||
$APP_SCHEME = env('APP_SCHEME', 'auto');
|
|
||||||
if (in_array(strtolower($APP_SCHEME), ['https', 'on', 'ssl', '1', 'true', 'yes'], true)) {
|
|
||||||
$request->server->set('HTTPS', 'on');
|
|
||||||
$request->headers->set('X-Forwarded-Proto', 'https');
|
|
||||||
$request->setTrustedProxies([$request->getClientIp()], $request::HEADER_X_FORWARDED_PROTO);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新请求的基本URL
|
// 更新请求的基本URL
|
||||||
RequestContext::updateBaseUrl($request);
|
RequestContext::updateBaseUrl($request);
|
||||||
|
|
||||||
|
|||||||
@ -15,10 +15,8 @@ class LdapUser extends Model
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The object classes of the LDAP model.
|
* The object classes of the LDAP model.
|
||||||
*
|
|
||||||
* @var array
|
|
||||||
*/
|
*/
|
||||||
public static $objectClasses = [
|
public static array $objectClasses = [
|
||||||
'person',
|
'person',
|
||||||
'top',
|
'top',
|
||||||
];
|
];
|
||||||
|
|||||||
@ -5,6 +5,7 @@ namespace App\Models;
|
|||||||
use App\Exceptions\ApiException;
|
use App\Exceptions\ApiException;
|
||||||
use App\Module\Base;
|
use App\Module\Base;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@ -31,7 +32,10 @@ class AbstractModel extends Model
|
|||||||
|
|
||||||
const ID = 'id';
|
const ID = 'id';
|
||||||
|
|
||||||
protected $dates = [
|
/**
|
||||||
|
* 全局日期字段(Laravel 10 移除 $dates 属性后改经 getCasts 合并,子模型 $casts 同名键优先)
|
||||||
|
*/
|
||||||
|
protected $defaultDatetimeCasts = [
|
||||||
'top_at',
|
'top_at',
|
||||||
'last_at',
|
'last_at',
|
||||||
|
|
||||||
@ -51,12 +55,23 @@ class AbstractModel extends Model
|
|||||||
|
|
||||||
'read_at',
|
'read_at',
|
||||||
'done_at',
|
'done_at',
|
||||||
|
'remind_at',
|
||||||
|
'reminded_at',
|
||||||
|
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
'deleted_at',
|
'deleted_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function getCasts(): array
|
||||||
|
{
|
||||||
|
$casts = parent::getCasts();
|
||||||
|
foreach ($this->defaultDatetimeCasts as $field) {
|
||||||
|
$casts[$field] ??= 'datetime';
|
||||||
|
}
|
||||||
|
return $casts;
|
||||||
|
}
|
||||||
|
|
||||||
protected $appendattrs = [];
|
protected $appendattrs = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -187,6 +202,66 @@ class AbstractModel extends Model
|
|||||||
return $instance;
|
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
|
* @param array $param
|
||||||
|
|||||||
48
app/Models/AiAssistantFeedback.php
Normal file
48
app/Models/AiAssistantFeedback.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
50
app/Models/AiAssistantSearchLog.php
Normal file
50
app/Models/AiAssistantSearchLog.php
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
@ -15,6 +15,26 @@ namespace App\Models;
|
|||||||
* @property string|null $images
|
* @property string|null $images
|
||||||
* @property \Carbon\Carbon $created_at
|
* @property \Carbon\Carbon $created_at
|
||||||
* @property \Carbon\Carbon $updated_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
|
class AiAssistantSession extends AbstractModel
|
||||||
{
|
{
|
||||||
|
|||||||
48
app/Models/AppBadge.php
Normal file
48
app/Models/AppBadge.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App\Models\AppBadge
|
||||||
|
*
|
||||||
|
* 插件/微应用菜单角标(每个 (app_id, menu_key, userid) 一行,仅存非清除态)
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $app_id 应用ID
|
||||||
|
* @property string $menu_key 菜单稳定标识(空串=第一个菜单)
|
||||||
|
* @property int $userid 用户ID
|
||||||
|
* @property int $count 角标数字
|
||||||
|
* @property bool $dot 是否显示红点
|
||||||
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge newModelQuery()
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge newQuery()
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge query()
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereAppId($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereCount($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereDot($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereId($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereMenuKey($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereUpdatedAt($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereUserid($value)
|
||||||
|
* @mixin \Eloquent
|
||||||
|
*/
|
||||||
|
class AppBadge extends AbstractModel
|
||||||
|
{
|
||||||
|
protected $table = 'app_badges';
|
||||||
|
|
||||||
|
const CREATED_AT = null;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'app_id',
|
||||||
|
'menu_key',
|
||||||
|
'userid',
|
||||||
|
'count',
|
||||||
|
'dot',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'userid' => 'integer',
|
||||||
|
'count' => 'integer',
|
||||||
|
'dot' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -1,99 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Cache;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use DB;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* App\Models\ApproveProcInstHistory
|
|
||||||
*
|
|
||||||
* @property int $id
|
|
||||||
* @property int $proc_def_id 流程定义ID
|
|
||||||
* @property string|null $proc_def_name 流程定义名
|
|
||||||
* @property string|null $title 标题
|
|
||||||
* @property int|null $department_id 用户部门ID
|
|
||||||
* @property string|null $department 用户部门
|
|
||||||
* @property string|null $company 用户公司
|
|
||||||
* @property string|null $node_id 当前节点
|
|
||||||
* @property string|null $candidate 审批人
|
|
||||||
* @property int|null $task_id 当前任务
|
|
||||||
* @property string|null $start_time 开始时间
|
|
||||||
* @property string|null $end_time 结束时间
|
|
||||||
* @property int|null $duration 持续时间
|
|
||||||
* @property string|null $start_user_id 开始用户ID
|
|
||||||
* @property string|null $start_user_name 开始用户名
|
|
||||||
* @property int|null $is_finished 是否完成
|
|
||||||
* @property string|null $var
|
|
||||||
* @property int $state 当前状态: 0待审批,1审批中,2通过,3拒绝,4撤回
|
|
||||||
* @property string|null $latest_comment
|
|
||||||
* @property string|null $global_comment
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory newModelQuery()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory newQuery()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory query()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereCandidate($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereCompany($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDepartment($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDepartmentId($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDuration($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereEndTime($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereGlobalComment($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereId($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereIsFinished($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereLatestComment($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereNodeId($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereProcDefId($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereProcDefName($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartTime($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartUserId($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartUserName($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereState($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereTaskId($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereTitle($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereVar($value)
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class ApproveProcInstHistory extends AbstractModel
|
|
||||||
{
|
|
||||||
protected $table = 'approve_proc_inst_history';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户审批状态(请假、外出)
|
|
||||||
* @param $userid
|
|
||||||
* @return mixed|null
|
|
||||||
*/
|
|
||||||
public static function getUserApprovalStatus($userid)
|
|
||||||
{
|
|
||||||
if (empty($userid)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Cache::remember('user_is_leave_' . $userid, Carbon::now()->addMinute(), function () use ($userid) {
|
|
||||||
return self::where([
|
|
||||||
['start_user_id', '=', $userid],
|
|
||||||
[DB::raw("JSON_UNQUOTE(JSON_EXTRACT(var, '$.startTime'))"), '<=', Carbon::now()->toDateTimeString()],
|
|
||||||
[DB::raw("JSON_UNQUOTE(JSON_EXTRACT(var, '$.endTime'))"), '>=', Carbon::now()->toDateTimeString()],
|
|
||||||
['state', '=', 2]
|
|
||||||
])->where(function ($query) {
|
|
||||||
$query->where('proc_def_name', 'like', '%请假%')
|
|
||||||
->orWhere('proc_def_name', 'like', '%外出%');
|
|
||||||
})->orderByDesc('id')->value('proc_def_name');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断用户是否请假(包含:请假、外出)
|
|
||||||
* @param $userid
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public static function userIsLeave($userid)
|
|
||||||
{
|
|
||||||
return (bool)self::getUserApprovalStatus($userid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
<?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|AbstractModel cancelAppend()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg newModelQuery()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg newQuery()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg query()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereCreatedAt($value)
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg 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
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -178,28 +178,26 @@ class File extends AbstractModel
|
|||||||
* @param int $pid
|
* @param int $pid
|
||||||
* @param string $type
|
* @param string $type
|
||||||
* @param bool $isGetparent
|
* @param bool $isGetparent
|
||||||
|
* @param string $scope 板块范围(根目录生效):mine=仅我的私有文件;shared=共享文件(别人共享给我的+我共享出去的);all=全部(默认,兼容旧调用)
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public function getFileList($user, int $pid, $type = "all", $isGetparent = true)
|
public function getFileList($user, int $pid, $type = "all", $isGetparent = true, $scope = "all")
|
||||||
{
|
{
|
||||||
$permission = 1000;
|
$permission = 1000;
|
||||||
$userids = $user->isTemp() ? [$user->userid] : [0, $user->userid];
|
$userids = $user->isTemp() ? [$user->userid] : [0, $user->userid];
|
||||||
$builder = File::wherePid($pid)
|
//
|
||||||
|
if ($pid > 0) {
|
||||||
|
// 目录内:按权限返回子级(不区分板块)
|
||||||
|
File::permissionFind($pid, $userids, 0, $permission);
|
||||||
|
$array = File::wherePid($pid)
|
||||||
->when($type == 'dir', function ($q) {
|
->when($type == 'dir', function ($q) {
|
||||||
$q->whereType('folder');
|
$q->whereType('folder');
|
||||||
});
|
})
|
||||||
if ($pid > 0) {
|
->take(500)->get()->toArray();
|
||||||
File::permissionFind($pid, $userids, 0, $permission);
|
|
||||||
} else {
|
|
||||||
$builder->whereUserid($user->userid);
|
|
||||||
}
|
|
||||||
//
|
|
||||||
$array = $builder->take(500)->get()->toArray();
|
|
||||||
foreach ($array as &$item) {
|
foreach ($array as &$item) {
|
||||||
$item['permission'] = $permission;
|
$item['permission'] = $permission;
|
||||||
}
|
}
|
||||||
//
|
unset($item);
|
||||||
if ($pid > 0) {
|
|
||||||
// 遍历获取父级
|
// 遍历获取父级
|
||||||
if ($isGetparent) {
|
if ($isGetparent) {
|
||||||
while ($pid > 0) {
|
while ($pid > 0) {
|
||||||
@ -230,7 +228,28 @@ class File extends AbstractModel
|
|||||||
$array = array_values($array);
|
$array = array_values($array);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 获取共享相关
|
// 根目录:按板块拆分
|
||||||
|
$array = [];
|
||||||
|
// 我的文件(mine 仅私有 share=0;all 含全部我的)
|
||||||
|
if ($scope === 'mine' || $scope === 'all') {
|
||||||
|
$mine = File::wherePid(0)
|
||||||
|
->whereUserid($user->userid)
|
||||||
|
->when($scope === 'mine', function ($q) {
|
||||||
|
$q->where('share', 0);
|
||||||
|
})
|
||||||
|
->when($type == 'dir', function ($q) {
|
||||||
|
$q->whereType('folder');
|
||||||
|
})
|
||||||
|
->take(500)->get()->toArray();
|
||||||
|
foreach ($mine as &$item) {
|
||||||
|
$item['permission'] = $permission;
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
$array = array_merge($array, $mine);
|
||||||
|
}
|
||||||
|
// 共享文件
|
||||||
|
if ($scope === 'shared' || $scope === 'all') {
|
||||||
|
// 别人共享给我的
|
||||||
DB::statement("SET SQL_MODE=''");
|
DB::statement("SET SQL_MODE=''");
|
||||||
$pre = DB::connection()->getTablePrefix();
|
$pre = DB::connection()->getTablePrefix();
|
||||||
$list = File::select(["files.*", DB::raw("MAX({$pre}file_users.permission) as permission")])
|
$list = File::select(["files.*", DB::raw("MAX({$pre}file_users.permission) as permission")])
|
||||||
@ -250,6 +269,22 @@ class File extends AbstractModel
|
|||||||
$array[] = $temp;
|
$array[] = $temp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 我共享出去的(仅 shared 板块补充;all 板块已包含在“我的文件”里)
|
||||||
|
if ($scope === 'shared') {
|
||||||
|
$mineShared = File::wherePid(0)
|
||||||
|
->whereUserid($user->userid)
|
||||||
|
->where('share', 1)
|
||||||
|
->when($type == 'dir', function ($q) {
|
||||||
|
$q->whereType('folder');
|
||||||
|
})
|
||||||
|
->take(500)->get()->toArray();
|
||||||
|
foreach ($mineShared as &$item) {
|
||||||
|
$item['permission'] = $permission;
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
$array = array_merge($array, $mineShared);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 图片直接返回预览地址
|
// 图片直接返回预览地址
|
||||||
foreach ($array as &$item) {
|
foreach ($array as &$item) {
|
||||||
@ -267,20 +302,74 @@ class File extends AbstractModel
|
|||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public function contentUpload($user, int $pid, $webkitRelativePath, $overwrite = false)
|
public function contentUpload($user, int $pid, $webkitRelativePath, $overwrite = false)
|
||||||
|
{
|
||||||
|
[$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath);
|
||||||
|
$data = Base::upload([
|
||||||
|
"file" => Request::file('files'),
|
||||||
|
"type" => 'more',
|
||||||
|
"autoThumb" => false,
|
||||||
|
"path" => 'uploads/tmp/file/' . date("Ym") . '/',
|
||||||
|
"quality" => true,
|
||||||
|
]);
|
||||||
|
if (Base::isError($data)) {
|
||||||
|
throw new ApiException($data['msg']);
|
||||||
|
}
|
||||||
|
return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, null, $overwrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 contentUpload 同一入库链路,但接收已落盘的本地文件而非 Request 上传文件。
|
||||||
|
* 供分片上传 merge 阶段调用。
|
||||||
|
*
|
||||||
|
* @param user $user
|
||||||
|
* @param int $pid
|
||||||
|
* @param string $localPath 合并后的完整文件绝对路径
|
||||||
|
* @param string $originalName 原始文件名(含扩展名)
|
||||||
|
* @param string $webkitRelativePath
|
||||||
|
* @param string|null $hash 文件 md5,用于秒传索引
|
||||||
|
* @param bool $overwrite
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function contentUploadFromPath($user, int $pid, string $localPath, string $originalName, $webkitRelativePath, $hash = null, $overwrite = false)
|
||||||
|
{
|
||||||
|
[$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath);
|
||||||
|
$data = Base::uploadFromPath([
|
||||||
|
"path_local" => $localPath,
|
||||||
|
"name" => $originalName,
|
||||||
|
"type" => 'more',
|
||||||
|
"autoThumb" => false,
|
||||||
|
"path" => 'uploads/tmp/file/' . date("Ym") . '/',
|
||||||
|
"quality" => true,
|
||||||
|
]);
|
||||||
|
if (Base::isError($data)) {
|
||||||
|
throw new ApiException($data['msg']);
|
||||||
|
}
|
||||||
|
return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, $hash, $overwrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传前置:权限/计数校验 + webkitRelativePath 拆出来的中间文件夹创建。
|
||||||
|
* 失败抛 ApiException;成功返回 [最终 pid, 拥有者 userid, 已创建的中间文件夹列表]。
|
||||||
|
*
|
||||||
|
* @param user $user
|
||||||
|
* @param int $pid
|
||||||
|
* @param string $webkitRelativePath
|
||||||
|
* @return array{0:int, 1:int, 2:array}
|
||||||
|
*/
|
||||||
|
public function contentUploadPrep($user, int $pid, $webkitRelativePath): array
|
||||||
{
|
{
|
||||||
$userid = $user->userid;
|
$userid = $user->userid;
|
||||||
if ($pid > 0) {
|
if ($pid > 0) {
|
||||||
if (File::wherePid($pid)->count() >= 300) {
|
if (File::wherePid($pid)->count() >= 300) {
|
||||||
return Base::retError('每个文件夹里最多只能创建300个文件或文件夹');
|
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
|
||||||
}
|
}
|
||||||
$row = File::permissionFind($pid, $user, 1);
|
$row = File::permissionFind($pid, $user, 1);
|
||||||
$userid = $row->userid;
|
$userid = $row->userid;
|
||||||
} else {
|
} else {
|
||||||
if (File::whereUserid($user->userid)->wherePid(0)->count() >= 300) {
|
if (File::whereUserid($user->userid)->wherePid(0)->count() >= 300) {
|
||||||
return Base::retError('每个文件夹里最多只能创建300个文件或文件夹');
|
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//
|
|
||||||
$dirs = explode("/", $webkitRelativePath);
|
$dirs = explode("/", $webkitRelativePath);
|
||||||
$addItem = [];
|
$addItem = [];
|
||||||
while (count($dirs) > 1) {
|
while (count($dirs) > 1) {
|
||||||
@ -297,13 +386,11 @@ class File extends AbstractModel
|
|||||||
'created_id' => $user->userid,
|
'created_id' => $user->userid,
|
||||||
]);
|
]);
|
||||||
$dirRow->handleDuplicateName();
|
$dirRow->handleDuplicateName();
|
||||||
if ($dirRow->saveBeforePP()) {
|
if (!$dirRow->saveBeforePP()) {
|
||||||
$addItem[] = File::find($dirRow->id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (empty($dirRow)) {
|
|
||||||
throw new ApiException('创建文件夹失败');
|
throw new ApiException('创建文件夹失败');
|
||||||
}
|
}
|
||||||
|
$addItem[] = File::find($dirRow->id);
|
||||||
|
}
|
||||||
$pid = $dirRow->id;
|
$pid = $dirRow->id;
|
||||||
});
|
});
|
||||||
foreach ($addItem as $tmpRow) {
|
foreach ($addItem as $tmpRow) {
|
||||||
@ -311,20 +398,24 @@ class File extends AbstractModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//
|
return [$pid, $userid, $addItem];
|
||||||
$path = 'uploads/tmp/file/' . date("Ym") . '/';
|
|
||||||
$data = Base::upload([
|
|
||||||
"file" => Request::file('files'),
|
|
||||||
"type" => 'more',
|
|
||||||
"autoThumb" => false,
|
|
||||||
"path" => $path,
|
|
||||||
"quality" => true
|
|
||||||
]);
|
|
||||||
if (Base::isError($data)) {
|
|
||||||
throw new ApiException($data['msg']);
|
|
||||||
}
|
}
|
||||||
$data = $data['data'];
|
|
||||||
//
|
/**
|
||||||
|
* 上传后置:ext → type 映射 + File 记录创建 + uploadMove + FileContent 入库。
|
||||||
|
*
|
||||||
|
* @param user $user
|
||||||
|
* @param int $userid 目标文件拥有者
|
||||||
|
* @param int $pid
|
||||||
|
* @param array $data Base::upload/uploadFromPath 返回的 data 部分
|
||||||
|
* @param array $addItem prep 阶段累积的中间文件夹
|
||||||
|
* @param string $webkitRelativePath
|
||||||
|
* @param string|null $hash 可选,写入 files.hash(秒传索引)
|
||||||
|
* @param bool $overwrite
|
||||||
|
* @return array{data: array, addItem: array}
|
||||||
|
*/
|
||||||
|
private function contentUploadCommit($user, int $userid, int $pid, array $data, array $addItem, $webkitRelativePath, $hash, bool $overwrite): array
|
||||||
|
{
|
||||||
$type = match ($data['ext']) {
|
$type = match ($data['ext']) {
|
||||||
'text', 'md', 'markdown' => 'document',
|
'text', 'md', 'markdown' => 'document',
|
||||||
'drawio' => 'drawio',
|
'drawio' => 'drawio',
|
||||||
@ -356,7 +447,6 @@ class File extends AbstractModel
|
|||||||
if ($data['ext'] == 'markdown') {
|
if ($data['ext'] == 'markdown') {
|
||||||
$data['ext'] = 'md';
|
$data['ext'] = 'md';
|
||||||
}
|
}
|
||||||
$file = null;
|
|
||||||
$params = [
|
$params = [
|
||||||
'pid' => $pid,
|
'pid' => $pid,
|
||||||
'name' => Base::rightDelete($data['name'], '.' . $data['ext']),
|
'name' => Base::rightDelete($data['name'], '.' . $data['ext']),
|
||||||
@ -365,6 +455,7 @@ class File extends AbstractModel
|
|||||||
'userid' => $userid,
|
'userid' => $userid,
|
||||||
'created_id' => $user->userid,
|
'created_id' => $user->userid,
|
||||||
];
|
];
|
||||||
|
$file = null;
|
||||||
if ($overwrite) {
|
if ($overwrite) {
|
||||||
$file = self::wherePid($params['pid'])->whereExt($params['ext'])->whereName($params['name'])->first();
|
$file = self::wherePid($params['pid'])->whereExt($params['ext'])->whereName($params['name'])->first();
|
||||||
}
|
}
|
||||||
@ -373,11 +464,12 @@ class File extends AbstractModel
|
|||||||
$file = File::createInstance($params);
|
$file = File::createInstance($params);
|
||||||
$file->handleDuplicateName();
|
$file->handleDuplicateName();
|
||||||
}
|
}
|
||||||
// 开始创建
|
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file, $hash) {
|
||||||
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file) {
|
|
||||||
$file->size = $data['size'] * 1024;
|
$file->size = $data['size'] * 1024;
|
||||||
|
if ($hash) {
|
||||||
|
$file->hash = $hash;
|
||||||
|
}
|
||||||
$file->saveBeforePP();
|
$file->saveBeforePP();
|
||||||
//
|
|
||||||
$data = Base::uploadMove($data, "uploads/file/" . $file->type . "/" . date("Ym") . "/" . $file->id . "/");
|
$data = Base::uploadMove($data, "uploads/file/" . $file->type . "/" . date("Ym") . "/" . $file->id . "/");
|
||||||
$content = [
|
$content = [
|
||||||
'from' => '',
|
'from' => '',
|
||||||
@ -389,25 +481,20 @@ class File extends AbstractModel
|
|||||||
$content['width'] = $data['width'];
|
$content['width'] = $data['width'];
|
||||||
$content['height'] = $data['height'];
|
$content['height'] = $data['height'];
|
||||||
}
|
}
|
||||||
$content = FileContent::createInstance([
|
FileContent::createInstance([
|
||||||
'fid' => $file->id,
|
'fid' => $file->id,
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
'text' => '',
|
'text' => '',
|
||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'userid' => $user->userid,
|
'userid' => $user->userid,
|
||||||
]);
|
])->save();
|
||||||
$content->save();
|
|
||||||
//
|
|
||||||
$tmpRow = File::find($file->id);
|
$tmpRow = File::find($file->id);
|
||||||
$tmpRow->pushMsg('add', $tmpRow);
|
$tmpRow->pushMsg('add', $tmpRow);
|
||||||
//
|
$row = File::handleImageUrl($tmpRow->toArray());
|
||||||
$data = File::handleImageUrl($tmpRow->toArray());
|
$row['full_name'] = $webkitRelativePath ?: ($row['name'] . '.' . $row['ext']);
|
||||||
$data['full_name'] = $webkitRelativePath ?: ($data['name'] . '.' . $data['ext']);
|
$row['overwrite'] = $overwrite ? 1 : 0;
|
||||||
$data['overwrite'] = $overwrite ? 1 : 0;
|
$addItem[] = $row;
|
||||||
//
|
return ['data' => $row, 'addItem' => $addItem];
|
||||||
$addItem[] = $data;
|
|
||||||
|
|
||||||
return ['data' => $data, 'addItem' => $addItem];
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,25 @@ namespace App\Models;
|
|||||||
* @property \Carbon\Carbon|null $last_retry_at 最后重试时间
|
* @property \Carbon\Carbon|null $last_retry_at 最后重试时间
|
||||||
* @property \Carbon\Carbon $created_at
|
* @property \Carbon\Carbon $created_at
|
||||||
* @property \Carbon\Carbon $updated_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
|
class ManticoreSyncFailure extends AbstractModel
|
||||||
{
|
{
|
||||||
@ -28,10 +47,8 @@ class ManticoreSyncFailure extends AbstractModel
|
|||||||
'last_retry_at',
|
'last_retry_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $dates = [
|
protected $casts = [
|
||||||
'last_retry_at',
|
'last_retry_at' => 'datetime',
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -68,6 +68,10 @@ use Request;
|
|||||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereUserid($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|Project whereUserid($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|Project withTrashed()
|
* @method static \Illuminate\Database\Eloquent\Builder|Project withTrashed()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|Project withoutTrashed()
|
* @method static \Illuminate\Database\Eloquent\Builder|Project withoutTrashed()
|
||||||
|
* @property-read array $deputy_userids
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|Project whereAiAutoAnalyze($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|Project whereDepartmentOwnerView($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|Project whereTaskTemplateShare($value)
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class Project extends AbstractModel
|
class Project extends AbstractModel
|
||||||
@ -605,6 +609,38 @@ class Project extends AbstractModel
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户是否有权限创建项目(依据系统设置「项目创建权限」)
|
||||||
|
* @param int $userid
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function userCanCreate($userid)
|
||||||
|
{
|
||||||
|
// 范围已在 Setting::getSettingAttribute() 归一化(默认 ['all'])
|
||||||
|
$modes = Base::settingFind('system', 'project_add_permission', ['all']);
|
||||||
|
// 「所有人」:放行(与具体用户无关,避免未携带身份时被误判为无权)
|
||||||
|
if (in_array('all', $modes)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$user = User::find(intval($userid));
|
||||||
|
if (empty($user)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 系统管理员始终可创建项目(不受开关限制)
|
||||||
|
if ($user->isAdmin()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 部门负责人/部门管理员
|
||||||
|
if (in_array('departmentOwner', $modes) && UserDepartment::getManagedDepartments($user->userid)->isNotEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 指定人员
|
||||||
|
if (in_array('appoint', $modes)) {
|
||||||
|
return in_array($user->userid, Base::settingFind('system', 'project_add_userids', []));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建项目
|
* 创建项目
|
||||||
* @param $params
|
* @param $params
|
||||||
@ -621,6 +657,10 @@ class Project extends AbstractModel
|
|||||||
$desc = trim(Arr::get($params, 'desc', ''));
|
$desc = trim(Arr::get($params, 'desc', ''));
|
||||||
$flow = trim(Arr::get($params, 'flow', 'close'));
|
$flow = trim(Arr::get($params, 'flow', 'close'));
|
||||||
$isPersonal = intval(Arr::get($params, 'personal'));
|
$isPersonal = intval(Arr::get($params, 'personal'));
|
||||||
|
// 个人项目为系统自动创建,不受创建权限限制
|
||||||
|
if (!$isPersonal && !self::userCanCreate($userid)) {
|
||||||
|
return Base::retError('当前仅指定人员可以创建项目');
|
||||||
|
}
|
||||||
if (mb_strlen($name) < 2) {
|
if (mb_strlen($name) < 2) {
|
||||||
return Base::retError('项目名称不可以少于2个字');
|
return Base::retError('项目名称不可以少于2个字');
|
||||||
} elseif (mb_strlen($name) > 32) {
|
} elseif (mb_strlen($name) > 32) {
|
||||||
|
|||||||
@ -482,7 +482,7 @@ class ProjectTask extends AbstractModel
|
|||||||
if ($parent_id == 0) {
|
if ($parent_id == 0) {
|
||||||
$priorityList = Setting::normalizeTaskPriorityList(Base::setting('priority'));
|
$priorityList = Setting::normalizeTaskPriorityList(Base::setting('priority'));
|
||||||
if ($p_level > 0) {
|
if ($p_level > 0) {
|
||||||
$matched = reset(array_filter($priorityList, fn($item) => intval($item['priority']) === $p_level)) ?: null;
|
$matched = collect($priorityList)->first(fn($item) => intval($item['priority']) === $p_level);
|
||||||
} else {
|
} else {
|
||||||
$matched = Setting::getDefaultTaskPriorityItem($priorityList);
|
$matched = Setting::getDefaultTaskPriorityItem($priorityList);
|
||||||
}
|
}
|
||||||
@ -937,7 +937,7 @@ class ProjectTask extends AbstractModel
|
|||||||
'cache' => [
|
'cache' => [
|
||||||
'task_at' => $oldStringAt,
|
'task_at' => $oldStringAt,
|
||||||
'change_at' => $newStringAt,
|
'change_at' => $newStringAt,
|
||||||
'over_sec' => $effectiveEndTime->diffInSeconds($oldAt[1]),
|
'over_sec' => (int)$effectiveEndTime->diffInSeconds($oldAt[1], true),
|
||||||
'owners' => $this->taskUser->where('owner', 1)->pluck('userid')->toArray(),
|
'owners' => $this->taskUser->where('owner', 1)->pluck('userid')->toArray(),
|
||||||
'assists' => $this->taskUser->where('owner', 0)->pluck('userid')->toArray(),
|
'assists' => $this->taskUser->where('owner', 0)->pluck('userid')->toArray(),
|
||||||
]
|
]
|
||||||
@ -1633,7 +1633,7 @@ class ProjectTask extends AbstractModel
|
|||||||
$this->addLog("{任务}超期未完成", [
|
$this->addLog("{任务}超期未完成", [
|
||||||
'cache' => [
|
'cache' => [
|
||||||
'task_at' => $this->start_at . '~' . $this->end_at,
|
'task_at' => $this->start_at . '~' . $this->end_at,
|
||||||
'over_sec' => Carbon::now()->diffInSeconds($this->end_at),
|
'over_sec' => (int)Carbon::now()->diffInSeconds($this->end_at, true),
|
||||||
'owners' => $this->taskUser->where('owner', 1)->pluck('userid')->toArray(),
|
'owners' => $this->taskUser->where('owner', 1)->pluck('userid')->toArray(),
|
||||||
'assists' => $this->taskUser->where('owner', 0)->pluck('userid')->toArray(),
|
'assists' => $this->taskUser->where('owner', 0)->pluck('userid')->toArray(),
|
||||||
]
|
]
|
||||||
@ -2246,7 +2246,7 @@ class ProjectTask extends AbstractModel
|
|||||||
$project = Project::userProject($task->project_id);
|
$project = Project::userProject($task->project_id);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
if ($task->owner !== null || $task->permission(4)) {
|
if ($task->owner !== null || $task->permission(4)) {
|
||||||
$project = Project::find($task->project_id);
|
$project = Project::withTrashed()->find($task->project_id);
|
||||||
if (empty($project)) {
|
if (empty($project)) {
|
||||||
throw new ApiException('项目不存在或已被删除', [ 'task_id' => $task_id ], -4002);
|
throw new ApiException('项目不存在或已被删除', [ 'task_id' => $task_id ], -4002);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,28 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
* @property \Illuminate\Support\Carbon|null $executed_at
|
* @property \Illuminate\Support\Carbon|null $executed_at
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
|
* @property-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
|
class ProjectTaskAiEvent extends AbstractModel
|
||||||
{
|
{
|
||||||
|
|||||||
@ -38,6 +38,8 @@ namespace App\Models;
|
|||||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereTitle($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereTitle($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereUpdatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereUpdatedAt($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereUserid($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereUserid($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskTemplate whereLastUsedAt($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskTemplate whereUseCount($value)
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class ProjectTaskTemplate extends AbstractModel
|
class ProjectTaskTemplate extends AbstractModel
|
||||||
|
|||||||
@ -156,7 +156,7 @@ class Report extends AbstractModel
|
|||||||
* @param User|null $user
|
* @param User|null $user
|
||||||
* @return Builder|Model|\Illuminate\Database\Query\Builder|object
|
* @return Builder|Model|\Illuminate\Database\Query\Builder|object
|
||||||
*/
|
*/
|
||||||
public static function getLastOne(User $user = null)
|
public static function getLastOne(?User $user = null)
|
||||||
{
|
{
|
||||||
$user === null && $user = User::auth();
|
$user === null && $user = User::auth();
|
||||||
$one = self::whereUserid($user->userid)->orderByDesc("created_at")->first();
|
$one = self::whereUserid($user->userid)->orderByDesc("created_at")->first();
|
||||||
|
|||||||
@ -51,20 +51,28 @@ class Setting extends AbstractModel
|
|||||||
switch ($this->name) {
|
switch ($this->name) {
|
||||||
// 系统设置
|
// 系统设置
|
||||||
case 'system':
|
case 'system':
|
||||||
$value['system_alias'] = $value['system_alias'] ?: env('APP_NAME');
|
$value['system_alias'] = ($value['system_alias'] ?? null) ?: config('app.name');
|
||||||
$value['image_compress'] = $value['image_compress'] ?: 'open';
|
$value['image_compress'] = ($value['image_compress'] ?? null) ?: 'open';
|
||||||
$value['image_quality'] = min(100, max(0, intval($value['image_quality']) ?: 90));
|
$value['image_quality'] = min(100, max(0, intval($value['image_quality'] ?? 0) ?: 90));
|
||||||
$value['image_save_local'] = $value['image_save_local'] ?: 'open';
|
$value['image_save_local'] = ($value['image_save_local'] ?? null) ?: 'open';
|
||||||
$value['task_user_limit'] = min(2000, max(1, intval($value['task_user_limit']) ?: 500));
|
$value['task_user_limit'] = min(2000, max(1, intval($value['task_user_limit'] ?? 0) ?: 500));
|
||||||
if (!is_array($value['task_default_time']) || count($value['task_default_time']) != 2 || !Timer::isTime($value['task_default_time'][0]) || !Timer::isTime($value['task_default_time'][1])) {
|
if (!is_array($value['task_default_time'] ?? null) || count($value['task_default_time']) != 2 || !Timer::isTime($value['task_default_time'][0]) || !Timer::isTime($value['task_default_time'][1])) {
|
||||||
$value['task_default_time'] = ['09:00', '18:00'];
|
$value['task_default_time'] = ['09:00', '18:00'];
|
||||||
}
|
}
|
||||||
|
// 项目创建权限:范围(all/departmentOwner/appoint,默认 all)+ 指定人员
|
||||||
|
$value['project_add_permission'] = array_values(array_intersect(
|
||||||
|
is_array($value['project_add_permission'] ?? null) ? $value['project_add_permission'] : [],
|
||||||
|
['all', 'departmentOwner', 'appoint']
|
||||||
|
)) ?: ['all'];
|
||||||
|
$value['project_add_userids'] = is_array($value['project_add_userids'] ?? null)
|
||||||
|
? array_values(array_unique(array_filter(array_map('intval', $value['project_add_userids']))))
|
||||||
|
: [];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// 文件设置
|
// 文件设置
|
||||||
case 'fileSetting':
|
case 'fileSetting':
|
||||||
$value['permission_pack_type'] = $value['permission_pack_type'] ?: 'all';
|
$value['permission_pack_type'] = ($value['permission_pack_type'] ?? null) ?: 'all';
|
||||||
$value['permission_pack_userids'] = is_array($value['permission_pack_userids']) ? $value['permission_pack_userids'] : [];
|
$value['permission_pack_userids'] = is_array($value['permission_pack_userids'] ?? null) ? $value['permission_pack_userids'] : [];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// AI 机器人设置
|
// AI 机器人设置
|
||||||
@ -73,7 +81,7 @@ class Setting extends AbstractModel
|
|||||||
$value['claude_key'] = $value['claude_token'];
|
$value['claude_key'] = $value['claude_token'];
|
||||||
}
|
}
|
||||||
$array = [];
|
$array = [];
|
||||||
$aiList = ['openai', 'claude', 'deepseek', 'gemini', 'grok', 'ollama', 'zhipu', 'qianwen', 'wenxin'];
|
$aiList = ['openai', 'claude', 'deepseek', 'gemini', 'grok', 'ollama', 'zhipu', 'qianwen', 'wenxin', 'dooai'];
|
||||||
$fieldList = ['key', 'secret', 'models', 'model', 'base_url', 'agency', 'temperature', 'system'];
|
$fieldList = ['key', 'secret', 'models', 'model', 'base_url', 'agency', 'temperature', 'system'];
|
||||||
foreach ($aiList as $aiName) {
|
foreach ($aiList as $aiName) {
|
||||||
foreach ($fieldList as $fieldName) {
|
foreach ($fieldList as $fieldName) {
|
||||||
@ -81,11 +89,13 @@ class Setting extends AbstractModel
|
|||||||
$content = !empty($value[$key]) ? trim($value[$key]) : '';
|
$content = !empty($value[$key]) ? trim($value[$key]) : '';
|
||||||
switch ($fieldName) {
|
switch ($fieldName) {
|
||||||
case 'models':
|
case 'models':
|
||||||
if ($content) {
|
// 新 JSON 数组格式原样保留;仅旧的换行格式按行清洗
|
||||||
|
if ($content && !str_starts_with($content, '[')) {
|
||||||
$content = explode("\n", $content);
|
$content = explode("\n", $content);
|
||||||
$content = array_filter($content);
|
$content = array_filter($content);
|
||||||
|
$content = implode("\n", $content);
|
||||||
}
|
}
|
||||||
$content = is_array($content) ? implode("\n", $content) : '';
|
$content = is_string($content) ? $content : '';
|
||||||
break;
|
break;
|
||||||
case 'model':
|
case 'model':
|
||||||
$models = Setting::AIBotModels2Array($array[$key . 's'], true);
|
$models = Setting::AIBotModels2Array($array[$key . 's'], true);
|
||||||
@ -206,28 +216,87 @@ class Setting extends AbstractModel
|
|||||||
/**
|
/**
|
||||||
* AI 机器人模型转数组
|
* AI 机器人模型转数组
|
||||||
* @param $models
|
* @param $models
|
||||||
* @param bool $retValue
|
* @param bool $retValue 仅返回模型 value 列表
|
||||||
|
* @param bool $visibleOnly 仅返回可见模型(跳过标记 hidden 的项,供展示给终端用户的场景)
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function AIBotModels2Array($models, $retValue = false)
|
public static function AIBotModels2Array($models, $retValue = false, $visibleOnly = false)
|
||||||
{
|
{
|
||||||
$list = is_array($models) ? $models : explode("\n", $models);
|
$list = null;
|
||||||
|
if (is_array($models)) {
|
||||||
|
$list = $models;
|
||||||
|
} else {
|
||||||
|
$text = trim((string)$models);
|
||||||
|
if ($text !== '' && str_starts_with($text, '[')) {
|
||||||
|
$decoded = json_decode($text, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$list = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($list === null) {
|
||||||
|
$list = explode("\n", (string)$models);
|
||||||
|
}
|
||||||
|
}
|
||||||
$array = [];
|
$array = [];
|
||||||
foreach ($list as $item) {
|
foreach ($list as $item) {
|
||||||
|
if (is_array($item)) {
|
||||||
|
// 新 JSON 记录格式:{id,name,thinking}(兼容 {value,label})
|
||||||
|
$value = trim((string)($item['id'] ?? $item['value'] ?? ''));
|
||||||
|
if ($value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 隐藏模型:仍保存在设置中,但展示给终端用户时跳过($visibleOnly)
|
||||||
|
if ($visibleOnly && !empty($item['hidden'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$label = trim((string)($item['name'] ?? $item['label'] ?? ''));
|
||||||
|
$thinking = strtolower(trim((string)($item['thinking'] ?? 'off')));
|
||||||
|
if (!in_array($thinking, ['off', 'low', 'medium', 'high'], true)) {
|
||||||
|
$thinking = 'off';
|
||||||
|
}
|
||||||
|
$array[] = [
|
||||||
|
'value' => $value,
|
||||||
|
'label' => $label !== '' ? $label : $value,
|
||||||
|
'thinking' => $thinking,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// 兼容旧字符串格式 "id|name"
|
||||||
$arr = Base::newTrim(explode('|', $item . '|'));
|
$arr = Base::newTrim(explode('|', $item . '|'));
|
||||||
if ($arr[0]) {
|
if ($arr[0]) {
|
||||||
$array[] = [
|
$array[] = [
|
||||||
'value' => $arr[0],
|
'value' => $arr[0],
|
||||||
'label' => $arr[1] ?: $arr[0]
|
'label' => $arr[1] ?: $arr[0],
|
||||||
|
'thinking' => 'off',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if ($retValue) {
|
if ($retValue) {
|
||||||
return array_column($array, 'value');
|
return array_column($array, 'value');
|
||||||
}
|
}
|
||||||
return $array;
|
return $array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定模型的思考档位(off|low|medium|high),未配置返回 off
|
||||||
|
* @param string|array $models 模型列表设置(JSON 字符串或旧格式)
|
||||||
|
* @param string $modelName 模型 ID
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function AIBotModelThinking($models, $modelName)
|
||||||
|
{
|
||||||
|
$modelName = trim((string)$modelName);
|
||||||
|
if ($modelName === '') {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
foreach (self::AIBotModels2Array($models) as $item) {
|
||||||
|
if ($item['value'] === $modelName) {
|
||||||
|
return $item['thinking'] ?? 'off';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 规范自定义微应用配置
|
* 规范自定义微应用配置
|
||||||
* @param array $list
|
* @param array $list
|
||||||
@ -289,18 +358,25 @@ class Setting extends AbstractModel
|
|||||||
/**
|
/**
|
||||||
* 将存储结构转换成 appstore 接口同款格式
|
* 将存储结构转换成 appstore 接口同款格式
|
||||||
* @param array $apps
|
* @param array $apps
|
||||||
|
* @param bool $keepVisible 是否保留可见范围(仅管理员编辑场景需要,用于回填)
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function formatCustomMicroAppsForResponse(array $apps)
|
public static function formatCustomMicroAppsForResponse(array $apps, bool $keepVisible = false)
|
||||||
{
|
{
|
||||||
return array_values(array_map(function ($app) {
|
return array_values(array_map(function ($app) use ($keepVisible) {
|
||||||
|
if ($keepVisible) {
|
||||||
|
$app['visible_to'] = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']);
|
||||||
|
} else {
|
||||||
unset($app['visible_to']);
|
unset($app['visible_to']);
|
||||||
|
}
|
||||||
if (!empty($app['menu_items']) && is_array($app['menu_items'])) {
|
if (!empty($app['menu_items']) && is_array($app['menu_items'])) {
|
||||||
$app['menu_items'] = array_values(array_map(function ($menu) {
|
$app['menu_items'] = array_values(array_map(function ($menu) {
|
||||||
$menu['keep_alive'] = isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true;
|
$menu['keep_alive'] = isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true;
|
||||||
$menu['disable_scope_css'] = (bool)($menu['disable_scope_css'] ?? false);
|
$menu['disable_scope_css'] = (bool)($menu['disable_scope_css'] ?? false);
|
||||||
$menu['auto_dark_theme'] = isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true;
|
$menu['auto_dark_theme'] = isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true;
|
||||||
$menu['transparent'] = (bool)($menu['transparent'] ?? false);
|
$menu['transparent'] = (bool)($menu['transparent'] ?? false);
|
||||||
|
$menu['key'] = isset($menu['key']) ? (string)$menu['key'] : '';
|
||||||
|
$menu['badge_clear_on_open'] = (bool)($menu['badge_clear_on_open'] ?? false);
|
||||||
if (isset($menu['visible_to'])) {
|
if (isset($menu['visible_to'])) {
|
||||||
unset($menu['visible_to']);
|
unset($menu['visible_to']);
|
||||||
}
|
}
|
||||||
@ -390,6 +466,9 @@ class Setting extends AbstractModel
|
|||||||
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
|
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
|
||||||
$payload['capsule'] = Base::newTrim($menu['capsule']);
|
$payload['capsule'] = Base::newTrim($menu['capsule']);
|
||||||
}
|
}
|
||||||
|
// 角标:菜单稳定标识 与 打开时是否自动清零
|
||||||
|
$payload['key'] = Base::newTrim($menu['key'] ?? '');
|
||||||
|
$payload['badge_clear_on_open'] = (bool)($menu['badge_clear_on_open'] ?? false);
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -398,7 +477,7 @@ class Setting extends AbstractModel
|
|||||||
* @param mixed $value
|
* @param mixed $value
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
protected static function normalizeCustomMicroVisible($value)
|
public static function normalizeCustomMicroVisible($value)
|
||||||
{
|
{
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
$list = array_filter(array_map('trim', $value));
|
$list = array_filter(array_map('trim', $value));
|
||||||
@ -421,7 +500,7 @@ class Setting extends AbstractModel
|
|||||||
* @param int $userId
|
* @param int $userId
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
|
public static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
|
||||||
{
|
{
|
||||||
if (in_array('all', $visible)) {
|
if (in_array('all', $visible)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -89,6 +89,8 @@ use Carbon\Carbon;
|
|||||||
*/
|
*/
|
||||||
class User extends AbstractModel
|
class User extends AbstractModel
|
||||||
{
|
{
|
||||||
|
const IMPORT_MAX = 500;
|
||||||
|
|
||||||
protected $primaryKey = 'userid';
|
protected $primaryKey = 'userid';
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@ -303,12 +305,12 @@ class User extends AbstractModel
|
|||||||
if ($onlyUserid && $onlyUserid != $this->userid) {
|
if ($onlyUserid && $onlyUserid != $this->userid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (env("PASSWORD_ADMIN") == 'disabled') {
|
if (config('dootask.password_admin') == 'disabled') {
|
||||||
if ($this->userid == 1) {
|
if ($this->userid == 1) {
|
||||||
throw new ApiException('当前环境禁止此操作');
|
throw new ApiException('当前环境禁止此操作');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (env("PASSWORD_OWNER") == 'disabled') {
|
if (config('dootask.password_owner') == 'disabled') {
|
||||||
throw new ApiException('当前环境禁止此操作');
|
throw new ApiException('当前环境禁止此操作');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -392,6 +394,10 @@ class User extends AbstractModel
|
|||||||
}
|
}
|
||||||
// 密码
|
// 密码
|
||||||
self::passwordPolicy($password);
|
self::passwordPolicy($password);
|
||||||
|
// license
|
||||||
|
if ($err = Doo::licenseBindingError(Doo::license())) {
|
||||||
|
throw new ApiException($err);
|
||||||
|
}
|
||||||
// 开始注册
|
// 开始注册
|
||||||
$user = Doo::userCreate($email, $password);
|
$user = Doo::userCreate($email, $password);
|
||||||
if ($other) {
|
if ($other) {
|
||||||
@ -425,6 +431,287 @@ class User extends AbstractModel
|
|||||||
return $createdUser;
|
return $createdUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
|
||||||
|
* @param string $email
|
||||||
|
* @param string $password
|
||||||
|
* @param string $nickname
|
||||||
|
* @param array $options changePass(bool,默认true) / emailVerity(bool,默认false,标记邮箱已认证) / department(int[]) / profession(string)
|
||||||
|
* @return self
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public static function createByAdmin(string $email, $password, string $nickname, array $options = []): self
|
||||||
|
{
|
||||||
|
$nickname = trim($nickname);
|
||||||
|
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
|
||||||
|
throw new ApiException('昵称需为2-20个字');
|
||||||
|
}
|
||||||
|
$changePass = ($options['changePass'] ?? true) ? 1 : 0;
|
||||||
|
$emailVerity = ($options['emailVerity'] ?? false) ? 1 : 0;
|
||||||
|
$profession = trim((string)($options['profession'] ?? ''));
|
||||||
|
// 校验前置(reg 之前快速失败,且可在无 Swoole 环境单测)
|
||||||
|
self::assertValidProfession($profession);
|
||||||
|
$departmentIds = self::assertValidDepartments($options['department'] ?? []);
|
||||||
|
// 复用 reg:邮箱校验/查重、passwordPolicy、Doo::userCreate、az/pinyin、全员群、索引同步、user_onboard hook
|
||||||
|
$user = self::reg($email, $password, ['nickname' => $nickname]);
|
||||||
|
// 管理员显式创建的账号视为正式员工,去除系统 reg_identity 可能带上的 temp
|
||||||
|
if (in_array('temp', $user->identity)) {
|
||||||
|
$user->identity = Base::arrayImplode(array_diff($user->identity, ['temp']));
|
||||||
|
}
|
||||||
|
$user->changepass = $changePass; // 复用现有首登强制改密机制
|
||||||
|
$user->email_verity = $emailVerity; // 管理员可在创建时直接标记邮箱认证状态
|
||||||
|
if ($profession !== '') {
|
||||||
|
$user->profession = $profession;
|
||||||
|
}
|
||||||
|
if ($departmentIds) {
|
||||||
|
$user->department = Base::arrayImplode($departmentIds);
|
||||||
|
}
|
||||||
|
$user->save();
|
||||||
|
// 设置了部门 → 加入对应部门群(复刻 operation 的 type=department 入群逻辑)
|
||||||
|
if ($departmentIds) {
|
||||||
|
$departments = UserDepartment::whereIn('id', $departmentIds)->get();
|
||||||
|
foreach ($departments as $department) {
|
||||||
|
try {
|
||||||
|
if ($department->dialog_id > 0 && $dialog = WebSocketDialog::find($department->dialog_id)) {
|
||||||
|
$dialog->joinGroup([$user->userid], 0, true);
|
||||||
|
$dialog->pushMsg("groupJoin", null, [$user->userid]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 部门入群为尽力投递:单个部门失败不影响账号创建与其他部门
|
||||||
|
\Log::warning('createByAdmin: 部门入群失败', [
|
||||||
|
'userid' => $user->userid,
|
||||||
|
'department_id' => $department->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将上传表格(Excel::toArray 的二维数组)归一化为导入行
|
||||||
|
* @param array $sheet
|
||||||
|
* @return array [{line, email, nickname, password}]
|
||||||
|
*/
|
||||||
|
public static function parseImportRows(array $sheet): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
foreach ($sheet as $index => $cells) {
|
||||||
|
if ($index === 0) {
|
||||||
|
continue; // 表头
|
||||||
|
}
|
||||||
|
$email = trim((string)($cells[0] ?? ''));
|
||||||
|
$nickname = trim((string)($cells[1] ?? ''));
|
||||||
|
$password = trim((string)($cells[2] ?? ''));
|
||||||
|
$profession = trim((string)($cells[3] ?? ''));
|
||||||
|
if ($email === '' && $nickname === '' && $password === '') {
|
||||||
|
continue; // 空行(仅职位有值也视为空行跳过)
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
'line' => $index + 1, // 电子表格行号(从 1 开始)
|
||||||
|
'email' => $email,
|
||||||
|
'nickname' => $nickname,
|
||||||
|
'password' => $password,
|
||||||
|
'profession' => $profession,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验单条导入行
|
||||||
|
* @param array $row ['email'=>,'nickname'=>,'password'=>,'profession'=>(选填)]
|
||||||
|
* @return string|null 错误文案;null 表示通过
|
||||||
|
*/
|
||||||
|
public static function validateImportRow(array $row): ?string
|
||||||
|
{
|
||||||
|
$email = trim((string)($row['email'] ?? ''));
|
||||||
|
$nickname = trim((string)($row['nickname'] ?? ''));
|
||||||
|
$password = trim((string)($row['password'] ?? ''));
|
||||||
|
if ($email === '' || $nickname === '' || $password === '') {
|
||||||
|
return '邮箱、昵称、初始密码均为必填';
|
||||||
|
}
|
||||||
|
if (!Base::isEmail($email)) {
|
||||||
|
return '邮箱格式不正确';
|
||||||
|
}
|
||||||
|
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
|
||||||
|
return '昵称需为2-20个字';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
self::passwordPolicy($password);
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
return $e->getMessage();
|
||||||
|
}
|
||||||
|
// 职位/职称选填,填写则校验 2-20 字
|
||||||
|
try {
|
||||||
|
self::assertValidProfession((string)($row['profession'] ?? ''));
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
return $e->getMessage();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验职位/职称:非空时必须 2-20 字(复用 operation 的现有文案)
|
||||||
|
* @param string $profession
|
||||||
|
* @return void
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public static function assertValidProfession(string $profession): void
|
||||||
|
{
|
||||||
|
$profession = trim($profession);
|
||||||
|
if ($profession === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mb_strlen($profession) < 2) {
|
||||||
|
throw new ApiException('职位/职称不可以少于2个字');
|
||||||
|
}
|
||||||
|
if (mb_strlen($profession) > 20) {
|
||||||
|
throw new ApiException('职位/职称最多只能设置20个字');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规整并校验部门 ID 列表:转正整数去重、最多 10 个、且每个必须存在
|
||||||
|
* @param mixed $ids
|
||||||
|
* @return int[]
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public static function assertValidDepartments($ids): array
|
||||||
|
{
|
||||||
|
if (!is_array($ids)) {
|
||||||
|
$ids = [];
|
||||||
|
}
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
||||||
|
if (count($ids) > 10) {
|
||||||
|
throw new ApiException('最多只可加入10个部门');
|
||||||
|
}
|
||||||
|
if ($ids) {
|
||||||
|
$existing = UserDepartment::whereIn('id', $ids)->pluck('id')->map(fn($v) => (int)$v)->all();
|
||||||
|
if (count($existing) < count($ids)) {
|
||||||
|
throw new ApiException('修改部门不存在');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入用户(部门/职位逐行:department 来自前端逐行设置,profession 来自 Excel 行)
|
||||||
|
* @param array $rows 每行含 email/nickname/password/profession,可选 department(int[])
|
||||||
|
* @param bool $changePass 是否要求首登改密(对本批所有账号生效)
|
||||||
|
* @return array ['total'=>int, 'success'=>int, 'failed'=>[['line','email','reason']]]
|
||||||
|
* @throws ApiException 行数超限
|
||||||
|
*/
|
||||||
|
public static function importUsers(array $rows, bool $changePass = true): array
|
||||||
|
{
|
||||||
|
if (count($rows) > self::IMPORT_MAX) {
|
||||||
|
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
|
||||||
|
}
|
||||||
|
$success = 0;
|
||||||
|
$failed = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$error = self::validateImportRow($row);
|
||||||
|
if ($error === null) {
|
||||||
|
$emailLower = strtolower(trim((string)$row['email']));
|
||||||
|
if (isset($seen[$emailLower])) {
|
||||||
|
$error = '文件内邮箱重复';
|
||||||
|
} else {
|
||||||
|
$seen[$emailLower] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($error === null) {
|
||||||
|
try {
|
||||||
|
self::createByAdmin($row['email'], $row['password'], $row['nickname'], [
|
||||||
|
'changePass' => $changePass,
|
||||||
|
'emailVerity' => !empty($row['email_verity']),
|
||||||
|
'department' => $row['department'] ?? [],
|
||||||
|
'profession' => $row['profession'] ?? '',
|
||||||
|
]);
|
||||||
|
$success++;
|
||||||
|
continue;
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$failed[] = [
|
||||||
|
'line' => $row['line'] ?? 0,
|
||||||
|
'email' => $row['email'] ?? '',
|
||||||
|
'reason' => $error,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'total' => count($rows),
|
||||||
|
'success' => $success,
|
||||||
|
'failed' => $failed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入预览(只解析+校验,不创建任何账号)
|
||||||
|
* 逐行判定 ok/error:必填/邮箱格式/昵称长度/密码策略、文件内邮箱重复、系统中邮箱已存在
|
||||||
|
* @param array $rows parseImportRows 的输出
|
||||||
|
* @return array ['total'=>int,'valid'=>int,'invalid'=>int,'rows'=>[['line','email','nickname','password','status','reason']]]
|
||||||
|
*/
|
||||||
|
public static function importPreview(array $rows): array
|
||||||
|
{
|
||||||
|
if (count($rows) > self::IMPORT_MAX) {
|
||||||
|
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
|
||||||
|
}
|
||||||
|
// 预查系统中已存在的邮箱(小写比较)
|
||||||
|
$emails = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$e = strtolower(trim((string)($row['email'] ?? '')));
|
||||||
|
if ($e !== '') {
|
||||||
|
$emails[$e] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$existing = [];
|
||||||
|
if ($emails) {
|
||||||
|
foreach (self::whereIn('email', array_keys($emails))->pluck('email') as $em) {
|
||||||
|
$existing[strtolower($em)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$seen = [];
|
||||||
|
$valid = 0;
|
||||||
|
$list = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$reason = self::validateImportRow($row);
|
||||||
|
$emailLower = strtolower(trim((string)($row['email'] ?? '')));
|
||||||
|
if ($reason === null) {
|
||||||
|
if (isset($seen[$emailLower])) {
|
||||||
|
$reason = '文件内邮箱重复';
|
||||||
|
} else {
|
||||||
|
$seen[$emailLower] = true;
|
||||||
|
if (isset($existing[$emailLower])) {
|
||||||
|
$reason = '邮箱地址已存在';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ok = $reason === null;
|
||||||
|
if ($ok) {
|
||||||
|
$valid++;
|
||||||
|
}
|
||||||
|
$list[] = [
|
||||||
|
'line' => $row['line'] ?? 0,
|
||||||
|
'email' => $row['email'] ?? '',
|
||||||
|
'nickname' => $row['nickname'] ?? '',
|
||||||
|
'password' => $row['password'] ?? '',
|
||||||
|
'profession' => $row['profession'] ?? '',
|
||||||
|
'email_verity' => 1, // 默认标记为已认证,前端可在预览中按行调整
|
||||||
|
'status' => $ok ? 'ok' : 'error',
|
||||||
|
'reason' => $reason ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'total' => count($rows),
|
||||||
|
'valid' => $valid,
|
||||||
|
'invalid' => count($rows) - $valid,
|
||||||
|
'rows' => $list,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取我的ID
|
* 获取我的ID
|
||||||
* @return int
|
* @return int
|
||||||
@ -678,6 +965,8 @@ class User extends AbstractModel
|
|||||||
return url("images/avatar/default_ollama.png");
|
return url("images/avatar/default_ollama.png");
|
||||||
case 'ai-zhipu@bot.system':
|
case 'ai-zhipu@bot.system':
|
||||||
return url("images/avatar/default_zhipu.png");
|
return url("images/avatar/default_zhipu.png");
|
||||||
|
case 'ai-dooai@bot.system':
|
||||||
|
return url("images/avatar/default_dooai.png");
|
||||||
case 'bot-manager@bot.system':
|
case 'bot-manager@bot.system':
|
||||||
return url("images/avatar/default_bot.png");
|
return url("images/avatar/default_bot.png");
|
||||||
case 'meeting-alert@bot.system':
|
case 'meeting-alert@bot.system':
|
||||||
|
|||||||
@ -151,6 +151,7 @@ class UserBot extends AbstractModel
|
|||||||
$name = match ($name) {
|
$name = match ($name) {
|
||||||
'system-msg' => '系统消息',
|
'system-msg' => '系统消息',
|
||||||
'task-alert' => '任务提醒',
|
'task-alert' => '任务提醒',
|
||||||
|
'todo-alert' => '待办提醒',
|
||||||
'check-in' => '签到打卡',
|
'check-in' => '签到打卡',
|
||||||
'anon-msg' => '匿名消息',
|
'anon-msg' => '匿名消息',
|
||||||
'approval-alert' => '审批',
|
'approval-alert' => '审批',
|
||||||
@ -163,6 +164,7 @@ class UserBot extends AbstractModel
|
|||||||
'ai-zhipu' => '智谱清言',
|
'ai-zhipu' => '智谱清言',
|
||||||
'ai-qianwen' => '通义千问',
|
'ai-qianwen' => '通义千问',
|
||||||
'ai-wenxin' => '文心一言',
|
'ai-wenxin' => '文心一言',
|
||||||
|
'ai-dooai' => 'Doo AI',
|
||||||
'bot-manager' => '机器人管理',
|
'bot-manager' => '机器人管理',
|
||||||
'meeting-alert' => '会议通知',
|
'meeting-alert' => '会议通知',
|
||||||
'okr-alert' => 'OKR提醒',
|
'okr-alert' => 'OKR提醒',
|
||||||
@ -271,8 +273,11 @@ class UserBot extends AbstractModel
|
|||||||
if ($match[1] === "ai-") {
|
if ($match[1] === "ai-") {
|
||||||
$aibotSetting = Base::setting('aibotSetting');
|
$aibotSetting = Base::setting('aibotSetting');
|
||||||
$aibotModel = $aibotSetting[$match[2] . '_model'];
|
$aibotModel = $aibotSetting[$match[2] . '_model'];
|
||||||
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models']);
|
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models'], false, true);
|
||||||
if ($aibotModels) {
|
if ($aibotModels) {
|
||||||
|
if (!in_array($aibotModel, array_column($aibotModels, 'value'), true)) {
|
||||||
|
$aibotModel = $aibotModels[0]['value'];
|
||||||
|
}
|
||||||
$menus = array_merge(
|
$menus = array_merge(
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
|
|||||||
@ -33,6 +33,7 @@ use Request;
|
|||||||
* @method static \Illuminate\Database\Eloquent\Builder|UserDepartment whereOwnerUserid($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|UserDepartment whereOwnerUserid($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|UserDepartment whereParentId($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|UserDepartment whereParentId($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|UserDepartment whereUpdatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|UserDepartment whereUpdatedAt($value)
|
||||||
|
* @property-read array $deputy_userids
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class UserDepartment extends AbstractModel
|
class UserDepartment extends AbstractModel
|
||||||
@ -615,4 +616,69 @@ class UserDepartment extends AbstractModel
|
|||||||
return $project;
|
return $project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员卡片「查看该会员项目/任务」的权限上下文。
|
||||||
|
* 允许条件:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
|
||||||
|
* @param User $viewer 当前登录用户
|
||||||
|
* @param int $targetUserid 目标会员
|
||||||
|
* @return array ['allowed'=>bool, 'is_self'=>bool, 'is_admin'=>bool, 'project_ids'=>int[]]
|
||||||
|
* project_ids 仅在部门负责人视角下有意义(限定可见项目集合);本人/管理员为空数组表示不限制
|
||||||
|
*/
|
||||||
|
public static function userWorksContext(User $viewer, int $targetUserid): array
|
||||||
|
{
|
||||||
|
$result = [
|
||||||
|
'allowed' => false,
|
||||||
|
'is_self' => false,
|
||||||
|
'is_admin' => false,
|
||||||
|
'project_ids' => [],
|
||||||
|
];
|
||||||
|
if ($targetUserid <= 0) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
// 机器人/系统账号(或不存在)不展示项目与任务
|
||||||
|
$target = User::select(['userid', 'bot'])->whereUserid($targetUserid)->first();
|
||||||
|
if (empty($target) || $target->bot) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
// 本人
|
||||||
|
if ($viewer->userid === $targetUserid) {
|
||||||
|
$result['allowed'] = true;
|
||||||
|
$result['is_self'] = true;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
// 系统管理员
|
||||||
|
if ($viewer->isAdmin()) {
|
||||||
|
$result['allowed'] = true;
|
||||||
|
$result['is_admin'] = true;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
// 部门负责人只读视角
|
||||||
|
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');
|
||||||
|
if (!in_array($targetUserid, $memberUserids, true)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
// 目标会员参与、且未关闭「部门负责人视角可见」的项目
|
||||||
|
$projectIds = ProjectUser::where('project_users.userid', $targetUserid)
|
||||||
|
->join('projects', 'projects.id', '=', 'project_users.project_id')
|
||||||
|
->whereNull('projects.deleted_at')
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->where('projects.department_owner_view', '<>', 'close')
|
||||||
|
->orWhereNull('projects.department_owner_view');
|
||||||
|
})
|
||||||
|
->distinct()
|
||||||
|
->pluck('projects.id')
|
||||||
|
->map(fn($v) => intval($v))
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
if (empty($projectIds)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
$result['allowed'] = true;
|
||||||
|
$result['project_ids'] = $projectIds;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,9 @@ use App\Module\Base;
|
|||||||
use App\Module\Doo;
|
use App\Module\Doo;
|
||||||
use App\Module\Timer;
|
use App\Module\Timer;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Guanguans\Notify\Factory;
|
use Symfony\Component\Mailer\Mailer;
|
||||||
use Guanguans\Notify\Messages\EmailMessage;
|
use Symfony\Component\Mailer\Transport;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App\Models\UserEmailVerification
|
* App\Models\UserEmailVerification
|
||||||
@ -97,16 +98,14 @@ class UserEmailVerification extends AbstractModel
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Factory::mailer()
|
$mailer = new Mailer(Transport::fromDsn("smtp://{$setting['account']}:{$setting['password']}@{$setting['smtp_server']}:{$setting['port']}?verify_peer=0"));
|
||||||
->setDsn("smtp://{$setting['account']}:{$setting['password']}@{$setting['smtp_server']}:{$setting['port']}?verify_peer=0")
|
$mailer->send((new Email())
|
||||||
->setMessage(EmailMessage::create()
|
|
||||||
->from($alias . " <{$setting['account']}>")
|
->from($alias . " <{$setting['account']}>")
|
||||||
->to($email)
|
->to($email)
|
||||||
->subject($subject)
|
->subject($subject)
|
||||||
->html($content))
|
->html($content));
|
||||||
->send();
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
if (str_contains($e->getMessage(), "Timed Out")) {
|
if (stripos($e->getMessage(), "timed out") !== false) {
|
||||||
throw new ApiException("邮件发送超时,请检查邮箱配置是否正确");
|
throw new ApiException("邮件发送超时,请检查邮箱配置是否正确");
|
||||||
} elseif ($e->getCode() === 550) {
|
} elseif ($e->getCode() === 550) {
|
||||||
throw new ApiException('邮件内容被拒绝,请检查邮箱是否开启接收功能');
|
throw new ApiException('邮件内容被拒绝,请检查邮箱是否开启接收功能');
|
||||||
|
|||||||
@ -57,8 +57,8 @@ class UserRecentItem extends AbstractModel
|
|||||||
'browsed_at',
|
'browsed_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $dates = [
|
protected $casts = [
|
||||||
'browsed_at',
|
'browsed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function record(int $userid, string $targetType, int $targetId, string $sourceType = '', int $sourceId = 0): self
|
public static function record(int $userid, string $targetType, int $targetId, string $sourceType = '', int $sourceId = 0): self
|
||||||
|
|||||||
@ -40,8 +40,8 @@ class UserTaskBrowse extends AbstractModel
|
|||||||
'browsed_at',
|
'browsed_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $dates = [
|
protected $casts = [
|
||||||
'browsed_at',
|
'browsed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -5,8 +5,6 @@ namespace App\Models;
|
|||||||
use App\Exceptions\ApiException;
|
use App\Exceptions\ApiException;
|
||||||
use App\Module\Base;
|
use App\Module\Base;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Guanguans\Notify\Factory;
|
|
||||||
use Guanguans\Notify\Messages\EmailMessage;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App\Models\UserTransfer
|
* App\Models\UserTransfer
|
||||||
|
|||||||
@ -56,12 +56,16 @@ use Illuminate\Support\Facades\DB;
|
|||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereUpdatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereUpdatedAt($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog withTrashed()
|
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog withTrashed()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog withoutTrashed()
|
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog withoutTrashed()
|
||||||
|
* @property-read array $deputy_ids
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class WebSocketDialog extends AbstractModel
|
class WebSocketDialog extends AbstractModel
|
||||||
{
|
{
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
|
// 全员群初始化默认名称(双语字面量),用于识别"管理员尚未自定义"的状态
|
||||||
|
const ALL_GROUP_DEFAULT_NAME = '全体成员 All members';
|
||||||
|
|
||||||
protected $appends = ['deputy_ids'];
|
protected $appends = ['deputy_ids'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -366,7 +370,9 @@ class WebSocketDialog extends AbstractModel
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'all':
|
case 'all':
|
||||||
$data['name'] = Doo::translate('全体成员');
|
$data['name'] = ($data['name'] && $data['name'] !== self::ALL_GROUP_DEFAULT_NAME)
|
||||||
|
? $data['name']
|
||||||
|
: Doo::translate('全体成员');
|
||||||
$data['dialog_mute'] = Base::settingFind('system', 'all_group_mute');
|
$data['dialog_mute'] = Base::settingFind('system', 'all_group_mute');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -710,6 +716,46 @@ class WebSocketDialog extends AbstractModel
|
|||||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否有权限设置/取消本会话内「他人」的待办
|
||||||
|
* 放行:群主/群管理员、关联项目负责人/项目管理员、关联任务负责人(及任务所属项目负责人/管理员)
|
||||||
|
*
|
||||||
|
* @param int $userid
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function checkTodoOwnerPermission($userid): bool
|
||||||
|
{
|
||||||
|
$userid = intval($userid);
|
||||||
|
if ($userid <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 系统管理员:可管理任意会话的他人待办(与管理员全局管理能力一致,覆盖无群主的全员群等)
|
||||||
|
if (User::find($userid)?->isAdmin()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 群主 / 群管理员
|
||||||
|
if ($this->isOwner($userid)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 关联项目(项目群)负责人 / 项目管理员
|
||||||
|
$project = Project::whereDialogId($this->id)->first();
|
||||||
|
if ($project && $project->isOwner($userid)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 关联任务(任务群)负责人,及任务所属项目负责人 / 管理员
|
||||||
|
$task = ProjectTask::whereDialogId($this->id)->first();
|
||||||
|
if ($task) {
|
||||||
|
if (ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->whereOwner(1)->exists()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$taskProject = Project::find($task->project_id);
|
||||||
|
if ($taskProject && $taskProject->isOwner($userid)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 群管理员 userid 列表
|
* 群管理员 userid 列表
|
||||||
*
|
*
|
||||||
@ -784,7 +830,9 @@ class WebSocketDialog extends AbstractModel
|
|||||||
$name = \DB::table('project_tasks')->where('dialog_id', $this->id)->value('name');
|
$name = \DB::table('project_tasks')->where('dialog_id', $this->id)->value('name');
|
||||||
break;
|
break;
|
||||||
case 'all':
|
case 'all':
|
||||||
$name = Doo::translate('全体成员');
|
$name = ($name && $name !== self::ALL_GROUP_DEFAULT_NAME)
|
||||||
|
? $name
|
||||||
|
: Doo::translate('全体成员');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1052,6 +1100,46 @@ class WebSocketDialog extends AbstractModel
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 sendMsgFiles 同链路,但接收已落盘的本地文件(分片合并产物),跳过 Base::upload。
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param int[] $dialogIds
|
||||||
|
* @param string $localPath 已落盘绝对路径
|
||||||
|
* @param string $originalName 原始文件名
|
||||||
|
* @param int $replyId
|
||||||
|
* @param bool $imageAttachment 任务群组中图片是否也作为附件保存
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function sendMsgFilesFromPath($user, $dialogIds, string $localPath, string $originalName, int $replyId = 0, bool $imageAttachment = false)
|
||||||
|
{
|
||||||
|
$first = null;
|
||||||
|
$fileName = $originalName;
|
||||||
|
$resolve = function ($path) use (&$first, &$fileName, $localPath, $originalName) {
|
||||||
|
if ($first !== null) {
|
||||||
|
return self::copyFileDataTo($first, $path);
|
||||||
|
}
|
||||||
|
$setting = Base::setting("system");
|
||||||
|
$data = Base::uploadFromPath([
|
||||||
|
"path_local" => $localPath,
|
||||||
|
"name" => $originalName,
|
||||||
|
"type" => 'more',
|
||||||
|
"path" => $path,
|
||||||
|
"fileName" => $fileName,
|
||||||
|
"quality" => true,
|
||||||
|
"convertVideo" => $setting['convert_video'] === 'open',
|
||||||
|
"compressVideo" => $setting['compress_video'] === 'open',
|
||||||
|
]);
|
||||||
|
if (Base::isError($data)) {
|
||||||
|
throw new ApiException($data['msg']);
|
||||||
|
}
|
||||||
|
$first = $data['data'];
|
||||||
|
$fileName = $first['name'];
|
||||||
|
return $first;
|
||||||
|
};
|
||||||
|
return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送消息文件
|
* 发送消息文件
|
||||||
*
|
*
|
||||||
@ -1066,24 +1154,18 @@ class WebSocketDialog extends AbstractModel
|
|||||||
*/
|
*/
|
||||||
public static function sendMsgFiles($user, $dialogIds, $files, $image64, $fileName, $replyId, $imageAttachment)
|
public static function sendMsgFiles($user, $dialogIds, $files, $image64, $fileName, $replyId, $imageAttachment)
|
||||||
{
|
{
|
||||||
$filePath = '';
|
$first = null;
|
||||||
$result = [];
|
$resolve = function ($path) use (&$first, &$fileName, $files, $image64) {
|
||||||
$data = [];
|
if ($first !== null) {
|
||||||
foreach ($dialogIds as $dialog_id) {
|
return self::copyFileDataTo($first, $path);
|
||||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
}
|
||||||
|
|
||||||
$action = $replyId > 0 ? "reply-$replyId" : "";
|
|
||||||
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
|
|
||||||
if ($image64) {
|
if ($image64) {
|
||||||
$data = Base::image64save([
|
$data = Base::image64save([
|
||||||
"image64" => $image64,
|
"image64" => $image64,
|
||||||
"path" => $path,
|
"path" => $path,
|
||||||
"fileName" => $fileName,
|
"fileName" => $fileName,
|
||||||
"quality" => true
|
"quality" => true,
|
||||||
]);
|
]);
|
||||||
} else if ($filePath) {
|
|
||||||
Base::makeDir(public_path($path));
|
|
||||||
copy($filePath, public_path($path) . basename($filePath));
|
|
||||||
} else {
|
} else {
|
||||||
$setting = Base::setting("system");
|
$setting = Base::setting("system");
|
||||||
$data = Base::upload([
|
$data = Base::upload([
|
||||||
@ -1099,19 +1181,41 @@ class WebSocketDialog extends AbstractModel
|
|||||||
if (Base::isError($data)) {
|
if (Base::isError($data)) {
|
||||||
throw new ApiException($data['msg']);
|
throw new ApiException($data['msg']);
|
||||||
}
|
}
|
||||||
$fileData = $data['data'];
|
$first = $data['data'];
|
||||||
$filePath = $fileData['file'];
|
$fileName = $first['name'];
|
||||||
$fileName = $fileData['name'];
|
return $first;
|
||||||
$fileData['thumb'] = Base::unFillUrl($fileData['thumb']);
|
};
|
||||||
$fileData['size'] *= 1024;
|
return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve);
|
||||||
|
}
|
||||||
|
|
||||||
// 任务群组保存文件
|
/**
|
||||||
|
* 遍历多个 dialog 发送文件消息:每个 dialog 取一份 fileData → 任务群组建附件 → sendMsg。
|
||||||
|
* 取 fileData 的策略由 $resolve(path) 决定(首次 upload,后续 copy)。
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param int[] $dialogIds
|
||||||
|
* @param int $replyId
|
||||||
|
* @param bool $imageAttachment
|
||||||
|
* @param callable $resolve fn(string $path): array 返回该 dialog 的 fileData
|
||||||
|
* @return array sendMsg 的最终返回(最后一个 dialog 的结果)
|
||||||
|
*/
|
||||||
|
private static function dispatchFileMessages($user, array $dialogIds, int $replyId, bool $imageAttachment, callable $resolve): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($dialogIds as $dialog_id) {
|
||||||
|
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||||
|
$action = $replyId > 0 ? "reply-$replyId" : "";
|
||||||
|
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
|
||||||
|
$fileData = $resolve($path);
|
||||||
|
$fileData['thumb'] = Base::unFillUrl($fileData['thumb'] ?? '');
|
||||||
|
$fileData['size'] *= 1024;
|
||||||
|
$task = null;
|
||||||
if ($dialog->group_type === 'task') {
|
if ($dialog->group_type === 'task') {
|
||||||
// 如果是图片不保存
|
// 图片消息默认不作为任务附件存档,除非显式 $imageAttachment
|
||||||
if ($imageAttachment || !in_array($fileData['ext'], File::imageExt)) {
|
if ($imageAttachment || !in_array($fileData['ext'], File::imageExt)) {
|
||||||
$task = ProjectTask::whereDialogId($dialog->id)->first();
|
$task = ProjectTask::whereDialogId($dialog->id)->first();
|
||||||
if ($task) {
|
if ($task) {
|
||||||
$file = ProjectTaskFile::createInstance([
|
ProjectTaskFile::createInstance([
|
||||||
'project_id' => $task->project_id,
|
'project_id' => $task->project_id,
|
||||||
'task_id' => $task->id,
|
'task_id' => $task->id,
|
||||||
'name' => $fileData['name'],
|
'name' => $fileData['name'],
|
||||||
@ -1120,20 +1224,30 @@ class WebSocketDialog extends AbstractModel
|
|||||||
'path' => $fileData['path'],
|
'path' => $fileData['path'],
|
||||||
'thumb' => $fileData['thumb'],
|
'thumb' => $fileData['thumb'],
|
||||||
'userid' => $user->userid,
|
'userid' => $user->userid,
|
||||||
]);
|
])->save();
|
||||||
$file->save();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送消息
|
|
||||||
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'file', $fileData, $user->userid);
|
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'file', $fileData, $user->userid);
|
||||||
if (Base::isSuccess($result)) {
|
if (Base::isSuccess($result) && $task) {
|
||||||
if (isset($task)) {
|
|
||||||
$result['data']['task_id'] = $task->id;
|
$result['data']['task_id'] = $task->id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把首个 dialog 上传得到的物理文件 copy 到后续 dialog 的目录,返回更新后的 fileData。
|
||||||
|
*/
|
||||||
|
private static function copyFileDataTo(array $first, string $path): array
|
||||||
|
{
|
||||||
|
Base::makeDir(public_path($path));
|
||||||
|
$target = public_path($path) . basename($first['file']);
|
||||||
|
copy($first['file'], $target);
|
||||||
|
$copy = $first;
|
||||||
|
$copy['file'] = $target;
|
||||||
|
$copy['path'] = $path . basename($first['file']);
|
||||||
|
$copy['url'] = Base::fillUrl($copy['path']);
|
||||||
|
return $copy;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -414,7 +414,7 @@ class WebSocketDialogMsg extends AbstractModel
|
|||||||
* @param array $userids 设置给指定会员
|
* @param array $userids 设置给指定会员
|
||||||
* @return mixed
|
* @return mixed
|
||||||
*/
|
*/
|
||||||
public function toggleTodoMsg($sender, $userids = [])
|
public function toggleTodoMsg($sender, $userids = [], $remindAt = false)
|
||||||
{
|
{
|
||||||
if (in_array($this->type, ['tag', 'todo', 'notice'])) {
|
if (in_array($this->type, ['tag', 'todo', 'notice'])) {
|
||||||
return Base::retError('此消息不支持设待办');
|
return Base::retError('此消息不支持设待办');
|
||||||
@ -423,6 +423,14 @@ class WebSocketDialogMsg extends AbstractModel
|
|||||||
$current = WebSocketDialogMsgTodo::whereMsgId($this->id)->pluck('userid')->toArray();
|
$current = WebSocketDialogMsgTodo::whereMsgId($this->id)->pluck('userid')->toArray();
|
||||||
$cancel = array_diff($current, $userids);
|
$cancel = array_diff($current, $userids);
|
||||||
$setup = array_diff($userids, $current);
|
$setup = array_diff($userids, $current);
|
||||||
|
// 待办操作权限管控(系统开关:禁止其他人员设置/取消待办)
|
||||||
|
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
|
||||||
|
$affected = array_unique(array_merge($cancel, $setup)); // 本次真正影响到的用户
|
||||||
|
$others = array_diff($affected, [$sender]); // 排除"自己"
|
||||||
|
if ($others && !$dialog->checkTodoOwnerPermission($sender)) {
|
||||||
|
return Base::retError('仅群主、项目/任务负责人或系统管理员可设置或取消他人待办');
|
||||||
|
}
|
||||||
|
}
|
||||||
//
|
//
|
||||||
$this->todo = $setup || count($current) > count($cancel) ? $sender : 0;
|
$this->todo = $setup || count($current) > count($cancel) ? $sender : 0;
|
||||||
$this->save();
|
$this->save();
|
||||||
@ -477,12 +485,39 @@ class WebSocketDialogMsg extends AbstractModel
|
|||||||
];
|
];
|
||||||
$dialog->pushMsg('update', $upData);
|
$dialog->pushMsg('update', $upData);
|
||||||
//
|
//
|
||||||
|
// 提醒时间:仅当调用方显式传入时处理(false=不传则不动既有提醒)
|
||||||
|
if ($remindAt !== false) {
|
||||||
|
$this->setTodoRemind($userids, $remindAt ?: null);
|
||||||
|
}
|
||||||
|
//
|
||||||
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
|
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
|
||||||
'add' => $addData,
|
'add' => $addData,
|
||||||
'update' => $upData,
|
'update' => $upData,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置/取消本消息指定成员待办的提醒时间(纯数据,无推送)。
|
||||||
|
* 改动会把 reminded_at 重置为 null,使其可再次到点提醒。
|
||||||
|
*
|
||||||
|
* @param array $userids 目标成员
|
||||||
|
* @param string|null $remindAt 提醒时间字符串;null/空 表示取消提醒
|
||||||
|
* @return int 受影响行数
|
||||||
|
*/
|
||||||
|
public function setTodoRemind(array $userids, $remindAt = null)
|
||||||
|
{
|
||||||
|
$userids = array_values(array_filter(array_map('intval', $userids)));
|
||||||
|
if (empty($userids)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return WebSocketDialogMsgTodo::whereMsgId($this->id)
|
||||||
|
->whereIn('userid', $userids)
|
||||||
|
->update([
|
||||||
|
'remind_at' => $remindAt ?: null,
|
||||||
|
'reminded_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转发消息
|
* 转发消息
|
||||||
* @param array|int $dialogids
|
* @param array|int $dialogids
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App\Models\WebSocketDialogMsgTodo
|
* App\Models\WebSocketDialogMsgTodo
|
||||||
*
|
*
|
||||||
@ -25,6 +27,10 @@ namespace App\Models;
|
|||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTodo whereId($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTodo whereId($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTodo whereMsgId($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTodo whereMsgId($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTodo whereUserid($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTodo whereUserid($value)
|
||||||
|
* @property \Illuminate\Support\Carbon|null $remind_at 提醒时间
|
||||||
|
* @property \Illuminate\Support\Carbon|null $reminded_at 已提醒时间
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebSocketDialogMsgTodo whereRemindAt($value)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebSocketDialogMsgTodo whereRemindedAt($value)
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class WebSocketDialogMsgTodo extends AbstractModel
|
class WebSocketDialogMsgTodo extends AbstractModel
|
||||||
@ -50,4 +56,21 @@ class WebSocketDialogMsgTodo extends AbstractModel
|
|||||||
}
|
}
|
||||||
return $this->appendattrs['msgData'];
|
return $this->appendattrs['msgData'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取到点待提醒的待办行:有提醒时间、未提醒、未完成、提醒时间已到。
|
||||||
|
* 纯查询,无副作用,供 TodoRemindTask 使用。
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection
|
||||||
|
*/
|
||||||
|
public static function dueReminders()
|
||||||
|
{
|
||||||
|
return self::whereNotNull('remind_at')
|
||||||
|
->whereNull('reminded_at')
|
||||||
|
->whereNull('done_at')
|
||||||
|
->where('remind_at', '<=', Carbon::now())
|
||||||
|
->orderBy('msg_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(500)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,8 @@ namespace App\Models;
|
|||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereTopAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereTopAt($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereUpdatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereUpdatedAt($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereUserid($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereUserid($value)
|
||||||
|
* @property int $role 0=普通成员 1=群主 2=群管理员
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebSocketDialogUser whereRole($value)
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class WebSocketDialogUser extends AbstractModel
|
class WebSocketDialogUser extends AbstractModel
|
||||||
|
|||||||
@ -21,7 +21,8 @@ class AI
|
|||||||
'ollama',
|
'ollama',
|
||||||
'zhipu',
|
'zhipu',
|
||||||
'qianwen',
|
'qianwen',
|
||||||
'wenxin'
|
'wenxin',
|
||||||
|
'dooai'
|
||||||
];
|
];
|
||||||
protected const OPENAI_DEFAULT_MODEL = 'gpt-5.1-mini';
|
protected const OPENAI_DEFAULT_MODEL = 'gpt-5.1-mini';
|
||||||
|
|
||||||
@ -140,7 +141,31 @@ class AI
|
|||||||
* @param mixed $contextInput
|
* @param mixed $contextInput
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function createStreamKey($modelType, $modelName, $contextInput = [])
|
/**
|
||||||
|
* 判定当前用户是否启用 ai-kb RAG(灰度判定)
|
||||||
|
*
|
||||||
|
* 规则(参考 config/ai.php):
|
||||||
|
* - 总开关 rag_enabled=false → 关闭所有(kill switch)
|
||||||
|
* - rag_canary_userids 为空 → 全员启用
|
||||||
|
* - 否则仅白名单 userid 启用
|
||||||
|
*/
|
||||||
|
public static function ragEnabledFor(int $userid): bool
|
||||||
|
{
|
||||||
|
if (!config('ai.rag_enabled', true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$raw = trim((string) config('ai.rag_canary_userids', ''));
|
||||||
|
if ($raw === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$allow = array_filter(array_map(
|
||||||
|
fn($v) => (int) trim($v),
|
||||||
|
explode(',', $raw)
|
||||||
|
), fn($v) => $v > 0);
|
||||||
|
return in_array($userid, $allow, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function createStreamKey($modelType, $modelName, $contextInput = [], $locale = 'zh', $ragEnabled = true, $contextKey = '', $fd = 0)
|
||||||
{
|
{
|
||||||
$modelType = trim((string)$modelType);
|
$modelType = trim((string)$modelType);
|
||||||
$modelName = trim((string)$modelName);
|
$modelName = trim((string)$modelName);
|
||||||
@ -221,6 +246,14 @@ class AI
|
|||||||
'model_type' => $remoteModelType,
|
'model_type' => $remoteModelType,
|
||||||
'model_name' => $modelName,
|
'model_name' => $modelName,
|
||||||
'context' => $contextJson,
|
'context' => $contextJson,
|
||||||
|
'locale' => $locale,
|
||||||
|
// ai-kb 灰度透传:1 启用 RAG(hint + search_help_docs tool),0 关闭
|
||||||
|
'rag_enabled' => $ragEnabled ? '1' : '0',
|
||||||
|
// 前端会话ID,AI 服务存为 context_key 用于检索打点关联
|
||||||
|
'context_key' => mb_substr(trim((string)$contextKey), 0, 100),
|
||||||
|
// AI 助手路径启用 doo 执行工具;fd 为用户当前 WebSocket 连接(页面操作用,0 表示无)
|
||||||
|
'doo_enabled' => '1',
|
||||||
|
'fd' => intval($fd),
|
||||||
];
|
];
|
||||||
|
|
||||||
$baseUrl = trim((string)($setting[$modelType . '_base_url'] ?? ''));
|
$baseUrl = trim((string)($setting[$modelType . '_base_url'] ?? ''));
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Module;
|
namespace App\Module;
|
||||||
|
|
||||||
use App\Exceptions\ApiException;
|
use App\Exceptions\ApiException;
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserDepartment;
|
use App\Models\UserDepartment;
|
||||||
use App\Services\RequestContext;
|
use App\Services\RequestContext;
|
||||||
@ -29,14 +30,7 @@ class Apps
|
|||||||
return (bool) RequestContext::get($key, false);
|
return (bool) RequestContext::get($key, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
$configFile = base_path('docker/appstore/config/' . $appId . '/config.yml');
|
return RequestContext::save($key, self::loadInstalledConfig($appId) !== null);
|
||||||
$installed = false;
|
|
||||||
if (file_exists($configFile)) {
|
|
||||||
$configData = Yaml::parseFile($configFile);
|
|
||||||
$installed = $configData['status'] === 'installed';
|
|
||||||
}
|
|
||||||
|
|
||||||
return RequestContext::save($key, $installed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,6 +56,180 @@ class Apps
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* appstore 目录下的绝对路径(统一 docker/appstore 前缀)。
|
||||||
|
*
|
||||||
|
* @param string $relative 相对 docker/appstore 的路径
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function appstorePath(string $relative): string
|
||||||
|
{
|
||||||
|
return base_path('docker/appstore/' . ltrim($relative, '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取并校验某应用的 appstore config.yml;仅当文件存在且 status=installed 时返回解析后的配置数组。
|
||||||
|
*
|
||||||
|
* @param string $appId
|
||||||
|
* @return array|null
|
||||||
|
*/
|
||||||
|
private static function loadInstalledConfig(string $appId): ?array
|
||||||
|
{
|
||||||
|
$appId = trim($appId);
|
||||||
|
if ($appId === '' || $appId === 'appstore') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$configFile = self::appstorePath("config/{$appId}/config.yml");
|
||||||
|
if (!file_exists($configFile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$config = Yaml::parseFile($configFile);
|
||||||
|
if (!is_array($config) || ($config['status'] ?? '') !== 'installed') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将单个 menu_items 项映射为角标用的菜单配置。
|
||||||
|
*
|
||||||
|
* @param array $menu
|
||||||
|
* @param mixed $visibleDefault 应用级默认可见范围
|
||||||
|
* @return array ['key'=>string,'visible'=>array,'badge_clear_on_open'=>bool]
|
||||||
|
*/
|
||||||
|
private static function mapMenuItem(array $menu, $visibleDefault): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => trim((string)($menu['key'] ?? '')),
|
||||||
|
'visible' => Setting::normalizeCustomMicroVisible($menu['visible_to'] ?? $visibleDefault),
|
||||||
|
'badge_clear_on_open' => (bool)($menu['badge_clear_on_open'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取(必要时生成并持久化)应用的独立密钥 APP_SECRET。
|
||||||
|
*
|
||||||
|
* 与全局 APP_KEY 不同,APP_SECRET 每个已安装应用独立、唯一,持久化在应用自身的
|
||||||
|
* docker/appstore/config/{appid}/config.yml(与 KB_INGEST_TOKEN 等每应用参数同源),
|
||||||
|
* 由 appstore 安装链路按内置 compose 变量 APP_SECRET 注入插件容器。
|
||||||
|
* 此处主程序侧负责生成/持久化与校验;首次需要时若不存在则惰性生成,保证主程序可独立验证。
|
||||||
|
*
|
||||||
|
* @param string $appId 应用ID
|
||||||
|
* @return string 应用密钥;应用未安装或非插件应用时返回空字符串
|
||||||
|
*/
|
||||||
|
public static function appSecret(string $appId): string
|
||||||
|
{
|
||||||
|
$appId = trim($appId);
|
||||||
|
$config = self::loadInstalledConfig($appId);
|
||||||
|
if ($config === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$secret = trim((string)($config['app_secret'] ?? ''));
|
||||||
|
if ($secret !== '') {
|
||||||
|
return $secret;
|
||||||
|
}
|
||||||
|
// 首次需要时生成并持久化(按 appid 唯一)
|
||||||
|
$secret = Base::generatePassword(48);
|
||||||
|
$config['app_secret'] = $secret;
|
||||||
|
try {
|
||||||
|
file_put_contents(self::appstorePath("config/{$appId}/config.yml"), Yaml::dump($config, 4, 2));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
info('[app_badge] persist app_secret fail', ['appid' => $appId, 'error' => $e->getMessage()]);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return $secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析应用的菜单角标配置(菜单 key 列表与各自的可见范围)。
|
||||||
|
*
|
||||||
|
* 同时覆盖两类应用:
|
||||||
|
* - 插件应用:读取 docker/appstore/apps/{appid}/{version}/config.yml 的 menu_items
|
||||||
|
* - 自定义微应用:读取 microapp_menu 设置
|
||||||
|
*
|
||||||
|
* @param string $appId 应用ID
|
||||||
|
* @return array|null ['source'=>'plugin'|'custom', 'menus'=>[['key'=>string,'visible'=>array,'badge_clear_on_open'=>bool], ...]];应用不存在返回 null
|
||||||
|
*/
|
||||||
|
public static function appMenuConfig(string $appId): ?array
|
||||||
|
{
|
||||||
|
$appId = trim($appId);
|
||||||
|
if ($appId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 插件应用
|
||||||
|
$config = self::loadInstalledConfig($appId);
|
||||||
|
if ($config !== null) {
|
||||||
|
$version = trim((string)($config['install_version'] ?? ''));
|
||||||
|
return [
|
||||||
|
'source' => 'plugin',
|
||||||
|
'menus' => self::readPluginMenus($appId, $version),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// 自定义微应用
|
||||||
|
$apps = Base::setting('microapp_menu');
|
||||||
|
if (is_array($apps)) {
|
||||||
|
foreach ($apps as $app) {
|
||||||
|
if (!is_array($app) || trim((string)($app['id'] ?? '')) !== $appId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$appVisibleDefault = $app['visible_to'] ?? 'admin';
|
||||||
|
$menus = [];
|
||||||
|
foreach (($app['menu_items'] ?? []) as $menu) {
|
||||||
|
if (!is_array($menu)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$menus[] = self::mapMenuItem($menu, $appVisibleDefault);
|
||||||
|
}
|
||||||
|
return ['source' => 'custom', 'menus' => $menus];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取插件包 config.yml 的菜单配置。
|
||||||
|
*
|
||||||
|
* @param string $appId
|
||||||
|
* @param string $version 已安装版本
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private static function readPluginMenus(string $appId, string $version): array
|
||||||
|
{
|
||||||
|
$paths = [];
|
||||||
|
if ($version !== '') {
|
||||||
|
$paths[] = self::appstorePath("apps/{$appId}/{$version}/config.yml");
|
||||||
|
}
|
||||||
|
$paths[] = self::appstorePath("apps/{$appId}/config.yml");
|
||||||
|
$pkg = null;
|
||||||
|
foreach ($paths as $p) {
|
||||||
|
if (file_exists($p) && is_readable($p)) {
|
||||||
|
try {
|
||||||
|
$pkg = Yaml::parseFile($p);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pkg = null;
|
||||||
|
}
|
||||||
|
if (is_array($pkg)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$menus = [];
|
||||||
|
if (is_array($pkg) && !empty($pkg['menu_items']) && is_array($pkg['menu_items'])) {
|
||||||
|
$appVisibleDefault = $pkg['visible_to'] ?? 'all';
|
||||||
|
foreach ($pkg['menu_items'] as $menu) {
|
||||||
|
if (!is_array($menu)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$menus[] = self::mapMenuItem($menu, $appVisibleDefault);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($menus)) {
|
||||||
|
// 读不到包配置(权限/缺失)时退化为单一默认菜单,仍可对第一个菜单设角标
|
||||||
|
$menus[] = ['key' => '', 'visible' => ['all'], 'badge_clear_on_open' => false];
|
||||||
|
}
|
||||||
|
return $menus;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch user lifecycle hook to appstore (user_onboard/user_offboard/user_update).
|
* Dispatch user lifecycle hook to appstore (user_onboard/user_offboard/user_update).
|
||||||
*
|
*
|
||||||
@ -72,7 +240,7 @@ class Apps
|
|||||||
*/
|
*/
|
||||||
public static function dispatchUserHook(User $user, string $action, string $eventType = '', array $changedFields = []): void
|
public static function dispatchUserHook(User $user, string $action, string $eventType = '', array $changedFields = []): void
|
||||||
{
|
{
|
||||||
$appKey = env('APP_KEY', '');
|
$appKey = config('app.key') ?: '';
|
||||||
if (empty($appKey)) {
|
if (empty($appKey)) {
|
||||||
info('[appstore_hook] APP_KEY is empty, skip dispatchUserHook');
|
info('[appstore_hook] APP_KEY is empty, skip dispatchUserHook');
|
||||||
return;
|
return;
|
||||||
|
|||||||
329
app/Module/Badge.php
Normal file
329
app/Module/Badge.php
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Module;
|
||||||
|
|
||||||
|
use App\Exceptions\ApiException;
|
||||||
|
use App\Models\AppBadge;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Tasks\PushTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件 / 微应用菜单角标业务编排。
|
||||||
|
*
|
||||||
|
* 角标真值归插件(应用密钥写入),主程序仅作为存储与分发:
|
||||||
|
* - 绝对设置/清除 app_badges 行(仅存非清除态)
|
||||||
|
* - 通过 WebSocket(PushTask)向在线用户实时推送 appBadge 消息
|
||||||
|
* - 提供初始同步所需的用户角标快照
|
||||||
|
*/
|
||||||
|
class Badge
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 设置角标(应用密钥鉴权场景):校验密钥/菜单/可见性,绝对设置并实时推送。
|
||||||
|
*
|
||||||
|
* @param string $appId
|
||||||
|
* @param string $secret 请求携带的应用密钥
|
||||||
|
* @param mixed $userid 目标用户ID(单个或数组)
|
||||||
|
* @param string $menuKeyInput 请求中的 menu_key(可空,留空取第一个菜单)
|
||||||
|
* @param mixed $count 角标数字
|
||||||
|
* @param mixed $dot 是否红点
|
||||||
|
* @return array 响应数据
|
||||||
|
* @throws ApiException 参数/密钥/应用/菜单校验失败
|
||||||
|
*/
|
||||||
|
public static function set(string $appId, string $secret, $userid, string $menuKeyInput, $count, $dot): array
|
||||||
|
{
|
||||||
|
if ($appId === '') {
|
||||||
|
throw new ApiException('参数错误');
|
||||||
|
}
|
||||||
|
if ($secret === '') {
|
||||||
|
throw new ApiException('密钥无效');
|
||||||
|
}
|
||||||
|
$expect = Apps::appSecret($appId);
|
||||||
|
if ($expect === '' || !hash_equals($expect, $secret)) {
|
||||||
|
throw new ApiException('密钥无效');
|
||||||
|
}
|
||||||
|
$menu = self::resolveAppMenu($appId, $menuKeyInput);
|
||||||
|
$menuKey = (string)($menu['key'] ?? '');
|
||||||
|
$userids = self::normalizeUserids($userid);
|
||||||
|
if (empty($userids)) {
|
||||||
|
throw new ApiException('参数错误');
|
||||||
|
}
|
||||||
|
// 仅保留对该应用菜单有可见权限的用户
|
||||||
|
$userids = self::filterVisibleUserids($menu, $userids);
|
||||||
|
if (empty($userids)) {
|
||||||
|
return ['appid' => $appId, 'menu_key' => $menuKey, 'affected' => 0];
|
||||||
|
}
|
||||||
|
$count = max(0, intval($count));
|
||||||
|
$dot = filter_var($dot, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
self::applySet($appId, $menuKey, $userids, $count, $dot);
|
||||||
|
self::push($appId, $menuKey, $userids, $count, $dot);
|
||||||
|
return [
|
||||||
|
'appid' => $appId,
|
||||||
|
'menu_key' => $menuKey,
|
||||||
|
'count' => $count,
|
||||||
|
'dot' => $dot,
|
||||||
|
'affected' => count($userids),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定用户在某应用某菜单的角标(用户 token 鉴权场景),并推送多端一致。
|
||||||
|
*
|
||||||
|
* @param int $userid 当前用户ID
|
||||||
|
* @param string $appId
|
||||||
|
* @param string $menuKeyInput 请求中的 menu_key(可空)
|
||||||
|
* @return array 响应数据
|
||||||
|
* @throws ApiException 参数/应用/菜单校验失败
|
||||||
|
*/
|
||||||
|
public static function clearForUser(int $userid, string $appId, string $menuKeyInput): array
|
||||||
|
{
|
||||||
|
if ($userid <= 0) {
|
||||||
|
throw new ApiException('参数错误');
|
||||||
|
}
|
||||||
|
if ($appId === '') {
|
||||||
|
throw new ApiException('参数错误');
|
||||||
|
}
|
||||||
|
$menu = self::resolveAppMenu($appId, $menuKeyInput);
|
||||||
|
$menuKey = (string)($menu['key'] ?? '');
|
||||||
|
AppBadge::whereAppId($appId)->whereMenuKey($menuKey)->whereUserid($userid)->delete();
|
||||||
|
// 推送给该用户的所有在线端,保证多端一致
|
||||||
|
self::push($appId, $menuKey, [$userid], 0, false);
|
||||||
|
return [
|
||||||
|
'appid' => $appId,
|
||||||
|
'menu_key' => $menuKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析应用菜单配置并定位目标菜单。
|
||||||
|
*
|
||||||
|
* @param string $appId
|
||||||
|
* @param string $menuKeyInput
|
||||||
|
* @return array 命中的菜单配置
|
||||||
|
* @throws ApiException 应用未安装或菜单不存在
|
||||||
|
*/
|
||||||
|
private static function resolveAppMenu(string $appId, string $menuKeyInput): array
|
||||||
|
{
|
||||||
|
$config = Apps::appMenuConfig($appId);
|
||||||
|
if ($config === null) {
|
||||||
|
throw new ApiException('应用未安装');
|
||||||
|
}
|
||||||
|
$menu = self::resolveMenu($config['menus'], $menuKeyInput);
|
||||||
|
if ($menu === null) {
|
||||||
|
throw new ApiException('菜单不存在');
|
||||||
|
}
|
||||||
|
return $menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化目标用户ID:单值/数组 -> 去重去零的整型数组。
|
||||||
|
*
|
||||||
|
* @param mixed $userid
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function normalizeUserids($userid): array
|
||||||
|
{
|
||||||
|
if (is_string($userid) || is_numeric($userid)) {
|
||||||
|
$userid = [$userid];
|
||||||
|
}
|
||||||
|
if (!is_array($userid)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return self::intIds($userid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数组 -> 去重去零的整型数组。
|
||||||
|
*
|
||||||
|
* @param array $ids
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function intIds(array $ids): array
|
||||||
|
{
|
||||||
|
return array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析目标菜单:menu_key 为空时取第一个菜单;否则必须命中已声明的菜单 key。
|
||||||
|
*
|
||||||
|
* @param array $menus appMenuConfig 返回的 menus
|
||||||
|
* @param string $menuKey 请求中的 menu_key(可空)
|
||||||
|
* @return array|null 命中的菜单配置;非法 menu_key 返回 null
|
||||||
|
*/
|
||||||
|
private static function resolveMenu(array $menus, string $menuKey): ?array
|
||||||
|
{
|
||||||
|
if ($menuKey === '') {
|
||||||
|
return $menus[0] ?? ['key' => '', 'visible' => ['all'], 'badge_clear_on_open' => false];
|
||||||
|
}
|
||||||
|
foreach ($menus as $menu) {
|
||||||
|
if (($menu['key'] ?? '') === $menuKey) {
|
||||||
|
return $menu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按菜单可见范围过滤目标用户,仅保留对该应用菜单有权限的用户。
|
||||||
|
*
|
||||||
|
* @param array $menu 命中的菜单配置(含 visible)
|
||||||
|
* @param int[] $userids
|
||||||
|
* @return int[] 允许的用户ID
|
||||||
|
*/
|
||||||
|
private static function filterVisibleUserids(array $menu, array $userids): array
|
||||||
|
{
|
||||||
|
if (empty($userids)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$visible = $menu['visible'] ?? ['all'];
|
||||||
|
if (in_array('all', $visible)) {
|
||||||
|
return $userids;
|
||||||
|
}
|
||||||
|
$allowed = [];
|
||||||
|
$users = User::whereIn('userid', $userids)->get(['userid', 'identity']);
|
||||||
|
foreach ($users as $user) {
|
||||||
|
if (Setting::isCustomMicroVisibleTo($visible, $user->isAdmin(), (int)$user->userid)) {
|
||||||
|
$allowed[] = (int)$user->userid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绝对设置角标(幂等)。count=0 且 dot=false 即清除(删行)。
|
||||||
|
*
|
||||||
|
* @param string $appId
|
||||||
|
* @param string $menuKey
|
||||||
|
* @param int[] $userids
|
||||||
|
* @param int $count
|
||||||
|
* @param bool $dot
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function applySet(string $appId, string $menuKey, array $userids, int $count, bool $dot): void
|
||||||
|
{
|
||||||
|
if (empty($userids)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 清除态:一条 whereIn 删除
|
||||||
|
if ($count === 0 && !$dot) {
|
||||||
|
AppBadge::whereAppId($appId)->whereMenuKey($menuKey)->whereIn('userid', $userids)->delete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 非清除态:依赖唯一键 (app_id,menu_key,userid) 批量 upsert
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$rows = array_map(fn($uid) => [
|
||||||
|
'app_id' => $appId,
|
||||||
|
'menu_key' => $menuKey,
|
||||||
|
'userid' => (int)$uid,
|
||||||
|
'count' => $count,
|
||||||
|
'dot' => $dot,
|
||||||
|
'updated_at' => $now,
|
||||||
|
], $userids);
|
||||||
|
AppBadge::upsert($rows, ['app_id', 'menu_key', 'userid'], ['count', 'dot', 'updated_at']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除某应用的全部角标(应用卸载时)。
|
||||||
|
*
|
||||||
|
* @param string $appId
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function clearByApp(string $appId): void
|
||||||
|
{
|
||||||
|
$appId = trim($appId);
|
||||||
|
if ($appId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AppBadge::whereAppId($appId)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除某用户的全部角标(用户离职时)。
|
||||||
|
*
|
||||||
|
* @param int $userid
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function clearByUser(int $userid): void
|
||||||
|
{
|
||||||
|
if ($userid <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AppBadge::whereUserid($userid)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户当前全部角标快照,用于前端初始同步。
|
||||||
|
* 过滤掉应用已不存在(卸载 / 自定义微应用被删除)的行,避免父级聚合统计残留数据。
|
||||||
|
*
|
||||||
|
* @param int $userid
|
||||||
|
* @return array app_id => menu_key => ['count'=>int,'dot'=>bool]
|
||||||
|
*/
|
||||||
|
public static function userBadges(int $userid): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
if ($userid <= 0) {
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
$rows = AppBadge::whereUserid($userid)->get(['app_id', 'menu_key', 'count', 'dot']);
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
// 自定义微应用 id 集合一次性收,避免每行重复 foreach
|
||||||
|
$customIds = [];
|
||||||
|
$customApps = Base::setting('microapp_menu');
|
||||||
|
if (is_array($customApps)) {
|
||||||
|
foreach ($customApps as $app) {
|
||||||
|
if (is_array($app) && !empty($app['id'])) {
|
||||||
|
$customIds[(string)$app['id']] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 按 app_id 缓存判定结果:插件应用走 Apps::isInstalled(单 yaml + 请求级缓存),
|
||||||
|
// 自定义微应用查 set,避免每行都读 yaml / 遍历 setting。
|
||||||
|
$exists = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$appId = (string)$row->app_id;
|
||||||
|
if (!isset($exists[$appId])) {
|
||||||
|
$exists[$appId] = isset($customIds[$appId]) || Apps::isInstalled($appId);
|
||||||
|
}
|
||||||
|
if (!$exists[$appId]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$appId][$row->menu_key] = [
|
||||||
|
'count' => (int)$row->count,
|
||||||
|
'dot' => (bool)$row->dot,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向在线用户实时推送角标变更(仅投递,不补发离线)。
|
||||||
|
*
|
||||||
|
* @param string $appId
|
||||||
|
* @param string $menuKey
|
||||||
|
* @param int[] $userids
|
||||||
|
* @param int $count
|
||||||
|
* @param bool $dot
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function push(string $appId, string $menuKey, array $userids, int $count, bool $dot): void
|
||||||
|
{
|
||||||
|
$userids = self::intIds($userids);
|
||||||
|
if (empty($userids)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PushTask::push([
|
||||||
|
'userid' => $userids,
|
||||||
|
'msg' => [
|
||||||
|
'type' => 'appBadge',
|
||||||
|
'data' => [
|
||||||
|
'appid' => $appId,
|
||||||
|
'menu_key' => $menuKey,
|
||||||
|
'count' => $count,
|
||||||
|
'dot' => $dot,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,7 +14,7 @@ use Overtrue\Pinyin\Pinyin;
|
|||||||
use Redirect;
|
use Redirect;
|
||||||
use Request;
|
use Request;
|
||||||
use Storage;
|
use Storage;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||||
use Symfony\Component\HttpFoundation\File\File;
|
use Symfony\Component\HttpFoundation\File\File;
|
||||||
use Validator;
|
use Validator;
|
||||||
@ -848,6 +848,13 @@ class Base
|
|||||||
*/
|
*/
|
||||||
public static function getSchemeAndHost()
|
public static function getSchemeAndHost()
|
||||||
{
|
{
|
||||||
|
// 优先用当前请求的协议+主机:getScheme() 会经 TrustProxies 采信 X-Forwarded-Proto,
|
||||||
|
// 从而正确识别 https;host 取自 Host 头(不信 X-Forwarded-Host,避免 Host 注入)
|
||||||
|
$request = request();
|
||||||
|
if ($request instanceof \Illuminate\Http\Request && $request->getHttpHost()) {
|
||||||
|
return $request->getSchemeAndHttpHost();
|
||||||
|
}
|
||||||
|
// 非请求上下文(Task/命令行等)的兜底
|
||||||
$scheme = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == '443' ? 'https://' : 'http://';
|
$scheme = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == '443' ? 'https://' : 'http://';
|
||||||
return $scheme.($_SERVER['HTTP_HOST'] ?? '');
|
return $scheme.($_SERVER['HTTP_HOST'] ?? '');
|
||||||
}
|
}
|
||||||
@ -2067,8 +2074,20 @@ class Base
|
|||||||
*/
|
*/
|
||||||
public static function upload($param)
|
public static function upload($param)
|
||||||
{
|
{
|
||||||
|
// 可选 key 默认值,下游直接访问不会 undefined index
|
||||||
|
$param += [
|
||||||
|
'chmod' => 0644,
|
||||||
|
'saveName' => null,
|
||||||
|
'scale' => null,
|
||||||
|
'size' => 0,
|
||||||
|
'fileName' => null,
|
||||||
|
'quality' => null,
|
||||||
|
'autoThumb' => null,
|
||||||
|
'convertVideo' => null,
|
||||||
|
'compressVideo' => null,
|
||||||
|
];
|
||||||
$file = $param['file'];
|
$file = $param['file'];
|
||||||
$chmod = $param['chmod'] ?: 0644;
|
$chmod = $param['chmod'];
|
||||||
if (empty($file)) {
|
if (empty($file)) {
|
||||||
return Base::retError("您没有选择要上传的文件");
|
return Base::retError("您没有选择要上传的文件");
|
||||||
}
|
}
|
||||||
@ -2123,6 +2142,9 @@ class Base
|
|||||||
$limitSize = intval($param['size']);
|
$limitSize = intval($param['size']);
|
||||||
if ($limitSize <= 0) {
|
if ($limitSize <= 0) {
|
||||||
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
|
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
|
||||||
|
if ($fileUploadLimit <= 0) {
|
||||||
|
$fileUploadLimit = 1024;
|
||||||
|
}
|
||||||
$limitSize = $fileUploadLimit * 1024;
|
$limitSize = $fileUploadLimit * 1024;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -2310,6 +2332,27 @@ class Base
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把本地文件包装成 UploadedFile(test=true) 转给 Base::upload,复用全套上传逻辑。
|
||||||
|
* @param array $param path_local + name 为本方法特有,其余与 Base::upload 一致
|
||||||
|
*/
|
||||||
|
public static function uploadFromPath(array $param)
|
||||||
|
{
|
||||||
|
$localPath = $param['path_local'] ?? '';
|
||||||
|
$name = $param['name'] ?? '';
|
||||||
|
if (!$localPath || !is_file($localPath)) {
|
||||||
|
return Base::retError('源文件不存在');
|
||||||
|
}
|
||||||
|
if (!$name) {
|
||||||
|
$name = basename($localPath);
|
||||||
|
}
|
||||||
|
unset($param['path_local'], $param['name']);
|
||||||
|
// test=true → UploadedFile::move 走 rename(),绕开 move_uploaded_file 的 is_uploaded_file 校验
|
||||||
|
$param['file'] = new \Illuminate\Http\UploadedFile($localPath, $name, null, null, true);
|
||||||
|
$param['fileName'] = $param['fileName'] ?? $name;
|
||||||
|
return self::upload($param);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件移动
|
* 上传文件移动
|
||||||
* @param array $uploadResult
|
* @param array $uploadResult
|
||||||
@ -2582,6 +2625,23 @@ class Base
|
|||||||
return $array;
|
return $array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 zip 压缩包并添加文件,条目名取文件 basename(等价旧 Madzipper::make()->add()->close())
|
||||||
|
* @param string $zipPath 压缩包路径
|
||||||
|
* @param string|array $files 要添加的文件路径
|
||||||
|
*/
|
||||||
|
public static function zipAddFiles($zipPath, $files)
|
||||||
|
{
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
if ($zip->open($zipPath, \ZipArchive::CREATE) !== true) {
|
||||||
|
throw new \RuntimeException("Unable to open zip file: " . $zipPath);
|
||||||
|
}
|
||||||
|
foreach ((array)$files as $file) {
|
||||||
|
$zip->addFile($file, basename($file));
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取中文字符拼音首字母
|
* 获取中文字符拼音首字母
|
||||||
* @param $str
|
* @param $str
|
||||||
@ -2597,8 +2657,7 @@ class Base
|
|||||||
return '#';
|
return '#';
|
||||||
}
|
}
|
||||||
if (!preg_match("/^[a-zA-Z]$/", $first)) {
|
if (!preg_match("/^[a-zA-Z]$/", $first)) {
|
||||||
$pinyin = new Pinyin();
|
$first = Pinyin::abbr($first, true)->join('');
|
||||||
$first = $pinyin->abbr($first, '', PINYIN_NAME);
|
|
||||||
}
|
}
|
||||||
return $first ? strtoupper($first) : '#';
|
return $first ? strtoupper($first) : '#';
|
||||||
}
|
}
|
||||||
@ -2616,8 +2675,7 @@ class Base
|
|||||||
}
|
}
|
||||||
if (!preg_match("/^[a-zA-Z0-9_.]+$/", $str)) {
|
if (!preg_match("/^[a-zA-Z0-9_.]+$/", $str)) {
|
||||||
$str = Cache::rememberForever("cn2pinyin:" . md5($str . '_' . $delim), function () use ($delim, $str) {
|
$str = Cache::rememberForever("cn2pinyin:" . md5($str . '_' . $delim), function () use ($delim, $str) {
|
||||||
$pinyin = new Pinyin();
|
return Pinyin::permalink($str, $delim);
|
||||||
return $pinyin->permalink($str, $delim);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return $str;
|
return $str;
|
||||||
@ -2818,14 +2876,17 @@ class Base
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 字节转格式
|
* 字节转格式
|
||||||
* @param $bytes
|
* @param int|float $bytes
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function readableBytes($bytes)
|
public static function readableBytes(int|float $bytes): string
|
||||||
{
|
{
|
||||||
$i = floor(log($bytes) / log(1024));
|
if ($bytes <= 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
$i = (int) floor(log($bytes) / log(1024));
|
||||||
$sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
$sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
return sprintf('%.02F', $bytes / pow(1024, $i)) * 1 . ' ' . $sizes[$i];
|
return (string) ((float) sprintf('%.02F', $bytes / pow(1024, $i))) . ' ' . $sizes[$i];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -2868,9 +2929,15 @@ class Base
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* DownloadFileResponse 下载文件
|
* DownloadFileResponse 下载文件
|
||||||
|
*
|
||||||
|
* 返回 Symfony BinaryFileResponse,在 LaravelS/Swoole 环境下由 StaticResponse 走原生
|
||||||
|
* sendfile() 发送——OS 级零拷贝、不占用 PHP 内存,可支持任意大小文件(如几百 MB 的大文件)。
|
||||||
|
* 切勿改回 StreamedResponse:它会被 LaravelS 用 ob_start()/ob_get_clean() 把整个响应体
|
||||||
|
* 缓冲进 PHP 内存,大文件会撞 memory_limit 导致下载失败。
|
||||||
|
*
|
||||||
* @param File|\SplFileInfo|string $file 文件对象或路径
|
* @param File|\SplFileInfo|string $file 文件对象或路径
|
||||||
* @param string|null $name 下载文件名
|
* @param string|null $name 下载文件名
|
||||||
* @return StreamedResponse
|
* @return BinaryFileResponse
|
||||||
*/
|
*/
|
||||||
public static function DownloadFileResponse($file, $name = null)
|
public static function DownloadFileResponse($file, $name = null)
|
||||||
{
|
{
|
||||||
@ -2889,12 +2956,6 @@ class Base
|
|||||||
throw new FileException('File must be readable and exist.');
|
throw new FileException('File must be readable and exist.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取文件信息
|
|
||||||
$size = $file->getSize();
|
|
||||||
if ($size === false || $size < 0) {
|
|
||||||
throw new FileException('Unable to determine file size.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理文件名
|
// 处理文件名
|
||||||
if (empty($name)) {
|
if (empty($name)) {
|
||||||
$name = basename($file->getPathname());
|
$name = basename($file->getPathname());
|
||||||
@ -2912,83 +2973,27 @@ class Base
|
|||||||
$mimeType = 'application/octet-stream';
|
$mimeType = 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 Range 请求
|
// BinaryFileResponse:autoEtag=false 避免对大文件做 md5/sha1 全文件哈希,autoLastModified=true 取 mtime(开销极小)
|
||||||
$start = 0;
|
$response = new BinaryFileResponse($file, 200, [], true, null, false, true);
|
||||||
$end = $size - 1;
|
$response->headers->set('Content-Type', $mimeType);
|
||||||
$length = $size;
|
$response->headers->set('Cache-Control', 'private, no-transform, no-store, must-revalidate, max-age=0');
|
||||||
$isRangeRequest = false;
|
// filename 兜底为纯 ASCII,filename* 用 UTF-8 编码,兼容含中文/特殊字符的文件名
|
||||||
|
$asciiName = preg_replace('/[^\x20-\x7e]/', '_', $name);
|
||||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
$response->headers->set('Content-Disposition', sprintf(
|
||||||
$range = str_replace('bytes=', '', $_SERVER['HTTP_RANGE']);
|
|
||||||
if (preg_match('/^(\d+)-(\d*)$/', $range, $matches)) {
|
|
||||||
$start = intval($matches[1]);
|
|
||||||
$end = !empty($matches[2]) ? intval($matches[2]) : $size - 1;
|
|
||||||
|
|
||||||
// 验证范围的有效性
|
|
||||||
if ($start >= 0 && $end < $size && $start <= $end) {
|
|
||||||
$length = $end - $start + 1;
|
|
||||||
$isRangeRequest = true;
|
|
||||||
} else {
|
|
||||||
$start = 0;
|
|
||||||
$end = $size - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置基本响应头
|
|
||||||
$headers = [
|
|
||||||
'Content-Type' => $mimeType,
|
|
||||||
'Content-Disposition' => sprintf(
|
|
||||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||||
$name,
|
$asciiName,
|
||||||
rawurlencode($name)
|
rawurlencode($name)
|
||||||
),
|
));
|
||||||
'Accept-Ranges' => 'bytes',
|
|
||||||
'Cache-Control' => 'private, no-transform, no-store, must-revalidate, max-age=0',
|
|
||||||
'Content-Length' => $length,
|
|
||||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $file->getMTime()) . ' GMT',
|
|
||||||
'ETag' => sprintf('"%s"', md5_file($file->getPathname()))
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($isRangeRequest) {
|
// LaravelS/Swoole 下 StaticResponse 用 sendfile() 整文件发送,不支持分段;
|
||||||
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
|
// 若放任 Symfony 处理 Range 会返回 206 头却仍发送完整文件,导致内容错位/损坏。
|
||||||
$statusCode = 206;
|
// 故在 Swoole 环境下移除 Range 请求头,始终以 200 返回完整文件。
|
||||||
} else {
|
if (app()->bound('swoole')) {
|
||||||
$statusCode = 200;
|
Request::instance()->headers->remove('Range');
|
||||||
|
$response->headers->set('Accept-Ranges', 'none');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建流式响应
|
return $response;
|
||||||
return new StreamedResponse(
|
|
||||||
function () use ($file, $start, $length) {
|
|
||||||
$handle = fopen($file->getPathname(), 'rb');
|
|
||||||
if ($handle === false) {
|
|
||||||
throw new FileException('Cannot open file for reading');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fseek($handle, $start) === -1) {
|
|
||||||
fclose($handle);
|
|
||||||
throw new FileException('Cannot seek to position ' . $start);
|
|
||||||
}
|
|
||||||
|
|
||||||
$remaining = $length;
|
|
||||||
$bufferSize = 8192; // 8KB chunks
|
|
||||||
|
|
||||||
while ($remaining > 0 && !feof($handle)) {
|
|
||||||
$readSize = min($bufferSize, $remaining);
|
|
||||||
$buffer = fread($handle, $readSize);
|
|
||||||
if ($buffer === false) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
echo $buffer;
|
|
||||||
flush();
|
|
||||||
$remaining -= strlen($buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
fclose($handle);
|
|
||||||
},
|
|
||||||
$statusCode,
|
|
||||||
$headers
|
|
||||||
);
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Log::error('File download failed', [
|
\Log::error('File download failed', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
|
|||||||
511
app/Module/ChunkUpload.php
Normal file
511
app/Module/ChunkUpload.php
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Module;
|
||||||
|
|
||||||
|
use App\Exceptions\ApiException;
|
||||||
|
use App\Models\File as FileModel;
|
||||||
|
use App\Models\FileContent;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WebSocketDialog;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分片上传核心:状态机与磁盘/Redis 调度。
|
||||||
|
*
|
||||||
|
* 流程:start → receive × N → merge → (scene dispatcher) → cleanup
|
||||||
|
*
|
||||||
|
* Redis key:
|
||||||
|
* upload:{upload_id} → JSON 元数据 (TTL 24h)
|
||||||
|
* upload:{upload_id}:chunks → SET 已收分片 index (TTL 24h)
|
||||||
|
* upload:hash:{userid}:{hash} → upload_id 反查(续传 / 同 hash 复用) (TTL 24h)
|
||||||
|
*
|
||||||
|
* 磁盘:
|
||||||
|
* uploads/tmp/chunks/{userid}/{upload_id}/{index}
|
||||||
|
*/
|
||||||
|
class ChunkUpload
|
||||||
|
{
|
||||||
|
/** 单个分片大小(5MB)。注意:要小于 Swoole package_max_length 1G */
|
||||||
|
const CHUNK_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** 状态/反查索引 TTL(秒):24h */
|
||||||
|
const STATE_TTL = 86400;
|
||||||
|
|
||||||
|
/** 单文件硬上限(KB):系统设置之外的兜底保护,10G */
|
||||||
|
const MAX_FILE_KB = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** 支持的 scene 枚举 */
|
||||||
|
const SCENES = ['file_cabinet', 'dialog_file', 'image', 'generic_file'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动上传。
|
||||||
|
* - 同用户同 hash 命中 files 表 → 秒传
|
||||||
|
* - 同用户同 hash 命中 upload 反查 → 续传
|
||||||
|
* - 否则新建 upload_id
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param array $param [hash, size(B), name, scene, scene_params(array)]
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function start(User $user, array $param): array
|
||||||
|
{
|
||||||
|
$hash = strtolower(trim($param['hash'] ?? ''));
|
||||||
|
$size = intval($param['size'] ?? 0);
|
||||||
|
$name = trim($param['name'] ?? '');
|
||||||
|
$scene = trim($param['scene'] ?? '');
|
||||||
|
$sceneParams = $param['scene_params'] ?? [];
|
||||||
|
if (!is_array($sceneParams)) {
|
||||||
|
$sceneParams = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($hash) !== 32) {
|
||||||
|
return Base::retError('文件 hash 格式错误');
|
||||||
|
}
|
||||||
|
if ($size <= 0) {
|
||||||
|
return Base::retError('文件大小无效');
|
||||||
|
}
|
||||||
|
if (intval(ceil($size / 1024)) > self::MAX_FILE_KB) {
|
||||||
|
return Base::retError('文件超过系统支持的最大尺寸');
|
||||||
|
}
|
||||||
|
// init 时拦截系统配置上限,避免传完分片才在 merge 阶段被 Base::upload 拒绝
|
||||||
|
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
|
||||||
|
if ($fileUploadLimit <= 0) {
|
||||||
|
$fileUploadLimit = 1024;
|
||||||
|
}
|
||||||
|
if ($size > $fileUploadLimit * 1024 * 1024) {
|
||||||
|
return Base::retError('文件大小超限,最大限制:' . $fileUploadLimit . 'MB');
|
||||||
|
}
|
||||||
|
if ($name === '') {
|
||||||
|
return Base::retError('文件名不能为空');
|
||||||
|
}
|
||||||
|
if (!in_array($scene, self::SCENES, true)) {
|
||||||
|
return Base::retError('不支持的上传场景');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 秒传:同用户已上传过同 hash 文件 → 直接复用入库
|
||||||
|
$hit = self::trySecondPass($user, $scene, $hash, $name, $sceneParams);
|
||||||
|
if ($hit !== null) {
|
||||||
|
return Base::retSuccess('success', $hit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 续传:同用户同 hash 有未完成上传
|
||||||
|
$reuseKey = self::keyHashIndex($user->userid, $hash);
|
||||||
|
$existingId = Redis::get($reuseKey);
|
||||||
|
if ($existingId) {
|
||||||
|
$meta = self::loadMeta($existingId);
|
||||||
|
if ($meta && $meta['userid'] === $user->userid && $meta['hash'] === $hash) {
|
||||||
|
return Base::retSuccess('success', self::sessionView($existingId, $meta));
|
||||||
|
}
|
||||||
|
// 反查指向了已失效的 upload_id,清掉
|
||||||
|
Redis::del($reuseKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 新建
|
||||||
|
$uploadId = Base::generatePassword(32);
|
||||||
|
$chunkCount = intval(ceil($size / self::CHUNK_SIZE));
|
||||||
|
$meta = [
|
||||||
|
'hash' => $hash,
|
||||||
|
'size' => $size,
|
||||||
|
'name' => $name,
|
||||||
|
'scene' => $scene,
|
||||||
|
'scene_params' => $sceneParams,
|
||||||
|
'userid' => intval($user->userid),
|
||||||
|
'chunk_size' => self::CHUNK_SIZE,
|
||||||
|
'chunk_count' => $chunkCount,
|
||||||
|
'created_at' => time(),
|
||||||
|
];
|
||||||
|
Redis::setex(self::keyMeta($uploadId), self::STATE_TTL, json_encode($meta, JSON_UNESCAPED_UNICODE));
|
||||||
|
Redis::setex($reuseKey, self::STATE_TTL, $uploadId);
|
||||||
|
Base::makeDir(self::chunkDir($user->userid, $uploadId));
|
||||||
|
|
||||||
|
return Base::retSuccess('success', self::sessionView($uploadId, $meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收一个分片。
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param string $uploadId
|
||||||
|
* @param int $index 分片序号(0-based)
|
||||||
|
* @param UploadedFile|null $blob
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function receive(User $user, string $uploadId, int $index, $blob): array
|
||||||
|
{
|
||||||
|
$meta = self::loadMeta($uploadId);
|
||||||
|
if (!$meta) {
|
||||||
|
return Base::retError('上传会话不存在或已过期');
|
||||||
|
}
|
||||||
|
if ($meta['userid'] !== intval($user->userid)) {
|
||||||
|
return Base::retError('上传会话归属错误');
|
||||||
|
}
|
||||||
|
if ($index < 0 || $index >= $meta['chunk_count']) {
|
||||||
|
return Base::retError('分片序号超出范围');
|
||||||
|
}
|
||||||
|
if (!$blob || !$blob->isValid()) {
|
||||||
|
return Base::retError('分片数据无效');
|
||||||
|
}
|
||||||
|
// 最后一片可能小于 CHUNK_SIZE,其余必须等于
|
||||||
|
$isLast = $index === $meta['chunk_count'] - 1;
|
||||||
|
$chunkSize = $blob->getSize();
|
||||||
|
if (!$isLast && $chunkSize !== self::CHUNK_SIZE) {
|
||||||
|
return Base::retError('分片大小不符合预期');
|
||||||
|
}
|
||||||
|
if ($isLast) {
|
||||||
|
$expectLast = $meta['size'] - self::CHUNK_SIZE * ($meta['chunk_count'] - 1);
|
||||||
|
if ($chunkSize !== $expectLast) {
|
||||||
|
return Base::retError('末尾分片大小不符合预期');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$dir = self::chunkDir($user->userid, $uploadId);
|
||||||
|
Base::makeDir($dir);
|
||||||
|
$blob->move($dir, (string)$index);
|
||||||
|
// 记录已收 + 续期三个相关 key
|
||||||
|
Redis::sadd(self::keyChunks($uploadId), $index);
|
||||||
|
Redis::expire(self::keyChunks($uploadId), self::STATE_TTL);
|
||||||
|
Redis::expire(self::keyMeta($uploadId), self::STATE_TTL);
|
||||||
|
Redis::expire(self::keyHashIndex($user->userid, $meta['hash']), self::STATE_TTL);
|
||||||
|
|
||||||
|
return Base::retSuccess('success', [
|
||||||
|
'upload_id' => $uploadId,
|
||||||
|
'received' => self::receivedList($uploadId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并分片并入库。需要在 Lock 内调用。
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param string $uploadId
|
||||||
|
* @return array scene 入库返回结构(与 retSuccess/retError 对齐)
|
||||||
|
*/
|
||||||
|
public static function merge(User $user, string $uploadId): array
|
||||||
|
{
|
||||||
|
$meta = self::loadMeta($uploadId);
|
||||||
|
if (!$meta) {
|
||||||
|
return Base::retError('上传会话不存在或已过期');
|
||||||
|
}
|
||||||
|
if ($meta['userid'] !== intval($user->userid)) {
|
||||||
|
return Base::retError('上传会话归属错误');
|
||||||
|
}
|
||||||
|
$received = self::receivedList($uploadId);
|
||||||
|
if (count($received) !== $meta['chunk_count']) {
|
||||||
|
return Base::retError('分片不完整,无法合并');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Lock::withLock("upload:merge:{$uploadId}", function () use ($user, $uploadId, $meta) {
|
||||||
|
$dir = self::chunkDir($user->userid, $uploadId);
|
||||||
|
$mergedPath = $dir . '/merged.' . substr($meta['hash'], 0, 8);
|
||||||
|
$writeFp = @fopen($mergedPath, 'wb');
|
||||||
|
if (!$writeFp) {
|
||||||
|
return Base::retError('无法创建合并文件');
|
||||||
|
}
|
||||||
|
// 拼接与 md5 同步进行:一遍磁盘读完成"写文件 + 算 hash"
|
||||||
|
$hashCtx = hash_init('md5');
|
||||||
|
try {
|
||||||
|
for ($i = 0; $i < $meta['chunk_count']; $i++) {
|
||||||
|
$partPath = $dir . '/' . $i;
|
||||||
|
$readFp = @fopen($partPath, 'rb');
|
||||||
|
if (!$readFp) {
|
||||||
|
return Base::retError("分片读取失败:{$i}");
|
||||||
|
}
|
||||||
|
while (!feof($readFp)) {
|
||||||
|
$buf = fread($readFp, 1024 * 1024);
|
||||||
|
if ($buf === false) {
|
||||||
|
fclose($readFp);
|
||||||
|
return Base::retError("分片读取失败:{$i}");
|
||||||
|
}
|
||||||
|
fwrite($writeFp, $buf);
|
||||||
|
hash_update($hashCtx, $buf);
|
||||||
|
}
|
||||||
|
fclose($readFp);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($writeFp);
|
||||||
|
}
|
||||||
|
$actualHash = hash_final($hashCtx);
|
||||||
|
if ($actualHash !== $meta['hash']) {
|
||||||
|
@unlink($mergedPath);
|
||||||
|
return Base::retError('文件校验失败,请重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 scene 入库
|
||||||
|
$result = self::dispatch($user, $meta, $mergedPath);
|
||||||
|
|
||||||
|
// 清理(无论成功失败都清,失败用户重新启 upload)
|
||||||
|
self::cleanup($user->userid, $uploadId, $meta['hash']);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}, 60000, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户主动取消:校验归属后清理。会话不存在或归属错误一律静默成功,前端取消按钮不需要分支处理。
|
||||||
|
*/
|
||||||
|
public static function cancelByUser(User $user, string $uploadId): void
|
||||||
|
{
|
||||||
|
$meta = self::loadMeta($uploadId);
|
||||||
|
if (!$meta || intval($meta['userid'] ?? 0) !== $user->userid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::cleanup($user->userid, $uploadId, $meta['hash'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理一个 upload_id 的所有状态。
|
||||||
|
*/
|
||||||
|
public static function cleanup(int $userid, string $uploadId, string $hash = ''): void
|
||||||
|
{
|
||||||
|
Redis::del(self::keyMeta($uploadId));
|
||||||
|
Redis::del(self::keyChunks($uploadId));
|
||||||
|
if ($hash) {
|
||||||
|
Redis::del(self::keyHashIndex($userid, $hash));
|
||||||
|
}
|
||||||
|
$dir = self::chunkDir($userid, $uploadId);
|
||||||
|
if (is_dir($dir)) {
|
||||||
|
Base::deleteDirAndFile($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== scene dispatcher =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把合并后的本地文件交给对应 scene 入库。
|
||||||
|
* 返回结构对齐各 scene 老接口的 retSuccess。
|
||||||
|
*/
|
||||||
|
protected static function dispatch(User $user, array $meta, string $mergedPath): array
|
||||||
|
{
|
||||||
|
$scene = $meta['scene'];
|
||||||
|
$name = $meta['name'];
|
||||||
|
$hash = $meta['hash'];
|
||||||
|
$params = $meta['scene_params'] ?? [];
|
||||||
|
|
||||||
|
switch ($scene) {
|
||||||
|
case 'file_cabinet':
|
||||||
|
$pid = intval($params['pid'] ?? 0);
|
||||||
|
$webkitRelativePath = strval($params['webkit_relative_path'] ?? $name);
|
||||||
|
$overwrite = boolval($params['overwrite'] ?? false);
|
||||||
|
// pid 锁避免与并发上传的 handleDuplicateName / 中间目录创建竞态
|
||||||
|
try {
|
||||||
|
return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite) {
|
||||||
|
$result = (new FileModel)->contentUploadFromPath($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite);
|
||||||
|
$outName = $result['data']['name'] ?? $name;
|
||||||
|
return Base::retSuccess($outName . ' 上传成功', $result['addItem']);
|
||||||
|
}, 120000, 120000);
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
return Base::retError($e->getMessage());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
|
||||||
|
return Base::retError('上传繁忙,请稍后再试');
|
||||||
|
}
|
||||||
|
return Base::retError($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'image':
|
||||||
|
// 头像 / 系统图片 / 编辑器粘贴图片,对齐 system/imgupload
|
||||||
|
$width = intval($params['width'] ?? 0);
|
||||||
|
$height = intval($params['height'] ?? 0);
|
||||||
|
$whcut = strval($params['whcut'] ?? 'percentage');
|
||||||
|
$whcut = match ($whcut) {
|
||||||
|
'1' => 'cover',
|
||||||
|
'0' => 'contain',
|
||||||
|
'cover', 'contain' => $whcut,
|
||||||
|
default => 'percentage',
|
||||||
|
};
|
||||||
|
$scale = [$width ?: 2160, $height ?: 4160, $whcut];
|
||||||
|
$imagePath = "uploads/user/picture/" . $user->userid . "/" . date("Ym") . "/";
|
||||||
|
$data = Base::uploadFromPath([
|
||||||
|
"path_local" => $mergedPath,
|
||||||
|
"name" => $name,
|
||||||
|
"type" => 'image',
|
||||||
|
"path" => $imagePath,
|
||||||
|
"scale" => $scale,
|
||||||
|
"quality" => true,
|
||||||
|
]);
|
||||||
|
if (Base::isError($data)) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
return Base::retSuccess('success', $data['data']);
|
||||||
|
|
||||||
|
case 'generic_file':
|
||||||
|
// 编辑器粘贴文件 / 系统通用文件,对齐 system/fileupload
|
||||||
|
$filePath = "uploads/user/file/" . $user->userid . "/" . date("Ym") . "/";
|
||||||
|
$data = Base::uploadFromPath([
|
||||||
|
"path_local" => $mergedPath,
|
||||||
|
"name" => $name,
|
||||||
|
"type" => 'file',
|
||||||
|
"path" => $filePath,
|
||||||
|
"quality" => true,
|
||||||
|
]);
|
||||||
|
return $data;
|
||||||
|
|
||||||
|
case 'dialog_file':
|
||||||
|
// 聊天发文件 + 任务附件共用同一接入(任务附件本质是任务对话流的一条消息)
|
||||||
|
$dialogIds = $params['dialog_ids'] ?? [];
|
||||||
|
if (!is_array($dialogIds)) {
|
||||||
|
$dialogIds = [$dialogIds];
|
||||||
|
}
|
||||||
|
$dialogIds = array_values(array_filter(array_map('intval', $dialogIds)));
|
||||||
|
if (empty($dialogIds)) {
|
||||||
|
return Base::retError('dialog_ids 不能为空');
|
||||||
|
}
|
||||||
|
$replyId = intval($params['reply_id'] ?? 0);
|
||||||
|
$imageAttachment = boolval($params['image_attachment'] ?? false);
|
||||||
|
try {
|
||||||
|
return WebSocketDialog::sendMsgFilesFromPath($user, $dialogIds, $mergedPath, $name, $replyId, $imageAttachment);
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
return Base::retError($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Base::retError("scene 暂未实现:{$scene}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同 hash 命中则在目标位置复用源 FileContent 指向的物理文件,零字节传输。
|
||||||
|
* 未命中返回 null 让上层走真上传。
|
||||||
|
*/
|
||||||
|
protected static function trySecondPass(User $user, string $scene, string $hash, string $name, array $sceneParams): ?array
|
||||||
|
{
|
||||||
|
if ($scene !== 'file_cabinet') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$hit = FileModel::whereUserid($user->userid)->whereHash($hash)->whereNull('deleted_at')->first();
|
||||||
|
if (!$hit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$srcContent = FileContent::whereFid($hit->id)->orderByDesc('id')->first();
|
||||||
|
if (!$srcContent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$contentArr = is_array($srcContent->content)
|
||||||
|
? $srcContent->content
|
||||||
|
: json_decode($srcContent->content, true);
|
||||||
|
if (empty($contentArr['url'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$rawPid = intval($sceneParams['pid'] ?? 0);
|
||||||
|
$webkitRelativePath = strval($sceneParams['webkit_relative_path'] ?? $name);
|
||||||
|
$overwrite = boolval($sceneParams['overwrite'] ?? false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Lock::withLock("file:upload:{$user->userid}:{$rawPid}", function () use ($user, $rawPid, $webkitRelativePath, $overwrite, $hit, $hash, $name, $contentArr) {
|
||||||
|
[$pid, $userid, $addItem] = (new FileModel)->contentUploadPrep($user, $rawPid, $webkitRelativePath);
|
||||||
|
|
||||||
|
$ext = $hit->ext;
|
||||||
|
$bareName = Base::rightDelete($name, '.' . $ext);
|
||||||
|
$existing = null;
|
||||||
|
if ($overwrite) {
|
||||||
|
$existing = FileModel::wherePid($pid)->whereName($bareName)->whereExt($ext)->whereNull('deleted_at')->first();
|
||||||
|
}
|
||||||
|
if ($existing) {
|
||||||
|
$existing->size = $hit->size;
|
||||||
|
$existing->hash = $hash;
|
||||||
|
$existing->type = $hit->type;
|
||||||
|
if (!$existing->saveBeforePP()) {
|
||||||
|
throw new ApiException('秒传保存失败');
|
||||||
|
}
|
||||||
|
FileContent::createInstance([
|
||||||
|
'fid' => $existing->id,
|
||||||
|
'content' => $contentArr,
|
||||||
|
'text' => '',
|
||||||
|
'size' => $existing->size,
|
||||||
|
'userid' => $user->userid,
|
||||||
|
])->save();
|
||||||
|
$created = FileModel::find($existing->id);
|
||||||
|
$overwriteFlag = 1;
|
||||||
|
} else {
|
||||||
|
$newFile = FileModel::createInstance([
|
||||||
|
'pid' => $pid,
|
||||||
|
'name' => $bareName,
|
||||||
|
'type' => $hit->type,
|
||||||
|
'ext' => $ext,
|
||||||
|
'size' => $hit->size,
|
||||||
|
'hash' => $hash,
|
||||||
|
'userid' => $userid,
|
||||||
|
'created_id' => $user->userid,
|
||||||
|
]);
|
||||||
|
$newFile->handleDuplicateName();
|
||||||
|
if (!$newFile->saveBeforePP()) {
|
||||||
|
throw new ApiException('秒传保存失败');
|
||||||
|
}
|
||||||
|
FileContent::createInstance([
|
||||||
|
'fid' => $newFile->id,
|
||||||
|
'content' => $contentArr,
|
||||||
|
'text' => '',
|
||||||
|
'size' => $newFile->size,
|
||||||
|
'userid' => $user->userid,
|
||||||
|
])->save();
|
||||||
|
$created = FileModel::find($newFile->id);
|
||||||
|
$overwriteFlag = 0;
|
||||||
|
}
|
||||||
|
$created->pushMsg($overwriteFlag ? 'update' : 'add', $created);
|
||||||
|
$data = FileModel::handleImageUrl($created->toArray());
|
||||||
|
$data['full_name'] = $name;
|
||||||
|
$data['overwrite'] = $overwriteFlag;
|
||||||
|
$addItem[] = $data;
|
||||||
|
return [
|
||||||
|
'done' => true,
|
||||||
|
'instant' => true,
|
||||||
|
'addItem' => $addItem,
|
||||||
|
'msg' => $name . ' 秒传成功',
|
||||||
|
];
|
||||||
|
}, 120000, 120000);
|
||||||
|
} catch (\Throwable $_e) {
|
||||||
|
// 退化到真上传:错误由 dispatch 阶段权威报出,避免两条路径错误码不一致
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== helpers =====
|
||||||
|
|
||||||
|
protected static function keyMeta(string $uploadId): string
|
||||||
|
{
|
||||||
|
return "upload:{$uploadId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function keyChunks(string $uploadId): string
|
||||||
|
{
|
||||||
|
return "upload:{$uploadId}:chunks";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function keyHashIndex(int $userid, string $hash): string
|
||||||
|
{
|
||||||
|
return "upload:hash:{$userid}:{$hash}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function chunkDir(int $userid, string $uploadId): string
|
||||||
|
{
|
||||||
|
return public_path("uploads/tmp/chunks/{$userid}/{$uploadId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function loadMeta(string $uploadId): ?array
|
||||||
|
{
|
||||||
|
$raw = Redis::get(self::keyMeta($uploadId));
|
||||||
|
if (!$raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
return is_array($data) ? $data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function receivedList(string $uploadId): array
|
||||||
|
{
|
||||||
|
$list = Redis::smembers(self::keyChunks($uploadId)) ?: [];
|
||||||
|
$list = array_map('intval', $list);
|
||||||
|
sort($list);
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function sessionView(string $uploadId, array $meta): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'done' => false,
|
||||||
|
'upload_id' => $uploadId,
|
||||||
|
'chunk_size' => $meta['chunk_size'],
|
||||||
|
'chunk_count' => $meta['chunk_count'],
|
||||||
|
'received' => self::receivedList($uploadId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,7 +53,7 @@ class Doo
|
|||||||
*/
|
*/
|
||||||
public static function licenseContent(): string
|
public static function licenseContent(): string
|
||||||
{
|
{
|
||||||
if (env("SYSTEM_LICENSE") == 'hidden') {
|
if (config('dootask.system_license') == 'hidden') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
$paths = [
|
$paths = [
|
||||||
@ -81,6 +81,39 @@ class Doo
|
|||||||
self::load()->licenseSave($license);
|
self::load()->licenseSave($license);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析License取字段
|
||||||
|
* @param $license
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function licenseDecode($license): array
|
||||||
|
{
|
||||||
|
return self::load()->licenseDecode($license);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 license 的 SN/MAC 是否与本机匹配。通过返回 null,否则返回错误文案。
|
||||||
|
* @param array $info license 信息,含 people/sn/mac(mac 兼容数组或逗号串)
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public static function licenseBindingError(array $info): ?string
|
||||||
|
{
|
||||||
|
$people = (int)($info['people'] ?? 0);
|
||||||
|
if (!($people === 0 || $people > 3)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((string)($info['sn'] ?? '') !== self::dooSN()) {
|
||||||
|
return '终端SN与License不匹配';
|
||||||
|
}
|
||||||
|
$mac = $info['mac'] ?? [];
|
||||||
|
$licenseMacs = array_filter(array_map('trim', is_array($mac) ? $mac : explode(',', (string)$mac)));
|
||||||
|
$curMacs = self::macs();
|
||||||
|
if ($licenseMacs && $curMacs && !array_intersect($licenseMacs, $curMacs)) {
|
||||||
|
return '终端MAC与License不匹配';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前会员ID(来自请求的token)
|
* 当前会员ID(来自请求的token)
|
||||||
* @return int
|
* @return int
|
||||||
|
|||||||
@ -14,6 +14,8 @@ class Ihttp
|
|||||||
}
|
}
|
||||||
if(!empty($urlset['query'])) {
|
if(!empty($urlset['query'])) {
|
||||||
$urlset['query'] = "?{$urlset['query']}";
|
$urlset['query'] = "?{$urlset['query']}";
|
||||||
|
} else {
|
||||||
|
$urlset['query'] = '';
|
||||||
}
|
}
|
||||||
if(empty($urlset['port'])) {
|
if(empty($urlset['port'])) {
|
||||||
$urlset['port'] = $urlset['scheme'] == 'https' ? '443' : '80';
|
$urlset['port'] = $urlset['scheme'] == 'https' ? '443' : '80';
|
||||||
|
|||||||
@ -29,8 +29,8 @@ class ManticoreBase
|
|||||||
*/
|
*/
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->host = env('SEARCH_HOST', 'search');
|
$this->host = config('dootask.search_host');
|
||||||
$this->port = (int) env('SEARCH_PORT', 9306);
|
$this->port = (int) config('dootask.search_port');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
485
app/Module/OnlineLicense.php
Normal file
485
app/Module/OnlineLicense.php
Normal file
@ -0,0 +1,485 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Module;
|
||||||
|
|
||||||
|
use App\Exceptions\ApiException;
|
||||||
|
use App\Services\RequestContext;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在线授权客户端编排。
|
||||||
|
*
|
||||||
|
* 在线授权产出的仍是现有格式的离线 license blob,只是「获取方式」变成用 appstore 账号登录
|
||||||
|
* 自助签发、并由本类定时续期。doo.so 本地校验、license 文件存储全部复用。绑定状态以单例
|
||||||
|
* 形式存于 settings 表(name=onlineLicense),instance_token 用 Crypt 加密。
|
||||||
|
*
|
||||||
|
* 四级状态机(基于租约内嵌到期 lease_expired_at 与本地宽限,全程不依赖 appstore 可达):
|
||||||
|
* active 续期正常
|
||||||
|
* reminder 续期失败/租约剩余不足 warn_days(仅管理员可见提醒)
|
||||||
|
* frozen 租约已过期(doo.so 既有行为:限制新增用户)
|
||||||
|
* revoked 冻结超过 grace_days 或 appstore 明确吊销 → 回落默认 3 人版
|
||||||
|
*/
|
||||||
|
class OnlineLicense
|
||||||
|
{
|
||||||
|
const KEY = 'onlineLicense';
|
||||||
|
|
||||||
|
// ---- 配置 ----
|
||||||
|
|
||||||
|
protected static function appstoreUrl(): string
|
||||||
|
{
|
||||||
|
return rtrim((string)config('dootask.online_license_appstore_url'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function renewWithinDays(): int
|
||||||
|
{
|
||||||
|
return (int)config('dootask.online_license_renew_within_days', 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function warnDays(): int
|
||||||
|
{
|
||||||
|
return (int)config('dootask.online_license_warn_days', 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function graceDays(): int
|
||||||
|
{
|
||||||
|
return (int)config('dootask.online_license_grace_days', 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 状态读写(单例 settings)----
|
||||||
|
|
||||||
|
public static function get(): array
|
||||||
|
{
|
||||||
|
return Base::setting(self::KEY) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function set(array $patch): array
|
||||||
|
{
|
||||||
|
$next = array_merge(self::get(), $patch);
|
||||||
|
Base::setting(self::KEY, $next);
|
||||||
|
return $next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function enabled(): bool
|
||||||
|
{
|
||||||
|
$s = self::get();
|
||||||
|
return !empty($s['enabled']) && ($s['mode'] ?? '') === 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function token(): string
|
||||||
|
{
|
||||||
|
$enc = self::get()['instance_token'] ?? '';
|
||||||
|
if (empty($enc)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Crypt::decryptString($enc);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前请求语言,透传给 appstore 用于邮件按语言渲染(中文/繁体→中文,其余→英文)。
|
||||||
|
* 非请求上下文(如定时续期)返回空串,由 appstore 回落默认语言。
|
||||||
|
*/
|
||||||
|
protected static function lang(): string
|
||||||
|
{
|
||||||
|
return (string)Base::headerOrInput('language');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function fingerprint(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sn' => Doo::dooSN(),
|
||||||
|
'macs' => implode(',', Doo::macs()),
|
||||||
|
// 优先真实外网地址:config('app.url') 若为 localhost 由 replaceBaseUrl 替换为缓存的访问地址
|
||||||
|
'url' => RequestContext::replaceBaseUrl((string)config('app.url')),
|
||||||
|
// DooTask 应用版本(非 doo.so 库版本)
|
||||||
|
'version' => Base::getVersion(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- appstore 调用 ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调 appstore license 接口。返回 ['ok'=>bool, 'data'=>array, 'message'=>string]。
|
||||||
|
* $bearer 非空时带实例令牌(续期/释放)。
|
||||||
|
*/
|
||||||
|
protected static function call(string $path, array $payload, string $bearer = ''): array
|
||||||
|
{
|
||||||
|
$url = self::appstoreUrl() . '/api/v1/license/' . ltrim($path, '/');
|
||||||
|
$headers = ['Content-Type' => 'application/json'];
|
||||||
|
if ($bearer !== '') {
|
||||||
|
$headers['Authorization'] = 'Bearer ' . $bearer;
|
||||||
|
}
|
||||||
|
$resp = Ihttp::ihttp_request($url, json_encode($payload, JSON_UNESCAPED_UNICODE), $headers, 15);
|
||||||
|
if (Base::isError($resp)) {
|
||||||
|
return ['ok' => false, 'data' => [], 'message' => $resp['msg'] ?: '无法连接授权服务'];
|
||||||
|
}
|
||||||
|
$body = Base::json2array($resp['data'] ?? '');
|
||||||
|
if (($body['code'] ?? 0) !== 200) {
|
||||||
|
return ['ok' => false, 'data' => [], 'message' => $body['message'] ?: '授权服务返回错误'];
|
||||||
|
}
|
||||||
|
return ['ok' => true, 'data' => $body['data'] ?? [], 'message' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理签发结果:issued/renewed 则落地 license + 更新绑定状态;其它状态原样返回供上层决策。
|
||||||
|
*/
|
||||||
|
protected static function applyIssue(string $account, array $d): string
|
||||||
|
{
|
||||||
|
$status = $d['status'] ?? '';
|
||||||
|
if (in_array($status, ['issued', 'renewed'], true)) {
|
||||||
|
$blob = $d['license'] ?? '';
|
||||||
|
if ($blob === '') {
|
||||||
|
throw new ApiException('授权服务未返回 license');
|
||||||
|
}
|
||||||
|
Doo::licenseSave($blob); // 复用离线落地与 doo.so 校验
|
||||||
|
$snap = $d['snapshot'] ?? [];
|
||||||
|
$patch = [
|
||||||
|
'enabled' => true,
|
||||||
|
'mode' => 'online',
|
||||||
|
'account' => $account,
|
||||||
|
'plan' => $snap['plan'] ?? '',
|
||||||
|
'people' => $snap['people'] ?? 0,
|
||||||
|
'valid_until' => $snap['valid_until'] ?? null,
|
||||||
|
'lease_expired_at' => $snap['lease_expired_at'] ?? null,
|
||||||
|
'server_status' => $status,
|
||||||
|
'error_count' => 0,
|
||||||
|
'last_error' => '',
|
||||||
|
'frozen_since' => null,
|
||||||
|
'last_renewed_at' => Carbon::now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
if (!empty($d['instance_token'])) {
|
||||||
|
$patch['instance_token'] = Crypt::encryptString($d['instance_token']);
|
||||||
|
}
|
||||||
|
self::set($patch);
|
||||||
|
self::computeStage();
|
||||||
|
}
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 对外动作 ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮箱验证码(登录与试用共用),返回脱敏邮箱。
|
||||||
|
*/
|
||||||
|
public static function emailSend(string $email): string
|
||||||
|
{
|
||||||
|
$r = self::call('email/send', ['email' => $email, 'lang' => self::lang()]);
|
||||||
|
if (!$r['ok']) {
|
||||||
|
throw new ApiException($r['message']);
|
||||||
|
}
|
||||||
|
return $r['data']['email'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱 + 验证码登录并签发。失败抛 ApiException。
|
||||||
|
* 本机有多条可用授权时,appstore 返回 select_required + candidates,
|
||||||
|
* 此处不签发、原样返回候选,由前端选定后走 loginConfirm()。
|
||||||
|
*/
|
||||||
|
public static function login(string $email, string $code): array
|
||||||
|
{
|
||||||
|
$r = self::call('login', array_merge(['email' => $email, 'code' => $code, 'lang' => self::lang()], self::fingerprint()));
|
||||||
|
if (!$r['ok']) {
|
||||||
|
throw new ApiException($r['message']);
|
||||||
|
}
|
||||||
|
$d = $r['data'];
|
||||||
|
if (($d['status'] ?? '') === 'select_required') {
|
||||||
|
return [
|
||||||
|
'select_required' => true,
|
||||||
|
'candidates' => $d['candidates'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$status = self::applyIssue($email, $d);
|
||||||
|
if (!in_array($status, ['issued', 'renewed'], true)) {
|
||||||
|
throw new ApiException(self::statusHint($status));
|
||||||
|
}
|
||||||
|
return self::status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多条可用授权时,用户选定 $entitlementId 后确认签发(复用同一验证码)。失败抛 ApiException。
|
||||||
|
*/
|
||||||
|
public static function loginConfirm(string $email, string $code, int $entitlementId): array
|
||||||
|
{
|
||||||
|
$payload = array_merge([
|
||||||
|
'email' => $email,
|
||||||
|
'code' => $code,
|
||||||
|
'entitlement_id' => $entitlementId,
|
||||||
|
'lang' => self::lang(),
|
||||||
|
], self::fingerprint());
|
||||||
|
$r = self::call('login/confirm', $payload);
|
||||||
|
if (!$r['ok']) {
|
||||||
|
throw new ApiException($r['message']);
|
||||||
|
}
|
||||||
|
$status = self::applyIssue($email, $r['data']);
|
||||||
|
if (!in_array($status, ['issued', 'renewed'], true)) {
|
||||||
|
throw new ApiException(self::statusHint($status));
|
||||||
|
}
|
||||||
|
return self::status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱 + 验证码申请试用并签发。
|
||||||
|
*/
|
||||||
|
public static function trial(string $email, string $code): array
|
||||||
|
{
|
||||||
|
$payload = array_merge(['email' => $email, 'code' => $code, 'lang' => self::lang()], self::fingerprint());
|
||||||
|
$r = self::call('trial', $payload);
|
||||||
|
if (!$r['ok']) {
|
||||||
|
throw new ApiException($r['message']);
|
||||||
|
}
|
||||||
|
$status = self::applyIssue($email, $r['data']);
|
||||||
|
if (!in_array($status, ['issued', 'renewed'], true)) {
|
||||||
|
throw new ApiException(self::statusHint($status));
|
||||||
|
}
|
||||||
|
return self::status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 续期(定时任务调用)。不抛异常:网络/服务错误只累加计数,最终由状态机本地降级。
|
||||||
|
*/
|
||||||
|
public static function renew(): void
|
||||||
|
{
|
||||||
|
if (!self::enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$token = self::token();
|
||||||
|
if ($token === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$r = self::call('renew', self::fingerprint(), $token);
|
||||||
|
if (!$r['ok']) {
|
||||||
|
$s = self::get();
|
||||||
|
self::set([
|
||||||
|
'error_count' => (int)($s['error_count'] ?? 0) + 1,
|
||||||
|
'last_error' => $r['message'],
|
||||||
|
]);
|
||||||
|
self::computeStage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$status = $r['data']['status'] ?? '';
|
||||||
|
if (in_array($status, ['issued', 'renewed'], true)) {
|
||||||
|
self::applyIssue(self::get()['account'] ?? '', $r['data']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 服务侧明确状态(revoked/suspended/no_entitlement):不延长租约,记录后交状态机
|
||||||
|
self::set(['server_status' => $status, 'last_error' => self::statusHint($status)]);
|
||||||
|
self::computeStage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否到了该续期的时间(租约剩余不足 renew_within_days)。
|
||||||
|
*/
|
||||||
|
public static function dueForRenew(): bool
|
||||||
|
{
|
||||||
|
$lease = self::get()['lease_expired_at'] ?? null;
|
||||||
|
if (!$lease) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Carbon::parse($lease)->lte(Carbon::now()->addDays(self::renewWithinDays()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时续期入口:由容器内独立进程的 artisan 命令(online-license:renew)按小时调用。
|
||||||
|
* 先本地状态机推进(断网也能降级 frozen→revoked),再在租约将尽时续期。
|
||||||
|
*/
|
||||||
|
public static function cron(): void
|
||||||
|
{
|
||||||
|
if (!self::enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::computeStage();
|
||||||
|
if (self::enabled() && self::dueForRenew()) {
|
||||||
|
self::renew();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进入授权页时的静默刷新:服务可达则按服务结果更新(成功续签 / 反映吊销冻结),
|
||||||
|
* 网络失败则什么都不做、不提示、不降级(避免一次页面刷新失败就误报)。
|
||||||
|
*/
|
||||||
|
public static function refresh(): void
|
||||||
|
{
|
||||||
|
if (!self::enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$token = self::token();
|
||||||
|
if ($token === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$r = self::call('renew', self::fingerprint(), $token);
|
||||||
|
if (!$r['ok']) {
|
||||||
|
return; // 刷新失败:不更新、不提示
|
||||||
|
}
|
||||||
|
$status = $r['data']['status'] ?? '';
|
||||||
|
if (in_array($status, ['issued', 'renewed'], true)) {
|
||||||
|
self::applyIssue(self::get()['account'] ?? '', $r['data']);
|
||||||
|
} elseif (in_array($status, ['revoked', 'suspended', 'no_entitlement'], true)) {
|
||||||
|
// 服务侧明确结果(非网络失败):如实反映
|
||||||
|
self::set(['server_status' => $status, 'last_error' => self::statusHint($status)]);
|
||||||
|
self::computeStage();
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// 忽略,保持现状
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出在线授权:释放座位 + 回落默认。
|
||||||
|
*/
|
||||||
|
public static function logout(): void
|
||||||
|
{
|
||||||
|
$token = self::token();
|
||||||
|
if ($token !== '') {
|
||||||
|
self::call('deactivate', [], $token);
|
||||||
|
}
|
||||||
|
self::fallbackToDefault();
|
||||||
|
Base::setting(self::KEY, ['enabled' => false, 'mode' => 'offline']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换到离线授权(互斥):保存离线 license 后调用。
|
||||||
|
* 尽力释放在线座位 + 清在线标志,但「不」删除 license 文件(刚保存的离线 license 要保留)。
|
||||||
|
*/
|
||||||
|
public static function switchToOffline(): void
|
||||||
|
{
|
||||||
|
if (!self::enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$token = self::token();
|
||||||
|
if ($token !== '') {
|
||||||
|
self::call('deactivate', [], $token); // 尽力释放座位,失败忽略
|
||||||
|
}
|
||||||
|
Base::setting(self::KEY, ['enabled' => false, 'mode' => 'offline']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 状态机 ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 据租约到期 + 宽限重新计算 status,并在 revoked 时执行降级。
|
||||||
|
*/
|
||||||
|
public static function computeStage(): string
|
||||||
|
{
|
||||||
|
$s = self::get();
|
||||||
|
if (($s['mode'] ?? '') !== 'online' || empty($s['enabled'])) {
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
$now = Carbon::now();
|
||||||
|
$server = $s['server_status'] ?? '';
|
||||||
|
$lease = $s['lease_expired_at'] ?? null;
|
||||||
|
|
||||||
|
if ($server === 'revoked') {
|
||||||
|
return self::transitionRevoked();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lease && Carbon::parse($lease)->lte($now)) {
|
||||||
|
// 租约已过期 → 冻结;超过宽限 → 吊销
|
||||||
|
$frozenSince = $s['frozen_since'] ?? null;
|
||||||
|
if (!$frozenSince) {
|
||||||
|
$frozenSince = $now->toDateTimeString();
|
||||||
|
self::set(['frozen_since' => $frozenSince]);
|
||||||
|
}
|
||||||
|
if (Carbon::parse($frozenSince)->addDays(self::graceDays())->lte($now)) {
|
||||||
|
return self::transitionRevoked();
|
||||||
|
}
|
||||||
|
self::set(['status' => 'frozen']);
|
||||||
|
return 'frozen';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 租约有效
|
||||||
|
$remindByLease = $lease && Carbon::parse($lease)->lte($now->copy()->addDays(self::warnDays()));
|
||||||
|
$remindByError = (int)($s['error_count'] ?? 0) > 0 || $server === 'suspended' || $server === 'no_entitlement';
|
||||||
|
$status = ($remindByLease || $remindByError) ? 'reminder' : 'active';
|
||||||
|
self::set(['status' => $status, 'frozen_since' => null]);
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function transitionRevoked(): string
|
||||||
|
{
|
||||||
|
self::fallbackToDefault();
|
||||||
|
self::set(['status' => 'revoked', 'enabled' => false]);
|
||||||
|
return 'revoked';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除在线 license 文件,让 dooso 回落默认 3 人版(触发既有超员禁用)。
|
||||||
|
*/
|
||||||
|
protected static function fallbackToDefault(): void
|
||||||
|
{
|
||||||
|
foreach (['LICENSE', 'license'] as $name) {
|
||||||
|
$path = config_path($name);
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 提醒文案(注入 system/license 的 error[],复用 dashboard 警告条与 license 页)----
|
||||||
|
|
||||||
|
public static function stageMessages(): array
|
||||||
|
{
|
||||||
|
if (!self::enabled() && (self::get()['status'] ?? '') !== 'revoked') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$s = self::get();
|
||||||
|
$status = $s['status'] ?? self::computeStage();
|
||||||
|
$msgs = [];
|
||||||
|
switch ($status) {
|
||||||
|
case 'reminder':
|
||||||
|
if (($s['server_status'] ?? '') === 'suspended') {
|
||||||
|
$msgs[] = '在线授权已被冻结,请联系服务商';
|
||||||
|
} elseif ((int)($s['error_count'] ?? 0) > 0) {
|
||||||
|
$msgs[] = '在线授权续期失败,请检查网络';
|
||||||
|
} else {
|
||||||
|
$msgs[] = '在线授权即将到期,请保持联网续期';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'frozen':
|
||||||
|
$msgs[] = '在线授权已过期,新增用户受限,请尽快续期';
|
||||||
|
break;
|
||||||
|
case 'revoked':
|
||||||
|
$msgs[] = '在线授权已失效,已回落到基础版';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return $msgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function statusHint(string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'no_entitlement' => '该账号暂无可用授权,请先申请试用或购买',
|
||||||
|
'revoked' => '该授权已被吊销',
|
||||||
|
'suspended' => '该授权已被冻结',
|
||||||
|
'seat_taken' => '该授权已在另一台实例使用,请先在原实例释放(换机)',
|
||||||
|
'entitlement_expired' => '该授权已到期',
|
||||||
|
default => '签发失败(' . $status . ')',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对外状态(前端在线 Tab / status 接口用,不含敏感 token)。
|
||||||
|
*/
|
||||||
|
public static function status(): array
|
||||||
|
{
|
||||||
|
$s = self::get();
|
||||||
|
if (($s['mode'] ?? '') !== 'online' || empty($s['enabled'])) {
|
||||||
|
return ['mode' => 'offline'];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'mode' => 'online',
|
||||||
|
'account' => $s['account'] ?? '',
|
||||||
|
'plan' => $s['plan'] ?? '',
|
||||||
|
'people' => $s['people'] ?? 0,
|
||||||
|
'valid_until' => $s['valid_until'] ?? null,
|
||||||
|
'lease_expired_at' => $s['lease_expired_at'] ?? null,
|
||||||
|
'last_renewed_at' => $s['last_renewed_at'] ?? null,
|
||||||
|
'status' => $s['status'] ?? self::computeStage(),
|
||||||
|
'error_count' => (int)($s['error_count'] ?? 0),
|
||||||
|
'last_error' => $s['last_error'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Module/PatchedAvatar.php
Normal file
48
app/Module/PatchedAvatar.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Module;
|
||||||
|
|
||||||
|
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||||
|
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||||
|
use Intervention\Image\ImageManager;
|
||||||
|
use Intervention\Image\Typography\FontFactory;
|
||||||
|
use Laravolt\Avatar\Avatar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* laravolt/avatar 6.5.0 的 buildAvatar 给纵向对齐传 'middle',
|
||||||
|
* 而 intervention/image 4.1.3 的 Alignment 枚举仅接受 'center',会抛
|
||||||
|
* InvalidArgumentException(Invalid value for alignment)。上游修复前以子类覆写修正。
|
||||||
|
*/
|
||||||
|
class PatchedAvatar extends Avatar
|
||||||
|
{
|
||||||
|
public function buildAvatar(): static
|
||||||
|
{
|
||||||
|
$this->buildInitial();
|
||||||
|
|
||||||
|
$x = $this->width / 2;
|
||||||
|
$y = $this->height / 2;
|
||||||
|
|
||||||
|
$driver = $this->driver === 'gd' ? new GdDriver : new ImagickDriver;
|
||||||
|
$this->image = ImageManager::usingDriver($driver)->createImage($this->width, $this->height);
|
||||||
|
|
||||||
|
$this->createShape();
|
||||||
|
|
||||||
|
if (empty($this->initials)) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->image->text(
|
||||||
|
$this->initials,
|
||||||
|
(int) $x,
|
||||||
|
(int) $y,
|
||||||
|
function (FontFactory $font) {
|
||||||
|
$font->filepath($this->font);
|
||||||
|
$font->size($this->fontSize);
|
||||||
|
$font->color($this->foreground);
|
||||||
|
$font->align('center', 'center');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,7 +24,8 @@ abstract class AbstractData
|
|||||||
|
|
||||||
protected function __construct()
|
protected function __construct()
|
||||||
{
|
{
|
||||||
$this->table = app('swoole')->{$this->getTableName()};
|
// 非 Swoole 运行时(artisan/测试)无 swoole 绑定,table 为 null,各方法返回默认值
|
||||||
|
$this->table = app()->bound('swoole') ? app('swoole')->{$this->getTableName()} : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTable()
|
public function getTable()
|
||||||
@ -42,22 +43,34 @@ abstract class AbstractData
|
|||||||
|
|
||||||
public static function set($key, $value)
|
public static function set($key, $value)
|
||||||
{
|
{
|
||||||
|
if (!self::instance()->table) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return self::instance()->table->set($key, ['value' => $value]);
|
return self::instance()->table->set($key, ['value' => $value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function get($key, $default = null)
|
public static function get($key, $default = null)
|
||||||
{
|
{
|
||||||
|
if (!self::instance()->table) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
$data = self::instance()->table->get($key);
|
$data = self::instance()->table->get($key);
|
||||||
return $data ? $data['value'] : $default;
|
return $data ? $data['value'] : $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function del($key)
|
public static function del($key)
|
||||||
{
|
{
|
||||||
|
if (!self::instance()->table) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return self::instance()->table->del($key);
|
return self::instance()->table->del($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function exist($key)
|
public static function exist($key)
|
||||||
{
|
{
|
||||||
|
if (!self::instance()->table) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return self::instance()->table->exist($key);
|
return self::instance()->table->exist($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +83,9 @@ abstract class AbstractData
|
|||||||
|
|
||||||
public static function clear()
|
public static function clear()
|
||||||
{
|
{
|
||||||
|
if (!self::instance()->table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
foreach (self::instance()->table as $key => $row) {
|
foreach (self::instance()->table as $key => $row) {
|
||||||
self::del($key);
|
self::del($key);
|
||||||
}
|
}
|
||||||
@ -77,6 +93,9 @@ abstract class AbstractData
|
|||||||
|
|
||||||
public static function getAll()
|
public static function getAll()
|
||||||
{
|
{
|
||||||
|
if (!self::instance()->table) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach (self::instance()->table as $key => $row) {
|
foreach (self::instance()->table as $key => $row) {
|
||||||
$result[$key] = $row['value'];
|
$result[$key] = $row['value'];
|
||||||
|
|||||||
@ -17,6 +17,9 @@ class OnlineData extends AbstractData
|
|||||||
*/
|
*/
|
||||||
public static function online($userid)
|
public static function online($userid)
|
||||||
{
|
{
|
||||||
|
if (!self::instance()->getTable()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
$key = "online::" . $userid;
|
$key = "online::" . $userid;
|
||||||
$value = self::instance()->getTable()->incr($key, 'value');
|
$value = self::instance()->getTable()->incr($key, 'value');
|
||||||
if ($value === 1) {
|
if ($value === 1) {
|
||||||
@ -35,6 +38,9 @@ class OnlineData extends AbstractData
|
|||||||
*/
|
*/
|
||||||
public static function offline($userid)
|
public static function offline($userid)
|
||||||
{
|
{
|
||||||
|
if (!self::instance()->getTable()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
$key = "online::" . $userid;
|
$key = "online::" . $userid;
|
||||||
$value = self::instance()->getTable()->decr($key, 'value');
|
$value = self::instance()->getTable()->decr($key, 'value');
|
||||||
if ($value === 0) {
|
if ($value === 0) {
|
||||||
@ -57,6 +63,9 @@ class OnlineData extends AbstractData
|
|||||||
*/
|
*/
|
||||||
public static function live($userid)
|
public static function live($userid)
|
||||||
{
|
{
|
||||||
|
if (!self::instance()->getTable()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
$key = "online::" . $userid;
|
$key = "online::" . $userid;
|
||||||
return intval(self::instance()->getTable()->get($key));
|
return intval(self::instance()->getTable()->get($key));
|
||||||
}
|
}
|
||||||
|
|||||||
13
app/Module/UserImport.php
Normal file
13
app/Module/UserImport.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Module;
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\Concerns\ToArray;
|
||||||
|
|
||||||
|
class UserImport implements ToArray
|
||||||
|
{
|
||||||
|
public function array(array $array)
|
||||||
|
{
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Module/UserImportTemplate.php
Normal file
21
app/Module/UserImportTemplate.php
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Module;
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\Concerns\FromArray;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
|
||||||
|
class UserImportTemplate implements FromArray, WithHeadings
|
||||||
|
{
|
||||||
|
public function array(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['employee@example.com', '张三', 'Abc123456', '工程师'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return ['邮箱(必填)', '昵称(必填,2-20字)', '初始密码(必填,6-32位)', '职位(选填,2-20字)'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ namespace App\Observers;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Module\Apps;
|
use App\Module\Apps;
|
||||||
|
use App\Module\Badge;
|
||||||
use App\Tasks\ManticoreSyncTask;
|
use App\Tasks\ManticoreSyncTask;
|
||||||
|
|
||||||
class UserObserver extends AbstractObserver
|
class UserObserver extends AbstractObserver
|
||||||
@ -80,6 +81,8 @@ class UserObserver extends AbstractObserver
|
|||||||
} elseif (!$originalDisableAt && $currentDisableAt) {
|
} elseif (!$originalDisableAt && $currentDisableAt) {
|
||||||
// disable_at 从 null 变为有值 → 离职 (offboarded)
|
// disable_at 从 null 变为有值 → 离职 (offboarded)
|
||||||
Apps::dispatchUserHook($user, 'user_offboard', 'offboarded');
|
Apps::dispatchUserHook($user, 'user_offboard', 'offboarded');
|
||||||
|
// 离职清除该用户全部应用角标
|
||||||
|
Badge::clearByUser((int)$user->userid);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -122,6 +125,9 @@ class UserObserver extends AbstractObserver
|
|||||||
if (!$user->bot) {
|
if (!$user->bot) {
|
||||||
Apps::dispatchUserHook($user, 'user_offboard', 'delete');
|
Apps::dispatchUserHook($user, 'user_offboard', 'delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除该用户全部应用角标
|
||||||
|
Badge::clearByUser((int)$user->userid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\File;
|
||||||
|
use App\Models\FileUser;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTask;
|
||||||
|
use App\Models\ProjectTaskContent;
|
||||||
|
use App\Models\ProjectTaskUser;
|
||||||
|
use App\Models\ProjectTaskVisibilityUser;
|
||||||
|
use App\Models\ProjectUser;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserTag;
|
||||||
|
use App\Models\UserTagRecognition;
|
||||||
|
use App\Models\WebSocketDialog;
|
||||||
|
use App\Models\WebSocketDialogMsg;
|
||||||
|
use App\Models\WebSocketDialogUser;
|
||||||
|
use App\Observers\FileObserver;
|
||||||
|
use App\Observers\FileUserObserver;
|
||||||
|
use App\Observers\ProjectObserver;
|
||||||
|
use App\Observers\ProjectTaskContentObserver;
|
||||||
|
use App\Observers\ProjectTaskObserver;
|
||||||
|
use App\Observers\ProjectTaskUserObserver;
|
||||||
|
use App\Observers\ProjectTaskVisibilityUserObserver;
|
||||||
|
use App\Observers\ProjectUserObserver;
|
||||||
|
use App\Observers\UserObserver;
|
||||||
|
use App\Observers\UserTagObserver;
|
||||||
|
use App\Observers\UserTagRecognitionObserver;
|
||||||
|
use App\Observers\WebSocketDialogMsgObserver;
|
||||||
|
use App\Observers\WebSocketDialogObserver;
|
||||||
|
use App\Observers\WebSocketDialogUserObserver;
|
||||||
|
use Illuminate\Auth\Events\Registered;
|
||||||
|
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@ -32,5 +66,48 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
\Illuminate\Database\Eloquent\Builder::macro('rawSql', function(){
|
\Illuminate\Database\Eloquent\Builder::macro('rawSql', function(){
|
||||||
return ($this->getQuery()->rawSql());
|
return ($this->getQuery()->rawSql());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->configureRateLimiting();
|
||||||
|
$this->registerEvents();
|
||||||
|
$this->registerObservers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* api 组限流(原 RouteServiceProvider::configureRateLimiting)
|
||||||
|
*/
|
||||||
|
protected function configureRateLimiting()
|
||||||
|
{
|
||||||
|
RateLimiter::for('api', function (Request $request) {
|
||||||
|
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件监听(原 EventServiceProvider::$listen)
|
||||||
|
*/
|
||||||
|
protected function registerEvents()
|
||||||
|
{
|
||||||
|
Event::listen(Registered::class, SendEmailVerificationNotification::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型观察者(原 EventServiceProvider::boot)
|
||||||
|
*/
|
||||||
|
protected function registerObservers()
|
||||||
|
{
|
||||||
|
File::observe(FileObserver::class);
|
||||||
|
FileUser::observe(FileUserObserver::class);
|
||||||
|
Project::observe(ProjectObserver::class);
|
||||||
|
ProjectTask::observe(ProjectTaskObserver::class);
|
||||||
|
ProjectTaskContent::observe(ProjectTaskContentObserver::class);
|
||||||
|
ProjectTaskUser::observe(ProjectTaskUserObserver::class);
|
||||||
|
ProjectTaskVisibilityUser::observe(ProjectTaskVisibilityUserObserver::class);
|
||||||
|
ProjectUser::observe(ProjectUserObserver::class);
|
||||||
|
User::observe(UserObserver::class);
|
||||||
|
UserTag::observe(UserTagObserver::class);
|
||||||
|
UserTagRecognition::observe(UserTagRecognitionObserver::class);
|
||||||
|
WebSocketDialog::observe(WebSocketDialogObserver::class);
|
||||||
|
WebSocketDialogMsg::observe(WebSocketDialogMsgObserver::class);
|
||||||
|
WebSocketDialogUser::observe(WebSocketDialogUserObserver::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class AuthServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The policy mappings for the application.
|
|
||||||
*
|
|
||||||
* @var array
|
|
||||||
*/
|
|
||||||
protected $policies = [
|
|
||||||
// 'App\Models\Model' => 'App\Policies\ModelPolicy',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register any authentication / authorization services.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function boot()
|
|
||||||
{
|
|
||||||
$this->registerPolicies();
|
|
||||||
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Broadcast;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
|
|
||||||
class BroadcastServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function boot()
|
|
||||||
{
|
|
||||||
Broadcast::routes();
|
|
||||||
|
|
||||||
require base_path('routes/channels.php');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Models\File;
|
|
||||||
use App\Models\FileUser;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\ProjectTask;
|
|
||||||
use App\Models\ProjectTaskContent;
|
|
||||||
use App\Models\ProjectTaskUser;
|
|
||||||
use App\Models\ProjectTaskVisibilityUser;
|
|
||||||
use App\Models\ProjectUser;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\UserTag;
|
|
||||||
use App\Models\UserTagRecognition;
|
|
||||||
use App\Models\WebSocketDialog;
|
|
||||||
use App\Models\WebSocketDialogMsg;
|
|
||||||
use App\Models\WebSocketDialogUser;
|
|
||||||
use App\Observers\FileObserver;
|
|
||||||
use App\Observers\FileUserObserver;
|
|
||||||
use App\Observers\ProjectObserver;
|
|
||||||
use App\Observers\ProjectTaskContentObserver;
|
|
||||||
use App\Observers\ProjectTaskObserver;
|
|
||||||
use App\Observers\ProjectTaskUserObserver;
|
|
||||||
use App\Observers\ProjectTaskVisibilityUserObserver;
|
|
||||||
use App\Observers\ProjectUserObserver;
|
|
||||||
use App\Observers\UserObserver;
|
|
||||||
use App\Observers\UserTagObserver;
|
|
||||||
use App\Observers\UserTagRecognitionObserver;
|
|
||||||
use App\Observers\WebSocketDialogMsgObserver;
|
|
||||||
use App\Observers\WebSocketDialogObserver;
|
|
||||||
use App\Observers\WebSocketDialogUserObserver;
|
|
||||||
use Illuminate\Auth\Events\Registered;
|
|
||||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The event listener mappings for the application.
|
|
||||||
*
|
|
||||||
* @var array
|
|
||||||
*/
|
|
||||||
protected $listen = [
|
|
||||||
Registered::class => [
|
|
||||||
SendEmailVerificationNotification::class,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register any events for your application.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function boot()
|
|
||||||
{
|
|
||||||
File::observe(FileObserver::class);
|
|
||||||
FileUser::observe(FileUserObserver::class);
|
|
||||||
Project::observe(ProjectObserver::class);
|
|
||||||
ProjectTask::observe(ProjectTaskObserver::class);
|
|
||||||
ProjectTaskContent::observe(ProjectTaskContentObserver::class);
|
|
||||||
ProjectTaskUser::observe(ProjectTaskUserObserver::class);
|
|
||||||
ProjectTaskVisibilityUser::observe(ProjectTaskVisibilityUserObserver::class);
|
|
||||||
ProjectUser::observe(ProjectUserObserver::class);
|
|
||||||
User::observe(UserObserver::class);
|
|
||||||
UserTag::observe(UserTagObserver::class);
|
|
||||||
UserTagRecognition::observe(UserTagRecognitionObserver::class);
|
|
||||||
WebSocketDialog::observe(WebSocketDialogObserver::class);
|
|
||||||
WebSocketDialogMsg::observe(WebSocketDialogMsgObserver::class);
|
|
||||||
WebSocketDialogUser::observe(WebSocketDialogUserObserver::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
|
||||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
class RouteServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The path to the "home" route for your application.
|
|
||||||
*
|
|
||||||
* This is used by Laravel authentication to redirect users after login.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
public const HOME = '/home';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The controller namespace for the application.
|
|
||||||
*
|
|
||||||
* When present, controller route declarations will automatically be prefixed with this namespace.
|
|
||||||
*
|
|
||||||
* @var string|null
|
|
||||||
*/
|
|
||||||
// protected $namespace = 'App\\Http\\Controllers';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define your route model bindings, pattern filters, etc.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function boot()
|
|
||||||
{
|
|
||||||
$this->configureRateLimiting();
|
|
||||||
|
|
||||||
$this->routes(function () {
|
|
||||||
Route::prefix('api')
|
|
||||||
->middleware('api')
|
|
||||||
->namespace($this->namespace)
|
|
||||||
->group(base_path('routes/api.php'));
|
|
||||||
|
|
||||||
Route::middleware('web')
|
|
||||||
->namespace($this->namespace)
|
|
||||||
->group(base_path('routes/web.php'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure the rate limiters for the application.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
protected function configureRateLimiting()
|
|
||||||
{
|
|
||||||
RateLimiter::for('api', function (Request $request) {
|
|
||||||
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -136,6 +136,20 @@ class WebSocketService implements WebSocketHandlerInterface
|
|||||||
}
|
}
|
||||||
Cache::put("User::encrypt:" . $frame->fd, Base::array2json($data), Carbon::now()->addDay());
|
Cache::put("User::encrypt:" . $frame->fd, Base::array2json($data), Carbon::now()->addDay());
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// AI 助手页面操作结果回包(由 assistant/operation/dispatch 派发,前端执行后回传)
|
||||||
|
case 'operationResult':
|
||||||
|
$requestId = trim($data['requestId'] ?? '');
|
||||||
|
if ($requestId !== '') {
|
||||||
|
$row = WebSocket::whereFd($frame->fd)->first();
|
||||||
|
Cache::put("ai_op_result:{$requestId}", [
|
||||||
|
'userid' => $row?->userid ?: 0,
|
||||||
|
'success' => !empty($data['success']),
|
||||||
|
'result' => $data['result'] ?? null,
|
||||||
|
'error' => $data['error'] ?? null,
|
||||||
|
], 60);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回消息
|
// 返回消息
|
||||||
|
|||||||
@ -29,6 +29,19 @@ abstract class AbstractTask extends Task
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重写投递:非 Swoole 运行时(artisan/测试)无 swoole 绑定,无法投递异步任务,跳过(与 AbstractObserver 守卫一致)
|
||||||
|
* @param mixed $task
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function task($task)
|
||||||
|
{
|
||||||
|
if (!app()->bound('swoole')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return parent::task($task);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开始执行任务
|
* 开始执行任务
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,6 +6,7 @@ use App\Models\FileContent;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectTask;
|
use App\Models\ProjectTask;
|
||||||
use App\Models\Report;
|
use App\Models\Report;
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserBot;
|
use App\Models\UserBot;
|
||||||
use App\Models\UserDepartment;
|
use App\Models\UserDepartment;
|
||||||
@ -469,7 +470,10 @@ class BotReceiveMsgTask extends AbstractTask
|
|||||||
if ($msg->msg['model_name']) {
|
if ($msg->msg['model_name']) {
|
||||||
$extras['model_name'] = $msg->msg['model_name'];
|
$extras['model_name'] = $msg->msg['model_name'];
|
||||||
}
|
}
|
||||||
// 提取模型“思考”参数
|
// 优先读取模型列表中按模型配置的思考档位(off|low|medium|high)
|
||||||
|
$thinkingEffort = Setting::AIBotModelThinking($setting[$type . '_models'] ?? '', $extras['model_name']);
|
||||||
|
// 兼容旧约定:模型名带 (thinking)/-reasoning 等后缀时,剥离后缀并视为 medium 档
|
||||||
|
if ($thinkingEffort === 'off') {
|
||||||
$thinkPatterns = [
|
$thinkPatterns = [
|
||||||
"/^(.+?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/",
|
"/^(.+?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/",
|
||||||
"/^(.+?)\s*\(\s*(think|thinking|reasoning)\s*\)\s*$/"
|
"/^(.+?)\s*\(\s*(think|thinking|reasoning)\s*\)\s*$/"
|
||||||
@ -482,8 +486,13 @@ class BotReceiveMsgTask extends AbstractTask
|
|||||||
}
|
}
|
||||||
if ($thinkMatch && !empty($thinkMatch[1])) {
|
if ($thinkMatch && !empty($thinkMatch[1])) {
|
||||||
$extras['model_name'] = $thinkMatch[1];
|
$extras['model_name'] = $thinkMatch[1];
|
||||||
|
$thinkingEffort = 'medium';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($thinkingEffort !== 'off') {
|
||||||
|
$extras['thinking_effort'] = $thinkingEffort;
|
||||||
$extras['max_tokens'] = 20000;
|
$extras['max_tokens'] = 20000;
|
||||||
$extras['thinking'] = 4096;
|
$extras['thinking'] = 4096; // 兼容旧版插件
|
||||||
$extras['temperature'] = 1.0;
|
$extras['temperature'] = 1.0;
|
||||||
}
|
}
|
||||||
// 设定会话ID
|
// 设定会话ID
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Tasks;
|
namespace App\Tasks;
|
||||||
|
|
||||||
use App\Models\ApproveProcInstHistory;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserCheckinRecord;
|
use App\Models\UserCheckinRecord;
|
||||||
use App\Models\WebSocketDialog;
|
use App\Models\WebSocketDialog;
|
||||||
@ -80,9 +79,6 @@ class CheckinRemindTask extends AbstractTask
|
|||||||
if (!UserCheckinRecord::whereUserid($user->userid)->where('created_at', '>', Carbon::now()->subDays(3))->exists()) {
|
if (!UserCheckinRecord::whereUserid($user->userid)->where('created_at', '>', Carbon::now()->subDays(3))->exists()) {
|
||||||
continue; // 3天内没有打卡
|
continue; // 3天内没有打卡
|
||||||
}
|
}
|
||||||
if (ApproveProcInstHistory::userIsLeave($user->userid)) {
|
|
||||||
continue; // 请假、外出
|
|
||||||
}
|
|
||||||
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
|
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
|
||||||
if ($dialog) {
|
if ($dialog) {
|
||||||
if ($type === 'exceed') {
|
if ($type === 'exceed') {
|
||||||
|
|||||||
@ -65,7 +65,7 @@ class DeleteTmpTask extends AbstractTask
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'file':
|
case 'file':
|
||||||
$day = intval(env("AUTO_EMPTY_FILE_RECYCLE", 365));
|
$day = intval(config('dootask.auto_empty_file_recycle'));
|
||||||
if ($day <= 0) {
|
if ($day <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -81,7 +81,7 @@ class DeleteTmpTask extends AbstractTask
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'tmp_file':
|
case 'tmp_file':
|
||||||
$day = intval(env("AUTO_EMPTY_TEMP_FILE", 30));
|
$day = intval(config('dootask.auto_empty_temp_file'));
|
||||||
if ($day <= 0) {
|
if ($day <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -94,6 +94,27 @@ class DeleteTmpTask extends AbstractTask
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'tmp_chunks':
|
||||||
|
// 分片上传残留:upload_id 目录超过 hours 小时未合并则整目录清掉
|
||||||
|
$chunksRoot = public_path('uploads/tmp/chunks');
|
||||||
|
if (!is_dir($chunksRoot)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$cutoff = time() - 3600 * $this->hours;
|
||||||
|
foreach (glob($chunksRoot . '/*', GLOB_ONLYDIR) ?: [] as $userDir) {
|
||||||
|
foreach (glob($userDir . '/*', GLOB_ONLYDIR) ?: [] as $uploadDir) {
|
||||||
|
$mtime = @filemtime($uploadDir);
|
||||||
|
if ($mtime && $mtime < $cutoff) {
|
||||||
|
Base::deleteDirAndFile($uploadDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 顺手清理空 user 目录
|
||||||
|
if (count(scandir($userDir) ?: []) <= 2) {
|
||||||
|
@rmdir($userDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'user_device':
|
case 'user_device':
|
||||||
UserDevice::where('expired_at', '<', Carbon::now()->subHours($this->hours))
|
UserDevice::where('expired_at', '<', Carbon::now()->subHours($this->hours))
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
|
|||||||
@ -9,8 +9,9 @@ use App\Module\Base;
|
|||||||
use App\Module\Doo;
|
use App\Module\Doo;
|
||||||
use App\Module\Timer;
|
use App\Module\Timer;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Guanguans\Notify\Factory;
|
use Symfony\Component\Mailer\Mailer;
|
||||||
use Guanguans\Notify\Messages\EmailMessage;
|
use Symfony\Component\Mailer\Transport;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 未读消息邮件通知任务
|
* 未读消息邮件通知任务
|
||||||
@ -258,20 +259,18 @@ class EmailNoticeTask extends AbstractTask
|
|||||||
private function sendEmail($user, $emailData): void
|
private function sendEmail($user, $emailData): void
|
||||||
{
|
{
|
||||||
Setting::validateAddr($user->email, function($to) use ($emailData) {
|
Setting::validateAddr($user->email, function($to) use ($emailData) {
|
||||||
Factory::mailer()
|
$mailer = new Mailer(Transport::fromDsn(sprintf(
|
||||||
->setDsn(sprintf(
|
|
||||||
'smtp://%s:%s@%s:%s?verify_peer=0',
|
'smtp://%s:%s@%s:%s?verify_peer=0',
|
||||||
$this->emailSetting['account'],
|
$this->emailSetting['account'],
|
||||||
$this->emailSetting['password'],
|
$this->emailSetting['password'],
|
||||||
$this->emailSetting['smtp_server'],
|
$this->emailSetting['smtp_server'],
|
||||||
$this->emailSetting['port']
|
$this->emailSetting['port']
|
||||||
))
|
)));
|
||||||
->setMessage(EmailMessage::create()
|
$mailer->send((new Email())
|
||||||
->from(sprintf('%s <%s>', Base::settingFind('system', 'system_alias', 'Task'), $this->emailSetting['account']))
|
->from(sprintf('%s <%s>', Base::settingFind('system', 'system_alias', 'Task'), $this->emailSetting['account']))
|
||||||
->to($to)
|
->to($to)
|
||||||
->subject($emailData['subject'])
|
->subject($emailData['subject'])
|
||||||
->html($emailData['content']))
|
->html($emailData['content']));
|
||||||
->send();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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