签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
@@ -55,10 +55,10 @@
# =============================================================================
# 数据库迁移命令
# =============================================================================
# php think db:migrate → 调用 ThinkPHP 命令行的数据库迁移工具
# [选项] → 通过 --key=value 格式传入源/目标数据库连接参数
# php artisan db:migrate → 调用 Laravel Artisan 的数据库迁移工具
# [选项] → 通过 --key=value 格式传入源/目标数据库连接参数
# =============================================================================
php think db:migrate [选项]
php artisan db:migrate [选项]
```
### 必填选项
@@ -68,14 +68,14 @@ php think db:migrate [选项]
| `--src-type` | 源数据库驱动:`mysql` / `pgsql` / `sqlsrv` / `oracle` / `sqlite` |
| `--src-database` | 源数据库名 |
| `--dst-type` | 目标数据库驱动:`mysql` / `pgsql` / `sqlsrv` / `oracle` / `sqlite` |
| `--dst-database` | 目标数据库名 |
| `--dst-database` | 目标数据库名(SQLite 时为文件路径) |
### 源连接选项
| 选项 | 默认值 | 说明 |
|------|--------|------|
| `--src-host` | `127.0.0.1` | 源主机地址 |
| `--src-hostport` | 自动推断 | 源端口(mysql=3306, pgsql=5432, sqlsrv=1433, oracle=1521) |
| `--src-port` | 自动推断 | 源端口(mysql=3306, pgsql=5432, sqlsrv=1433, oracle=1521) |
| `--src-username` | - | 源用户名(SQLite 除外) |
| `--src-password` | - | 源密码(SQLite 除外) |
@@ -84,7 +84,7 @@ php think db:migrate [选项]
| 选项 | 默认值 | 说明 |
|------|--------|------|
| `--dst-host` | `127.0.0.1` | 目标主机地址 |
| `--dst-hostport` | 自动推断 | 目标端口 |
| `--dst-port` | 自动推断 | 目标端口 |
| `--dst-username` | - | 目标用户名(SQLite 除外) |
| `--dst-password` | - | 目标密码(SQLite 除外) |
@@ -94,7 +94,11 @@ php think db:migrate [选项]
|------|--------|------|
| `--drop-target-first` | 否 | 迁移前先删除目标库中的同名表 |
| `--batch-size` | `1000` | 每批读取的行数(影响内存占用与迁移速度) |
| `--tables` | 全部 | 只迁移指定表(逗号分隔) |
| `--tables` | 全部 | 只迁移指定表(逗号分隔,不含前缀) |
:::warning 参数名为 `--src-port` / `--dst-port`
选项是 `--src-port` / `--dst-port`(**不是** `hostport`),与签名 `DbMigrateCommand::$signature` 保持一致。端口缺省时按驱动自动推断。
:::
## 迁移命令签名
@@ -107,11 +111,11 @@ php think db:migrate [选项]
# --src-* → 源数据库连接参数(MySQL)
# --dst-* → 目标数据库连接参数(PostgreSQL)
# --drop-target-first → 迁移前先删除目标库中的同名表(避免表已存在报错)
php think db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=root --src-password=root \
--dst-type=pgsql --dst-host=127.0.0.1 --dst-hostport=5432 \
--dst-database=rxthinkcmf.thinkphp6.elevue --dst-username=postgres --dst-password=123456 \
php artisan db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-database=rxthinkcmf.laravel13.elevue --src-username=root --src-password=root \
--dst-type=pgsql --dst-host=127.0.0.1 --dst-port=5432 \
--dst-database=rxthinkcmf.laravel13.elevue --dst-username=postgres --dst-password=123456 \
--drop-target-first
```
@@ -122,12 +126,12 @@ php think db:migrate \
# MySQL 迁移到 SQL Server
# -----------------------------------------------------------------------------
# --dst-type=sqlsrv → 目标驱动为 SQL Server
# --dst-hostport=1433 → SQL Server 默认端口
php think db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=root --src-password=root \
--dst-type=sqlsrv --dst-host=127.0.0.1 --dst-hostport=1433 \
--dst-database=rxthinkcmf.thinkphp6.elevue --dst-username=sa --dst-password=123456 \
# --dst-port=1433 → SQL Server 默认端口
php artisan db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-database=rxthinkcmf.laravel13.elevue --src-username=root --src-password=root \
--dst-type=sqlsrv --dst-host=127.0.0.1 --dst-port=1433 \
--dst-database=rxthinkcmf.laravel13.elevue --dst-username=sa --dst-password=123456 \
--drop-target-first
```
@@ -139,23 +143,38 @@ php think db:migrate \
# -----------------------------------------------------------------------------
# --dst-type=sqlite → 目标驱动为 SQLite(无需用户名密码)
# --dst-database → SQLite 文件路径(文件不存在时自动创建)
php think db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=root --src-password=root \
--dst-type=sqlite --dst-database=runtime/rxthinkcmf.thinkphp6.elevue.db
php artisan db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-database=rxthinkcmf.laravel13.elevue --src-username=root --src-password=root \
--dst-type=sqlite --dst-database=database/rxthinkcmf.laravel13.elevue.db
```
### MySQL → MySQL(同库复制)
### MySQL → Oracle
```bash
# -----------------------------------------------------------------------------
# MySQL 迁移到 Oracle
# -----------------------------------------------------------------------------
# --dst-type=oracle → 目标驱动为 Oracle(自动建用户 + 序列 + 触发器)
php artisan db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-database=rxthinkcmf.laravel13.elevue --src-username=root --src-password=root \
--dst-type=oracle --dst-host=127.0.0.1 --dst-port=1521 \
--dst-database=rxthinkcmf.laravel13.elevue --dst-username=system --dst-password=123456 \
--drop-target-first
```
### MySQL → MySQL(同实例跨库复制)
```bash
# -----------------------------------------------------------------------------
# MySQL 同库复制(源和目标在同一 MySQL 实例,数据库名不同)
# -----------------------------------------------------------------------------
# 不带 --drop-target-first → 目标表已存在时跳过(不删除)
php think db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
php artisan db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-database=source_db --src-username=root --src-password=root \
--dst-type=mysql --dst-host=127.0.0.1 --dst-hostport=3306 \
--dst-type=mysql --dst-host=127.0.0.1 --dst-port=3306 \
--dst-database=target_db --dst-username=root --dst-password=root
```
@@ -165,11 +184,11 @@ php think db:migrate \
# -----------------------------------------------------------------------------
# PostgreSQL 迁移到 MySQL
# -----------------------------------------------------------------------------
php think db:migrate \
--src-type=pgsql --src-host=127.0.0.1 --src-hostport=5432 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=postgres --src-password=123456 \
--dst-type=mysql --dst-host=127.0.0.1 --dst-hostport=3306 \
--dst-database=rxthinkcmf.thinkphp6.elevue --dst-username=root --dst-password=root \
php artisan db:migrate \
--src-type=pgsql --src-host=127.0.0.1 --src-port=5432 \
--src-database=rxthinkcmf.laravel13.elevue --src-username=postgres --src-password=123456 \
--dst-type=mysql --dst-host=127.0.0.1 --dst-port=3306 \
--dst-database=rxthinkcmf.laravel13.elevue --dst-username=root --dst-password=root \
--drop-target-first
```
@@ -179,11 +198,11 @@ php think db:migrate \
# -----------------------------------------------------------------------------
# SQL Server 迁移到 MySQL
# -----------------------------------------------------------------------------
php think db:migrate \
--src-type=sqlsrv --src-host=127.0.0.1 --src-hostport=1433 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=sa --src-password=123456 \
--dst-type=mysql --dst-host=127.0.0.1 --dst-hostport=3306 \
--dst-database=rxthinkcmf.thinkphp6.elevue --dst-username=root --dst-password=root \
php artisan db:migrate \
--src-type=sqlsrv --src-host=127.0.0.1 --src-port=1433 \
--src-database=rxthinkcmf.laravel13.elevue --src-username=sa --src-password=123456 \
--dst-type=mysql --dst-host=127.0.0.1 --dst-port=3306 \
--dst-database=rxthinkcmf.laravel13.elevue --dst-username=root --dst-password=root \
--drop-target-first
```
@@ -193,13 +212,13 @@ php think db:migrate \
# -----------------------------------------------------------------------------
# 只迁移指定表(逗号分隔表名,不含前缀)
# -----------------------------------------------------------------------------
# --tables=think_user,think_role,think_menu → 只迁移这 3 张表
php think db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=root --src-password=root \
--dst-type=pgsql --dst-host=127.0.0.1 --dst-hostport=5432 \
--dst-database=rxthinkcmf.thinkphp6.elevue --dst-username=postgres --dst-password=123456 \
--tables=think_user,think_role,think_menu \
# --tables=laravel_user,laravel_role,laravel_menu → 只迁移这 3 张表
php artisan db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-database=rxthinkcmf.laravel13.elevue --src-username=root --src-password=root \
--dst-type=pgsql --dst-host=127.0.0.1 --dst-port=5432 \
--dst-database=rxthinkcmf.laravel13.elevue --dst-username=postgres --dst-password=123456 \
--tables=laravel_user,laravel_role,laravel_menu \
--drop-target-first
```
@@ -208,127 +227,75 @@ php think db:migrate \
### MySQL → PostgreSQL 完整日志
```bash
$ php think db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=root --src-password=root \
--dst-type=pgsql --dst-host=127.0.0.1 --dst-hostport=5432 \
--dst-database=rxthinkcmf.thinkphp6.elevue --dst-username=postgres --dst-password=123456 \
$ php artisan db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-database=rxthinkcmf.laravel13.elevue --src-username=root --src-password=root \
--dst-type=pgsql --dst-host=127.0.0.1 --dst-port=5432 \
--dst-database=rxthinkcmf.laravel13.elevue --dst-username=postgres --dst-password=123456 \
--drop-target-first
==============================================================
[迁移开始] 源: mysql:rxthinkcmf.thinkphp6.elevue → 目标: pgsql:rxthinkcmf.thinkphp6.elevue
[迁移开始] 源: mysql:rxthinkcmf.laravel13.elevue → 目标: pgsql:rxthinkcmf.laravel13.elevue
[迁移开始] 待迁移 25 张表
==============================================================
[·] [1/5] 建目标库...
· 目标库 "rxthinkcmf.thinkphp6.elevue" 已就绪
· 目标库 "rxthinkcmf.laravel13.elevue" 已就绪
[✓] [1/5] 建目标库 完成(耗时 0ms)
[·] [2/5] 建表结构(25 张表)...
[✓] [2/5] 建表结构 完成(耗时 440ms),25 张表,118 个索引,0 个外键
[·] [3/5] 逐表迁移数据...
· [1/25] 正在迁移 think_article...
[think_article] +13(累计 13)
[✓ think_article] 共 13 行(耗时 10ms)
· [2/25] 正在迁移 think_category...
[think_category] +150(累计 150)
[✓ think_category] 共 150 行(耗时 12ms)
· [3/25] 正在迁移 think_city...
[think_city] +1000(累计 1000)
[think_city] +1000(累计 2000)
[think_city] +1000(累计 3000)
· [1/25] 正在迁移 laravel_article...
[laravel_article] +13(累计 13)
[✓ laravel_article] 共 13 行(耗时 10ms)
· [2/25] 正在迁移 laravel_city...
[laravel_city] +1000(累计 1000)
...(大表分批输出进度,每批 1000 行)...
[think_city] +562(累计 783562)
[✓ think_city] 共 783562 行(耗时 10m10s)
· [4/25] 正在迁移 think_config...
[think_config] +1(累计 1)
[✓ think_config] 共 1 行(耗时 11ms)
[laravel_city] +562(累计 783562)
[✓ laravel_city] 共 783562 行(耗时 10m10s)
...(其余表类似)...
· [25/25] 正在迁移 think_user_role...
[think_user_role] +25(累计 25)
[✓ think_user_role] 共 25 行(耗时 6ms)
[✓] [3/5] 逐表迁移数据 完成(耗时 10m10s),共 785215 行
[·] [4/5] 重置自增序列...
[✓] [4/5] 重置自增序列 完成(耗时 23ms)
[·] [5/5] 逐表行数校验...
[think_article] source=13 target=13 PASS
[think_category] source=150 target=150 PASS
[think_city] source=783562 target=783562 PASS
[think_config] source=1 target=1 PASS
[laravel_article] source=13 target=13 PASS
[laravel_city] source=783562 target=783562 PASS
...(其余表类似)...
[think_user_role] source=25 target=25 PASS
[✓] [5/5] 逐表行数校验 完成(耗时 452ms),25 张表全部 PASS
==============================================================
[迁移完成] 成功:785215 行 / 25 张表 / 总耗时 10m11s
==============================================================
```
### MySQL → SQL Server 日志摘要
```bash
$ php think db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=root --src-password=root \
--dst-type=sqlsrv --dst-host=127.0.0.1 --dst-hostport=1433 \
--dst-database=rxthinkcmf.thinkphp6.elevue --dst-username=sa --dst-password=123456 \
--drop-target-first
==============================================================
[迁移开始] 源: mysql:rxthinkcmf.thinkphp6.elevue → 目标: sqlsrv:rxthinkcmf.thinkphp6.elevue
[迁移开始] 待迁移 25 张表
==============================================================
[·] [1/5] 建目标库...
· 目标库 "rxthinkcmf.thinkphp6.elevue" 已就绪
[✓] [1/5] 建目标库 完成(耗时 0ms)
[·] [2/5] 建表结构(25 张表)...
[✓] [2/5] 建表结构 完成(耗时 1.5s),25 张表,118 个索引,0 个外键
[·] [3/5] 逐表迁移数据...
...(逐表迁移,SQL Server 每行耗时较 MySQL/SQLite 更长)...
[✓] [3/5] 逐表迁移数据 完成(耗时 11m48s),共 785231 行
[·] [4/5] 重置自增序列...
[✓] [4/5] 重置自增序列 完成(耗时 52ms)
[·] [5/5] 逐表行数校验...
...(25 张表全部 PASS)...
[✓] [5/5] 逐表行数校验 完成(耗时 681ms),25 张表全部 PASS
==============================================================
[迁移完成] 成功:785231 行 / 25 张表 / 总耗时 11m50s
==============================================================
```
### MySQL → Oracle 日志摘要
```bash
$ php think db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=root --src-password=root \
--dst-type=oracle --dst-host=127.0.0.1 --dst-hostport=1521 \
$ php artisan db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-database=rxthinkcmf.laravel13.elevue --src-username=root --src-password=root \
--dst-type=oracle --dst-host=127.0.0.1 --dst-port=1521 \
--dst-database=rxthinkcmf --dst-username=system --dst-password=123456 \
--drop-target-first
==============================================================
[迁移开始] 源: mysql:rxthinkcmf.thinkphp6.elevue → 目标: oracle:rxthinkcmf.thinkphp6.elevue
[迁移开始] 待迁移 25 张表
[迁移开始] 源: mysql:rxthinkcmf.laravel13.elevue → 目标: oracle:rxthinkcmf.laravel13.elevue
==============================================================
[·] [1/5] 建目标库...
· 目标库 "rxthinkcmf" 已就绪
· 目标库 "rxthinkcmf" 已就绪(CREATE USER + GRANT)
[✓] [1/5] 建目标库 完成(耗时 0ms)
[·] [2/5] 建表结构(25 张表)...
· 为 think_article 创建序列+触发器(自增列 id)
· 为 think_city 创建序列+触发器(自增列 id)
· 为 laravel_article 创建序列+触发器(自增列 id)
· 为 laravel_city 创建序列+触发器(自增列 id)
...(其余含自增列的表类似)...
[✓] [2/5] 建表结构 完成(耗时 2.1s),25 张表,118 个索引,0 个外键
[✓] [2/5] 建表结构 完成(耗时 2.1s)
[·] [3/5] 逐表迁移数据...
· [1/25] 正在迁移 think_article...
[think_article] +13(累计 13)
[✓ think_article] 共 13 行(耗时 45ms)
...(Oracle INSERT 耗时较 MySQL/SQLite 更长)...
[✓] [3/5] 逐表迁移数据 完成(耗时 15m30s),共 785215 行
[·] [4/5] 重置自增序列...
· 重置序列: think_article_id_seq → 14
· 重置序列: think_city_id_seq → 783563
...(其余序列类似)...
· 重置序列: laravel_article_id_seq → 14
· 重置序列: laravel_city_id_seq → 783563
[✓] [4/5] 重置自增序列 完成(耗时 180ms)
[·] [5/5] 逐表行数校验...
...(25 张表全部 PASS)...
[✓] [5/5] 逐表行数校验 完成(耗时 820ms),25 张表全部 PASS
==============================================================
[迁移完成] 成功:785215 行 / 25 张表 / 总耗时 15m33s
==============================================================
@@ -336,83 +303,43 @@ $ php think db:migrate \
:::tip Oracle 迁移要点
- **建库**:Oracle 通过 `CREATE USER` 创建用户,库名即用户名,需要 DBA 权限
- **自增列**:自动创建序列(`_seq`)和触发器(`_trg`),插入时自动填充
- **序列重置**:迁移完成后通过 `ALTER SEQUENCE ... RESTART` 重置到 MAX+1
- **自增列**:自动创建序列(`_seq`)和触发器(`_trg`),插入时自动填充——与应用侧 `OracleProcessor` 的主键回填约定一致
- **序列重置**:迁移完成后重置到 MAX+1
- **分页**:使用 `ROW_NUMBER() OVER (ORDER BY 主键)` 保证稳定排序
- **字段名**:Oracle 默认大写,工具自动处理大小写转换
:::
### MySQL → SQLite 日志摘要
```bash
$ php think db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=root --src-password=root \
--dst-type=sqlite --dst-database=runtime/rxthinkcmf.thinkphp6.elevue.db
==============================================================
[迁移开始] 源: mysql:rxthinkcmf.thinkphp6.elevue → 目标: sqlite:runtime/rxthinkcmf.thinkphp6.elevue.db
[迁移开始] 待迁移 25 张表
==============================================================
[·] [1/5] 建目标库...
· 目标库 "runtime/rxthinkcmf.thinkphp6.elevue.db" 已就绪
[✓] [1/5] 建目标库 完成(耗时 0ms)
[·] [2/5] 建表结构(25 张表)...
[✓] [2/5] 建表结构 完成(耗时 1s),25 张表,118 个索引,0 个外键
[·] [3/5] 逐表迁移数据...
...(SQLite 写入速度最快)...
[✓] [3/5] 逐表迁移数据 完成(耗时 9m44s),共 785235 行
[·] [4/5] 重置自增序列...
· 当前驱动 (sqlite) 无需重置序列,跳过
[✓] [4/5] 重置自增序列 完成(耗时 0ms)
[·] [5/5] 逐表行数校验...
...(25 张表全部 PASS)...
[✓] [5/5] 逐表行数校验 完成(耗时 427ms),25 张表全部 PASS
==============================================================
[迁移完成] 成功:785235 行 / 25 张表 / 总耗时 9m46s
==============================================================
```
### 只迁移指定表日志
```bash
$ php think db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
--src-database=rxthinkcmf.thinkphp6.elevue --src-username=root --src-password=root \
--dst-type=pgsql --dst-host=127.0.0.1 --dst-hostport=5432 \
--dst-database=rxthinkcmf.thinkphp6.elevue --dst-username=postgres --dst-password=123456 \
--tables=think_user,think_role,think_menu \
$ php artisan db:migrate \
--src-type=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-database=rxthinkcmf.laravel13.elevue --src-username=root --src-password=root \
--dst-type=pgsql --dst-host=127.0.0.1 --dst-port=5432 \
--dst-database=rxthinkcmf.laravel13.elevue --dst-username=postgres --dst-password=123456 \
--tables=laravel_user,laravel_role,laravel_menu \
--drop-target-first
==============================================================
[迁移开始] 源: mysql:rxthinkcmf.thinkphp6.elevue → 目标: pgsql:rxthinkcmf.thinkphp6.elevue
[迁移开始] 待迁移 3 张表
==============================================================
[·] [1/5] 建目标库...
· 目标库 "rxthinkcmf.thinkphp6.elevue" 已就绪
[✓] [1/5] 建目标库 完成(耗时 0ms)
[·] [2/5] 建表结构(3 张表)...
· 已删除目标表: think_user
· 已删除目标表: think_role
· 已删除目标表: think_menu
· 已删除目标表: laravel_user
· 已删除目标表: laravel_role
· 已删除目标表: laravel_menu
[✓] [2/5] 建表结构 完成(耗时 85ms),3 张表,5 个索引,0 个外键
[·] [3/5] 逐表迁移数据...
· [1/3] 正在迁移 think_user...
[think_user] +22(累计 22)
[✓ think_user] 共 22 行(耗时 9ms)
· [2/3] 正在迁移 think_role...
[think_role] +24(累计 24)
[✓ think_role] 共 24 行(耗时 5ms)
· [3/3] 正在迁移 think_menu...
[think_menu] +228(累计 228)
[✓ think_menu] 共 228 行(耗时 12ms)
· [1/3] 正在迁移 laravel_user...
[laravel_user] +22(累计 22)
[✓ laravel_user] 共 22 行(耗时 9ms)
...(其余表类似)...
[✓] [3/5] 逐表迁移数据 完成(耗时 30ms),共 274 行
[·] [4/5] 重置自增序列...
[✓] [4/5] 重置自增序列 完成(耗时 25ms)
[·] [5/5] 逐表行数校验...
[think_user] source=22 target=22 PASS
[think_role] source=24 target=24 PASS
[think_menu] source=228 target=228 PASS
[laravel_user] source=22 target=22 PASS
[laravel_role] source=24 target=24 PASS
[laravel_menu] source=228 target=228 PASS
[✓] [5/5] 逐表行数校验 完成(耗时 60ms),3 张表全部 PASS
==============================================================
[迁移完成] 成功:274 行 / 3 张表 / 总耗时 250ms
@@ -522,8 +449,6 @@ SET IDENTITY_INSERT [表名] OFF
```sql
-- 重置标识种子到指定值,下一次插入自动加一
-- 第一个参数:表名(带方括号)
-- 第三个参数:RESEED 值(当前最大 ID)
DBCC CHECKIDENT ('[表名]', RESEED, {$maxId})
```
@@ -557,7 +482,6 @@ CREATE SEQUENCE "TABLE_ID_SEQ" START WITH 1 INCREMENT BY 1 NOCACHE;
-- 创建触发器(插入前自动取序列值填充自增列)
-- 触发器名规则:表名_列名_TRG(大写)
-- IF :NEW.COL IS NULL 判断:允许显式传值,仅当未指定时才自动取序列
-- :NEW 引用的列名必须大写且不带引号
CREATE OR REPLACE TRIGGER "TABLE_ID_TRG"
BEFORE INSERT ON "TABLE" FOR EACH ROW
BEGIN
@@ -567,11 +491,14 @@ BEGIN
END;
```
:::tip 与应用侧的衔接
序列命名 `{表}_{列}_seq`(大写)与应用侧 `App\Support\Database\OracleProcessor` 的 `insertGetId` 回填约定完全一致(见 [5.16 统一响应与异常抛出](/zh/core/exception.html) 相关说明与 [4.9 数据库设计](/zh/architecture/database-design.html))。
:::
### 序列重置
```sql
-- 重置序列到 MAX(id)+1
-- ALTER SEQUENCE ... RESTART:将序列起点设置为指定值,下一次 nextval 返回该值
ALTER SEQUENCE "TABLE_ID_SEQ" RESTART START WITH 1234;
```
@@ -581,38 +508,50 @@ Oracle 建库实际上是通过 `CREATE USER` + `GRANT CONNECT, RESOURCE` 创建
## 驱动安装与环境配置
迁移工具基于 PDO 直连数据库,各驱动需要对应的 PDO 扩展。XAMPP 默认只自带 MySQL、PostgreSQL 和 SQLite 驱动,SQL Server 和 Oracle 需手动安装。
迁移工具基于 PDO 直连数据库,各驱动需要对应的 PDO 扩展。Laragon 默认自带 MySQL、PostgreSQL 和 SQLite 驱动,SQL Server 和 Oracle 需手动安装。
运行以下命令检查已安装的驱动:
```bash
# 检查已安装的 PDO 驱动(-m 列出扩展,findstr /i 不区分大小写过滤)
D:\xampp8.2\php\php.exe -m | findstr /i pdo
$PHP_HOME\php.exe -m | findstr /i pdo
# 列出所有可用的 PDO 驱动
$PHP_HOME\php.exe -r "print_r(PDO::getAvailableDrivers());"
```
### MySQL(XAMPP 默认自带)
### MySQL(Laragon 默认自带)
无需额外操作。验证:
```bash
# 检查 MySQL 驱动是否已加载
D:\xampp8.2\php\php.exe -m | findstr mysql
$PHP_HOME\php.exe -m | findstr mysql
```
### PostgreSQL(XAMPP 默认自带)
### PostgreSQL(DLL 随包自带,php.ini 默认注释)
无需额外操作。验证:
`ext\php_pdo_pgsql.dll` 与 `libpq.dll` 已随 PHP 包附带,但 `$PHP_HOME\php.ini` 扩展区的
`extension=pdo_pgsql` 默认是注释状态,**需去掉分号启用**:
```ini
;extension=pdo_odbc
extension=pdo_pgsql ← 去掉此行分号
extension=pdo_sqlite
```
验证(`pdo_pgsql` 出现即可,`PDO::getAvailableDrivers()` 应包含 `pgsql`):
```bash
# 检查 PostgreSQL 驱动是否已加载
D:\xampp8.2\php\php.exe -m | findstr pgsql
$PHP_HOME\php.exe -m | findstr pgsql
$PHP_HOME\php.exe -r "print_r(PDO::getAvailableDrivers());"
```
若未安装,编辑 `D:\xampp8.2\php\php.ini`,找到 `;extension=pdo_pgsql` 去掉分号启用。
CLI 每次进程都会重读 `php.ini`,无需重启;跑在 Apache(mod_php)下的需重启 Apache 才生效。
未启用时执行 `--dst-type=pgsql` 会报 `could not find driver`。
### SQL Server(需手动安装)
XAMPP 默认不带 SQL Server 驱动,需要安装两个组件。
Laragon 默认不带 SQL Server 驱动,需要安装两个组件。
:::warning 先决条件
安装 ODBC 驱动前,系统需要 Microsoft Visual C++ Redistributable。大多数 Windows 已预装,如果安装失败,先安装它再重试。
@@ -631,153 +570,118 @@ XAMPP 默认不带 SQL Server 驱动,需要安装两个组件。
从微软官方 GitHub 下载:
- 下载地址(Releases 页面):https://github.com/Microsoft/msphpsql/releases
- 下载文件:`Windows_5.12.0RTW.zip`
- 下载文件:`Windows_5.13.3RTW.zip`(本项目基于 **PHP 8.3 起步**,该包覆盖 PHP 8.3 / 8.4 / 8.5)
解压后目录结构(平铺,无子目录):
```text
Windows_5.12.0RTW/
├── php_pdo_sqlsrv_81_nts_x64.dll
├── php_pdo_sqlsrv_81_ts_x64.dll
├── php_pdo_sqlsrv_82_nts_x64.dll
├── php_pdo_sqlsrv_82_ts_x64.dll ← PHP 8.2 TS x64
├── php_pdo_sqlsrv_83_nts_x64.dll
├── php_pdo_sqlsrv_83_ts_x64.dll
├── php_sqlsrv_81_nts_x64.dll
├── php_sqlsrv_81_ts_x64.dll
├── php_sqlsrv_82_nts_x64.dll
├── php_sqlsrv_82_ts_x64.dll ← PHP 8.2 TS x64
├── php_sqlsrv_83_nts_x64.dll
├── php_sqlsrv_83_ts_x64.dll
└── ...(另有 x86 版本)
```
**文件命名规则**:`php_{驱动}_{PHP版本}_{线程安全}_{架构}.dll`
解压后目录结构(平铺,无子目录),文件命名规则:`php_{驱动}_{PHP版本}_{线程安全}_{架构}.dll`
- 驱动:`sqlsrv` 或 `pdo_sqlsrv`
- PHP 版本:`81` / `82` / `83`
- PHP 版本:`83`(PHP 8.3)/ `84`(PHP 8.4)/ `85`(PHP 8.5)
- 线程安全:`ts`(Thread Safe)/ `nts`(Non-Thread Safe)
- 架构:`x64` / `x86`
将以下两个文件复制到 `D:\xampp8.2\php\ext\`:
`Windows_5.13.3RTW.zip` 解压后共 24 个 DLL:
- `php_sqlsrv_82_ts_x64.dll`
- `php_pdo_sqlsrv_82_ts_x64.dll`
```text
php_sqlsrv_83_nts_x64.dll php_sqlsrv_83_nts_x86.dll
php_sqlsrv_83_ts_x64.dll php_sqlsrv_83_ts_x86.dll
php_sqlsrv_84_nts_x64.dll php_sqlsrv_84_nts_x86.dll
php_sqlsrv_84_ts_x64.dll php_sqlsrv_84_ts_x86.dll
php_sqlsrv_85_nts_x64.dll php_sqlsrv_85_nts_x86.dll
php_sqlsrv_85_ts_x64.dll php_sqlsrv_85_ts_x86.dll
php_pdo_sqlsrv_83_nts_x64.dll php_pdo_sqlsrv_83_nts_x86.dll
php_pdo_sqlsrv_83_ts_x64.dll php_pdo_sqlsrv_83_ts_x86.dll
php_pdo_sqlsrv_84_nts_x64.dll php_pdo_sqlsrv_84_nts_x86.dll
php_pdo_sqlsrv_84_ts_x64.dll php_pdo_sqlsrv_84_ts_x86.dll
php_pdo_sqlsrv_85_nts_x64.dll php_pdo_sqlsrv_85_nts_x86.dll
php_pdo_sqlsrv_85_ts_x64.dll php_pdo_sqlsrv_85_ts_x86.dll
```
:::tip 版本匹配(PHP 8.2 + TS + x64)
- PHP 版本:`82` = PHP 8.2
- 线程安全:XAMPP 默认 TS(运行 `php -v` 确认,输出含 `Thread Safety => enabled`)
将以下两个文件复制到 `$PHP_HOME\ext\`(本项目用 **PHP 8.3 TS x64**):
- `php_sqlsrv_83_ts_x64.dll`
- `php_pdo_sqlsrv_83_ts_x64.dll`
:::tip 版本匹配(PHP 8.3 + TS + x64)
- PHP 版本:`83` = PHP 8.3
- 线程安全:Laragon 的 Apache 用 TS(运行 `$PHP_HOME\php.exe -i | findstr "Thread Safety"` 确认 → `enabled`)
- 架构:`x64`(64 位系统)
:::
**步骤 3:在 php.ini 中启用扩展**
编辑 `D:\xampp8.2\php\php.ini`(注意不是 `apache\bin\php.ini`):
编辑 `$PHP_HOME\php.ini`:
1. 启用前置扩展:找到 `;extension=mbstring` 去掉分号(sqlsrv 依赖 mbstring)
2. 在 mbstring 之后添加以下两行(文件名必须与 ext 目录中的实际文件一致):
```ini
; SQL Server PHP 扩展(必须在 mbstring 之后加载)
extension=php_sqlsrv_82_ts_x64.dll ; SQL Server 核心驱动
extension=php_pdo_sqlsrv_82_ts_x64.dll ; SQL Server PDO 驱动(迁移工具使用)
extension=php_sqlsrv_83_ts_x64.dll ; SQL Server 核心驱动
extension=php_pdo_sqlsrv_83_ts_x64.dll ; SQL Server PDO 驱动(迁移工具使用)
```
3. 保存后重启 XAMPP 中的 Apache 服务(命令行验证无需重启 Apache,只需重新运行 php 命令)
3. 保存后重启 Apache(命令行验证无需重启 Apache,只需重新运行 php 命令)
**步骤 4:验证安装**
```bash
# 检查 SQL Server 驱动是否已加载(应看到 sqlsrv 和 pdo_sqlsrv 两行)
D:\xampp8.2\php\php.exe -m | findstr sqlsrv
$PHP_HOME\php.exe -m | findstr sqlsrv
```
应看到 `sqlsrv` 和 `pdo_sqlsrv` 两行输出。
### Oracle(需手动配置)
XAMPP 8.2 已自带 `php_pdo_oci.dll`(位于 `D:\xampp8.2\php\ext\`)。迁移工具基于 PDO 连接数据库,需要 `pdo_oci` 扩展,只需安装 Oracle Instant Client 并在 php.ini 中启用 `pdo_oci` 即可。
迁移工具基于 PDO 连接数据库,需要 `pdo_oci` 扩展,需安装 Oracle Instant Client 并在 php.ini 中启用 `pdo_oci`(完整图文步骤见 [2.1 开发环境准备](/zh/quickstart/environment.html)「数据库安装 → Oracle」)。
**步骤 1:安装 Oracle Instant Client**
**步骤摘要:**
pdo_oci 扩展依赖 Oracle 客户端库,必须先安装。
- 下载地址:https://www.oracle.com/database/technologies/instant-client/downloads.html
- 版本选择:下载 Basic Package,选择 x64 版本,推荐 19c(与 XAMPP 自带的 DLL 对应)
- 安装:
1. 下载 `instantclient-basic-windows.x64-19.20.0.0.0dbru.zip`
2. 解压到固定路径,如 `C:\oracle\instantclient_19_20`(⚠️ 避免空格与中文)
3. 配置系统环境变量:
- 将 `C:\oracle\instantclient_19_20` 添加到系统 `PATH`
- 新建系统变量 `TNS_ADMIN` = `C:\oracle\instantclient_19_20`(可选,用于 tnsnames.ora)
4. 重启终端或重启电脑使环境变量生效
1. 安装 Oracle Instant Client(Basic Package,x64,推荐 19c),解压到固定路径 `C:\oracle\instantclient_19_20`(⚠️ 避免空格与中文)
2. 将该路径添加到系统 `PATH`
3. 安装 VC++ 2015-2022 Redistributable(x64):https://aka.ms/vc14/vc_redist.x64.exe
4. 编辑 `$PHP_HOME\php.ini`,启用 `extension=pdo_oci`
5. 完全重启 Laragon(不是只重启 Apache)
:::warning 安装 VC++ 运行库
Oracle Instant Client 19c 依赖 VC++ 2015-2022 Redistributable,缺少会导致 DLL 加载失败。
下载地址:https://aka.ms/vc14/vc_redist.x64.exe
:::
**步骤 2:在 php.ini 中启用扩展**
编辑 `D:\xampp8.2\php\php.ini`(注意不是 `apache\bin\php.ini`):
找到以下行:
```ini
;extension=pdo_oci ; Oracle PDO 驱动(默认被注释)
```
去掉前面的分号,改为:
```ini
extension=pdo_oci ; 启用 Oracle PDO 驱动(迁移工具依赖此扩展)
```
保存后重启 XAMPP(完全关闭再启动,不是只重启 Apache)。
**步骤 3:验证安装**
命令行验证:
**验证安装:**
```bash
# 检查 pdo_oci 扩展是否已加载
D:\xampp8.2\php\php.exe -m | findstr -i "pdo_oci"
$PHP_HOME\php.exe -m | findstr -i "pdo_oci"
# 列出所有可用的 PDO 驱动(应包含 oci)
D:\xampp8.2\php\php.exe -r "print_r(PDO::getAvailableDrivers());"
$PHP_HOME\php.exe -r "print_r(PDO::getAvailableDrivers());"
```
应看到 `pdo_oci` 输出,且 `PDO::getAvailableDrivers()` 返回的数组中包含 `oci`。
phpinfo() 验证:创建 `<?php phpinfo(); ?>` 放入 htdocs,浏览器访问后搜索 `pdo_oci`,能看到 PDO drivers 中有 `oci` 且 Oracle Instant Client 版本号显示即成功。
### SQLite(XAMPP 默认自带)
### SQLite(默认自带)
无需额外操作。验证:
```bash
# 检查 SQLite 驱动是否已加载
D:\xampp8.2\php\php.exe -m | findstr sqlite
$PHP_HOME\php.exe -m | findstr sqlite
```
### 驱动安装速查表
| 驱动 | XAMPP 默认 | 安装方式 | 验证命令 |
| 驱动 | Laragon 默认 | 安装方式 | 验证命令 |
|------|-----------|----------|----------|
| pdo_mysql | ✅ 自带 | 无需安装 | `php -m \| findstr mysql` |
| pdo_pgsql | ✅ 自带 | php.ini 启用 | `php -m \| findstr pgsql` |
| pdo_sqlsrv | ❌ 需安装 | ODBC 驱动 + PECL 扩展 | `php -m \| findstr sqlsrv` |
| pdo_oci | ⚠️ DLL 自带,需启用 | InstantClient + VC++ 运行库 + php.ini 启用 | `php -m \| findstr pdo_oci` |
| pdo_sqlsrv | ❌ 需安装 | ODBC 驱动 + 扩展 DLL(83_ts_x64) | `php -m \| findstr sqlsrv` |
| pdo_oci | ❌ 需配置 | InstantClient + VC++ 运行库 + php.ini 启用 | `php -m \| findstr pdo_oci` |
| pdo_sqlite | ✅ 自带 | 无需安装 | `php -m \| findstr sqlite` |
## 相关代码
| 文件 | 说明 |
|------|------|
| `app/command/DbMigrateCommand.php` | CLI 命令入口(参数解析、退出码) |
| `app/service/DbMigrateService.php` | 迁移引擎服务(连接、建库、建表、数据迁移、序列重置、行数校验) |
| `app/service/DbSchemaBuilder.php` | DDL 生成器(类型映射、CREATE TABLE / INDEX / FK / COMMENT 语句生成) |
| `config/console.php` | 命令注册 |
| `app/Console/Commands/DbMigrateCommand.php` | CLI 命令入口(参数解析、退出码,Laravel 自动发现注册) |
| `app/Services/DbMigrateService.php` | 迁移引擎服务(连接、建库、建表、数据迁移、序列重置、行数校验) |
| `app/Services/DbSchemaBuilder.php` | DDL 生成器(类型映射、CREATE TABLE / INDEX / FK / COMMENT 语句生成) |
## 注意事项
@@ -789,7 +693,7 @@ D:\xampp8.2\php\php.exe -m | findstr sqlite
5. `--drop-target-first` 会删除目标表的全部数据(含索引、外键和注释),请谨慎使用
6. 大表迁移建议调大 `--batch-size`(如 5000)以提高效率
7. 迁移过程中如遇中断,已提交的 `--batch-size` 批次数据不会回滚,建议使用 `--drop-target-first` 重试
8. 含点号的数据库名(如 `rxthinkcmf.thinkphp6.elevue`)各驱动均已适配
8. 含点号的数据库名(如 `rxthinkcmf.laravel13.elevue`)各驱动均已适配
9. 索引、外键和注释在建表阶段同步迁移,使用 `--drop-target-first` 重建时会一并重建
10. 外键约束在所有表创建后统一添加,避免引用顺序问题
11. 命令退出码:成功返回 0,失败或行数校验未通过返回 1(可用于 CI/CD 判断)
@@ -803,9 +707,10 @@ A: 使用 `--drop-target-first` 选项重新执行,会先删除目标表再重
### Q: 如何只迁移部分表?
A: 使用 `--tables` 选项指定表名,逗号分隔:
A: 使用 `--tables` 选项指定表名,逗号分隔(不含前缀):
```bash
php think db:migrate ... --tables=think_user,think_role
php artisan db:migrate ... --tables=laravel_user,laravel_role
```
### Q: 大表迁移很慢怎么办?
@@ -816,9 +721,15 @@ A: 调大 `--batch-size`,如 `--batch-size=5000`。默认 1000 行一批,增
A: 会。所有驱动(MySQL / PostgreSQL / SQL Server / Oracle / SQLite)在目标库不存在时都会自动创建,无需手动建库。建库时会自动从源库读取 collation 并应用(仅当源与目标驱动相同时)。
PostgreSQL 走维护库 `postgres` 执行 `CREATE DATABASE`,含点号的库名会自动加双引号(如 `"rxthinkcmf.laravel13.elevue"`);建库失败时抛出 `创建目标数据库失败: …` 并把原始异常保留在 previous 中。
### Q: 报 `could not find driver` 怎么办?
A: PHP 缺少目标驱动的 PDO 扩展。迁到 PostgreSQL 需要 `pdo_pgsql`:在 `$PHP_HOME\php.ini` 扩展区去掉 `extension=pdo_pgsql` 的分号(DLL 随包自带,无需下载),CLI 重读 `php.ini` 即生效;Linux 用 `apt install php8.3-pgsql`。可用 `$PHP_HOME\php.exe -r "print_r(PDO::getAvailableDrivers());"` 确认驱动已加载。
### Q: PostgreSQL 报 SQLSTATE 08006 错误怎么办?
A: `08006` 是连接异常的通用错误码,在中文环境下错误信息可能乱码。工具会主动连接 `postgres` 维护库查询 `pg_database` 确认目标库是否不存在。如果确认不存在会自动建库;如果维护库也连不上,请检查 PostgreSQL 服务是否启动、端口是否正确、密码是否正确。
A: `08006` 是连接异常的通用错误码,服务端消息可能是英文(`database "x" does not exist`)或中文(`数据库 "x" 不存在`),中文环境下控制台还可能出现乱码(先 `chcp 65001` 或换 Windows Terminal 查看)。工具对两种语言的消息都会识别,并**主动连接 `postgres` 维护库查询 `pg_database`** 权威确认目标库是否缺失:确认缺失则自动建库后重连;维护库也连不上时按原始异常抛出,请检查 PostgreSQL 服务是否启动、端口是否正确、密码是否正确。
### Q: SQL Server 报"登录失败"怎么办?
@@ -827,11 +738,12 @@ A: SQL Server 数据库不存在时会返回"登录失败"(SQLSTATE 28000)
### Q: 索引、外键、注释、默认值会迁移吗?
A: 会。迁移工具在建表阶段会同步迁移以下元数据:
- **索引**:自动读取源表的非主键索引(UNIQUE / 普通 / 全文),在目标表创建后执行 `CREATE INDEX`
- **外键**:自动读取源表的外键约束,在所有表创建后统一添加 `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY`,避免引用顺序问题
- **字段注释**:MySQL 目标直接写入 `COMMENT` 子句;PostgreSQL / Oracle 使用 `COMMENT ON COLUMN`;SQL Server 使用 `sp_addextendedproperty`
- **表注释**:MySQL 使用 `ALTER TABLE COMMENT`;PostgreSQL / Oracle 使用 `COMMENT ON TABLE`
- **默认值**:完整保留,包括空字符串 `DEFAULT ''`、数值默认值、`CURRENT_TIMESTAMP`、`CURRENT_DATE`、函数调用(如 `uuid_generate_v4()`)等
- **默认值**:完整保留,包括空字符串 `DEFAULT ''`、数值默认值、`CURRENT_TIMESTAMP`、`CURRENT_DATE`、函数调用等
- **enum/set**:自动迁移候选值,生成 `CHECK` 约束
- **decimal/datetime 精度**:保留精度参数(如 `decimal(10,2)` → `numeric(10,2)`,`datetime(3)` → `timestamp(3)`)
- **collation**:建库时自动应用源库 collation(仅同驱动时);建表时迁移表级和列级 collation(MySQL / PostgreSQL)
@@ -839,6 +751,7 @@ A: 会。迁移工具在建表阶段会同步迁移以下元数据:
### Q: 数据迁移的可靠性如何保证?
A: 迁移工具有以下保障机制:
- **事务保护**:每个 `--batch-size`(默认 1000 行)为一个独立事务,提交后即生效;当前批次失败时回滚,已提交的历史批次不受影响
- **稳定排序**:分页查询基于主键列 `ORDER BY`,确保大表分批迁移不会漏行或重复
- **退出码**:命令成功返回 0,失败或行数校验未通过返回 1,可用于 CI/CD 自动化判断
@@ -846,12 +759,16 @@ A: 迁移工具有以下保障机制:
### Q: Oracle 自增列如何处理?
A: Oracle 没有原生自增语法。迁移工具会自动为自增列创建序列(`CREATE SEQUENCE`)和触发器(`CREATE TRIGGER`),实现等效的自增功能。
A: Oracle 没有原生自增语法。迁移工具会自动为自增列创建序列(`CREATE SEQUENCE`)和触发器(`CREATE TRIGGER`),实现等效的自增功能;序列命名与应用侧 `OracleProcessor` 主键回填约定一致。
### Q: 含点号的数据库名支持吗?
A: 支持。如 `rxthinkcmf.thinkphp6.elevue` 这类含点号的库名,各驱动均已适配。
A: 支持。如 `rxthinkcmf.laravel13.elevue` 这类含点号的库名,各驱动均已适配。
### Q: SQLite 作为目标有什么限制?
A: SQLite 不支持字段注释、表注释和 collation。迁移完成后数据完整,索引和外键正常迁移。
### Q: 与 document/ 下的 SQL 脚本有何区别?
A: `document/` 下的五套 SQL 脚本是**静态初始化脚本**(建库 + 初始数据,见 [2.5 数据库初始化](/zh/quickstart/database-init.html));`db:migrate` 是**活的迁移工具**,用于把一个已有数据库完整搬到另一个数据库(结构 + 数据 + 元数据),两者互补。
+143 -187
View File
@@ -1,7 +1,7 @@
# 7.4 数据导入导出
:::tip 概述
ExcelService 基于 PhpSpreadsheet 封装导入导出能力,支持普通导入导出和分批导出(大数据量)。导入支持表头映射,导出支持枚举显示名自动翻译。
`ExcelService` 基于 PhpSpreadsheet(`phpoffice/phpspreadsheet ^5.10`)封装导入导出能力:`importWithMap` 表头映射导入、`export` 一次性导出、`chunkExport` 分批导出(大数据量)。导入支持枚举文字反查,导出支持字典显示名自动翻译。
:::
## 导入流程
@@ -12,38 +12,41 @@ ExcelService 基于 PhpSpreadsheet 封装导入导出能力,支持普通导入
前端上传 Excel 文件
│
▼
Controller::import()
│ 校验文件扩展名(xls/xlsx)
Controller::_import($service) ← 基类快捷方法
│ 1. 文件存在校验("请选择文件")
│ 2. 扩展名校验(仅 xls / xlsx)
│ 3. 大小校验(10MB 上限)
│ 4. MIME 类型真实检测(finfo)
▼
ExcelService::importWithMap($filePath, $headerMap)
│ 读取 Excel → 按表头映射转为关联数组
▼
Logic::import()
Service::import($filePath)
│
├─ ExcelService::importWithMap($filePath, $headerMap)
│ 读取活动工作表 → 第一行表头按 headerMap 映射为字段名
│ 第二行起逐行取值,跳过整行为空的行
│
├─ 枚举字段反向映射(文字 → 数字)
│ DictService::getValue('gender', '男') → '1'
│ DictCacheService::getValue('example_status', '在用') → '1'
│
├─ 逐行调用 $this->add($row)
│ ├─ 成功 → 计数 +1
│ └─ 失败 → 收集错误(行号 + 原因)
│ └─ 失败 → 收集错误(行号 + 字段 + 原因),不中断整体
│
▼
返回 { count: 成功数, errors: [失败行列表] }
```
### Logic 层实现
### Service 层实现(来自项目实际代码)
```php
// ExampleLogic::import()
// ExampleService::import()
public function import(string $filePath): array
{
// 1. 定义表头映射:Excel 表头 → 数据库字段
$headerMap = [
'案例名称' => 'name',
'案例类型' => 'type',
'案例状态' => 'status',
'案例排序' => 'sort',
'名称' => 'name',
'状态' => 'status',
'排序' => 'sort',
];
// 2. 解析 Excel 为关联数组
@@ -51,11 +54,8 @@ public function import(string $filePath): array
// 3. 枚举字段反向映射:文字 → 数字
foreach ($data as &$row) {
if (!empty($row['type']) && !is_numeric($row['type'])) {
$row['type'] = DictService::getValue('example_type', $row['type']);
}
if (!empty($row['status']) && !is_numeric($row['status'])) {
$row['status'] = DictService::getValue('example_status', $row['status']);
if (! empty($row['status']) && ! is_numeric($row['status'])) {
$row['status'] = DictCacheService::getValue('example_status', $row['status']);
}
}
unset($row);
@@ -65,11 +65,12 @@ public function import(string $filePath): array
$errors = [];
foreach ($data as $index => $row) {
try {
$this->add($row); // 调用 BaseLogic::add(),触发 beforeAdd/validateData/afterAdd
$this->add($row); // 走完整 add 流程(钩子 / 唯一校验 / 事务)
$count++;
} catch (\Exception $e) {
$errors[] = [
'row' => $index + 2, // +2 因为第1行是表头,索引从0开始
'row' => $index + 2, // +2 因为第 1 行是表头,索引从 0 开始
'name' => $row['name'] ?? '',
'reason' => $e->getMessage(),
];
}
@@ -79,39 +80,26 @@ public function import(string $filePath): array
}
```
### Controller 层实现
### Controller 层实现(基类快捷方法)
```php
// ExampleController::import()
#[Log('案例演示-导入数据', Log::TYPE_IMPORT)]
#[Permission('sys:example:import', '导入案例演示')]
public function import(): Json
// 控制器无需手写文件校验 —— _import() 已内置
#[OperationLog('示例管理-导入数据', OperationLog::TYPE_IMPORT, '通过Excel导入示例数据')]
#[Permission('sys:example:import', '导入示例')]
public function import(): JsonResponse
{
$file = $this->request->file('file');
if (!$file) {
return $this->fail('请选择文件');
}
$ext = strtolower($file->getOriginalExtension());
if (!in_array($ext, ['xls', 'xlsx'])) {
return $this->fail('仅支持 xls、xlsx 格式的文件');
}
try {
$result = $this->logic->import($file->getRealPath());
$msg = empty($result['errors'])
? "成功导入 {$result['count']} 条数据"
: "成功导入 {$result['count']} 条,失败 " . count($result['errors']) . " 条";
return $this->success($result, $msg);
} catch (\Exception $e) {
return $this->fail('导入失败: ' . $e->getMessage());
}
return $this->_import($this->exampleService);
}
```
`Controller::_import()` 内置校验顺序(见 [3.6 控制器开发](/zh/guide/controller.html)):
1. 文件是否存在(`"请选择文件"`)
2. 扩展名白名单(仅 `xls`、`xlsx`)
3. 大小上限(10MB)
4. MIME 真实检测(`finfo`,防伪造扩展名)
5. 调用 `Service::import()`,按 `errors` 是否为空组装成功/部分失败消息
### 导入响应示例
**全部成功:**
@@ -120,10 +108,7 @@ public function import(): Json
{
"code": 0,
"msg": "成功导入 10 条数据",
"data": {
"count": 10,
"errors": []
}
"data": { "count": 10, "errors": [] }
}
```
@@ -136,8 +121,8 @@ public function import(): Json
"data": {
"count": 8,
"errors": [
{ "row": 3, "reason": "标题不能为空" },
{ "row": 7, "reason": "编码已存在" }
{ "row": 3, "name": "示例A", "reason": "名称已存在" },
{ "row": 7, "name": "", "reason": "案例名称不能为空" }
]
}
}
@@ -145,79 +130,89 @@ public function import(): Json
## 导出流程
### 分批导出(推荐)
### 一次性导出(中小数据量,来自项目实际代码)
```php
// ExampleLogic::export()
// ExampleService::export()
public function export(array $params = []): string
{
// 1. 定义导出表头:字段名 → Excel 表头
// 枚举字段使用 {字段名}Text(自动翻译后的字段)
$list = $this->allList($params);
$headers = [
'name' => '案例名称',
'typeText' => '案例类型', // 枚举显示名
'statusText' => '案例状态', // 枚举显示名
'sort' => '案例排序',
'create_time' => '创建时间',
'name' => '名称',
'statusText' => '状态', // 枚举显示名(serializeMaps 已补全)
'sort' => '排序',
'create_user' => '创建人',
'create_time' => '创建时间',
];
// 2. 分批导出(每批 500 条)
return ExcelService::chunkExport($headers, function (int $page) use ($params) {
$chunk = [];
$this->chunkList($params, function (array $data) use (&$chunk) {
$chunk = $data;
}, 500);
return $chunk;
}, '案例演示数据.xlsx', 500);
return ExcelService::export($list, $headers, '示例数据.xlsx');
}
```
### 分批导出(大数据量)
```php
// ExcelService::chunkExport —— 数据由回调按页提供,取空即终止
return ExcelService::chunkExport(
$headers,
function (int $page) use ($params): array {
// 按页取数(示例:每页 500 条,返回空数组即导出结束)
$list = $this->pageList(array_merge($params, ['pageNo' => $page, 'pageSize' => 500]));
return $list['records'] ?? [];
},
'导出数据.xlsx'
);
```
:::tip 终止条件
`chunkExport` 的终止条件是 **`$chunkProvider` 返回空数组**——不依赖条数与页大小的关系,过滤后数量不固定也能正确处理。
:::
### Controller 层实现
```php
// ExampleController::export()
#[Log('案例演示-导出数据', Log::TYPE_EXPORT)]
#[Permission('sys:example:export', '导出案例演示')]
public function export(): Json
#[OperationLog('示例管理-导出数据', OperationLog::TYPE_EXPORT)]
#[Permission('sys:example:export', '导出示例')]
public function export(): JsonResponse
{
try {
$filePath = $this->logic->export($this->getParams());
return $this->success($filePath, '导出成功');
} catch (\Exception $e) {
return $this->fail('导出失败: ' . $e->getMessage());
}
return $this->_export($this->exampleService); // 返回文件地址
}
```
### 一次性导出(小数据量)
### 导出文件
```php
// 适用于数据量较小(< 1000 条)的场景
$data = $this->allList($params);
return ExcelService::export($data, $headers, '导出数据.xlsx');
- **存放位置**:`{upload_dir}/temp/{Ymd}/`(`upload_dir` 取 `config/file.php`,支持绝对路径)
- **文件名**:`原文件名_YmdHis.xlsx`,同秒重复导出自动追加 `_1`、`_2` 序号,避免同名文件被占用
- **返回值**:`file.domain_url` 拼接的完整地址,接口 `data` 直接返回该地址字符串,前端 `window.open(res)` 即可下载
**导出响应示例:**
```json
{
"code": 0,
"ok": true,
"msg": "导出成功",
"data": "http://file.laravel13.elevue/temp/20261006/用户数据_20261006050143.xlsx"
}
```
## ExcelService 方法清单
| 方法 | 说明 | 参数 | 返回值 |
|------|------|------|--------|
| `import($filePath, $sheetIndex, $startRow)` | 原始导入 | 文件路径, Sheet索引, 起始行 | `array` 二维数组(数字下标) |
| `importWithMap($filePath, $headerMap)` | 映射导入 | 文件路径, 表头映射 | `array` 关联数组列表 |
| `export($data, $headers, $fileName)` | 一次性导出 | 数据, 表头映射, 文件名 | `string` 文件 URL |
| `chunkExport($headers, $callback, $fileName, $pageSize)` | 分批导出 | 表头映射, 回调函数, 文件名, 每批数量 | `string` 文件 URL |
| 方法 | 说明 | 返回值 |
|------|------|--------|
| `importWithMap($filePath, $headerMap)` | 表头映射导入(未声明的列忽略、缺少的列补空串、跳过空行) | `array` `[字段名 => 值, ...]` |
| `export($data, $headers, $fileName)` | 一次性导出 | `string` 文件 URL |
| `chunkExport($headers, $chunkProvider, $fileName)` | 分批导出(回调 `fn(int $page): array`,返回空数组终止) | `string` 文件 URL |
### importWithMap 参数详解
```php
ExcelService::importWithMap($filePath, $headerMap);
```
| 参数 | 类型 | 说明 |
|------|------|------|
| `$filePath` | string | Excel 文件的绝对路径 |
| `$headerMap` | array | `[Excel表头 => 数据库字段]` 映射 |
| `$headerMap` | array | `['Excel表头' => '数据库字段']` 映射 |
**表头映射示例:**
@@ -232,26 +227,21 @@ $headerMap = [
### chunkExport 参数详解
```php
ExcelService::chunkExport($headers, $callback, $fileName, $pageSize);
```
| 参数 | 类型 | 说明 |
|------|------|------|
| `$headers` | array | `[字段名 => Excel表头]` 映射 |
| `$callback` | callable | 分批回调,接收 `$page` 参数,返回该批数据 |
| `$fileName` | string | 导出文件名 |
| `$pageSize` | int | 每批数据量(默认 500) |
| `$headers` | array | `['字段名' => '中文表头']` 映射 |
| `$chunkProvider` | callable | 分页取数回调 `fn(int $page): array`,返回空数组终止 |
| `$fileName` | string | 导出文件名(默认 `export.xlsx`) |
## 枚举字段处理
### 导入时:文字 → 值
```php
// Excel 中"男" → 数据库中 1
// Excel 中"在用" → 数据库中 1
foreach ($data as &$row) {
if (!empty($row['gender']) && !is_numeric($row['gender'])) {
$row['gender'] = DictService::getValue('gender', $row['gender']);
if (! empty($row['status']) && ! is_numeric($row['status'])) {
$row['status'] = DictCacheService::getValue('example_status', $row['status']);
}
}
```
@@ -259,21 +249,13 @@ foreach ($data as &$row) {
### 导出时:值 → 文字
```php
// 配置 serializeMaps 后自动翻译
/**
* 枚举显示名映射
*
* @var array
*/
// 配置 serializeMaps 后自动翻译(HasSerializeMaps)
protected array $serializeMaps = [
'gender' => 'gender',
'status' => 'example_status',
];
/**
* 导出表头:使用 Text 后缀字段,自动翻译枚举值
*/
// 导出表头:使用 Text 后缀字段,自动翻译枚举值
$headers = [
'name' => '姓名',
'genderText' => '性别',
@@ -286,8 +268,7 @@ $headers = [
### 导入
```php
// UserLogic::import()
// UserService::import()(形态示例)
public function import(string $filePath): array
{
$headerMap = [
@@ -303,72 +284,41 @@ public function import(string $filePath): array
// 枚举反向映射
foreach ($data as &$row) {
if (!empty($row['gender']) && !is_numeric($row['gender'])) {
$row['gender'] = DictService::getValue('gender', $row['gender']);
if (! empty($row['gender']) && ! is_numeric($row['gender'])) {
$row['gender'] = DictCacheService::getValue('gender', $row['gender']);
}
if (!empty($row['status']) && !is_numeric($row['status'])) {
$row['status'] = DictService::getValue('user_status', $row['status']);
if (! empty($row['status']) && ! is_numeric($row['status'])) {
$row['status'] = DictCacheService::getValue('user_status', $row['status']);
}
}
unset($row);
$count = 0;
$errors = [];
foreach ($data as $index => $row) {
try {
$this->add($row);
$count++;
} catch (\Exception $e) {
$errors[] = [
'row' => $index + 2,
'username' => $row['username'] ?? '',
'reason' => $e->getMessage(),
];
}
}
return ['count' => $count, 'errors' => $errors];
// 逐行 add(),失败明细汇总(同 ExampleService::import 模式)
// ...
}
```
### 导出
```php
// UserLogic::export()
// UserService::export()(形态示例)
public function export(array $params = []): string
{
$headers = [
'username' => '用户名',
'realname' => '姓名',
'genderText' => '性别',
'mobile' => '手机号',
'email' => '邮箱',
'statusText' => '状态',
'deptName' => '部门',
'levelName' => '职级',
'positionName'=> '岗位',
'create_time' => '创建时间',
'username' => '用户名',
'realname' => '姓名',
'genderText' => '性别',
'mobile' => '手机号',
'email' => '邮箱',
'statusText' => '状态',
'deptName' => '部门',
'levelName' => '职级',
'positionName' => '岗位',
'create_time' => '创建时间',
];
// 预加载名称映射(避免 N+1 查询)
$levelMap = Level::getNameMap();
$positionMap = Position::getNameMap();
$deptMap = Dept::getNameMap();
return ExcelService::chunkExport($headers, function (int $page) use ($params, $levelMap, $positionMap, $deptMap) {
$chunk = [];
$this->chunkList($params, function (array $data) use (&$chunk, $levelMap, $positionMap, $deptMap) {
foreach ($data as &$item) {
$item['deptName'] = $deptMap[$item['dept_id']] ?? '';
$item['levelName'] = $levelMap[$item['level_id']] ?? '';
$item['positionName'] = $positionMap[$item['position_id']] ?? '';
}
unset($item);
$chunk = $data;
}, 500);
return $chunk;
}, '用户数据.xlsx', 500);
// afterPageList 已批量补全 deptName/levelName/positionName
return ExcelService::export($this->allList($params), $headers, '用户数据.xlsx');
}
```
@@ -382,7 +332,6 @@ public function export(array $params = []): string
:action="'/api/example/import'"
:headers="{ Authorization: `Bearer ${token}` }"
:on-success="handleImportSuccess"
:on-error="handleImportError"
accept=".xls,.xlsx"
>
<el-button type="primary">导入</el-button>
@@ -397,9 +346,9 @@ const handleImportSuccess = (response) => {
ElMessage.success(`成功导入 ${count} 条数据`);
} else {
ElMessage.warning(`成功 ${count} 条,失败 ${errors.length} 条`);
// 可以展示错误详情
// 可展开 errors 查看行号与原因
}
refreshTable(); // 刷新列表
refreshTable();
}
};
</script>
@@ -410,8 +359,8 @@ const handleImportSuccess = (response) => {
```typescript
// 导出接口返回文件 URL,前端直接下载
const handleExport = async () => {
const res = await exportExample(params);
window.open(res); // res 是文件 URL
const res = await exampleExport(params); // 文件地址
window.open(res);
};
```
@@ -420,32 +369,39 @@ const handleExport = async () => {
| 场景 | 方式 | 说明 |
|------|------|------|
| 数据量 < 1000 | `export()` 一次性导出 | 简单直接 |
| 数据量 ≥ 1000 | `chunkExport()` 分批导出 | 避免内存溢出 |
| 导入大量数据 | 逐行调用 `add()` | 单行失败不影响其他行 |
| 关联字段导出 | 预加载名称映射 | 避免 N+1 查询 |
| 数据量 ≥ 1000 | `chunkExport()` 分批导出 | 回调按页取数,避免内存溢出 |
| 导入大量数据 | 逐行调用 `add()` | 单行失败不影响其他行(事务隔离) |
| 关联字段导出 | 依赖 `afterPageList` 补全名称 | 批量映射避免 N+1 查询 |
| PHP 内存 | `memory_limit=256M` | Docker 镜像已配置 |
## 常见问题
### 问题 1:导入提示"文件格式不正确"
### 问题 1:导入提示"仅支持 xls、xlsx 格式的文件"
**原因:** 文件扩展名不是 `.xls` 或 `.xlsx`。
**原因:** 文件扩展名不是 `.xls` 或 `.xlsx`(如 csv、xlsx.zip)。
**解决:** Controller 中校验扩展名,前端 `accept=".xls,.xlsx"` 限制文件类型。
**解决:** 前端 `accept=".xls,.xlsx"` 限制文件类型;`_import()` 会依次校验扩展名、大小(10MB)、MIME。
### 问题 2:导入时中文乱码
**原因:** Excel 文件编码问题(旧版 .xls 可能是 GBK 编码)。
**原因:** 旧版 `.xls` 可能是 GBK 编码。
**解决:** PhpSpreadsheet 自动处理编码,通常不需要手动处理。如仍有问题,检查 Excel 文件是否损坏。
**解决:** PhpSpreadsheet 自动处理编码,通常无需手动处理;优先使用 `.xlsx` 格式。
### 问题 3:导出内存溢出
**原因:** 使用 `export()` 一次性导出大数据量。
**解决:** 改用 `chunkExport()` 分批导出,每批 500 条。
**解决:** 改用 `chunkExport()` 分批导出(回调按页取数,取空自动终止)。
### 问题 4:枚举字段导出为数字
**原因:** 导出表头未使用 `Text` 后缀字段。
**解决:** 确保 Logic 中配置了 `serializeMaps`,导出表头使用 `{字段名}Text`。
**解决:** 确认 Service 中配置了 `serializeMaps`(且 `use HasSerializeMaps`),导出表头使用 `{字段名}Text`。
### 问题 5:导入成功但下拉框值显示异常
**原因:** Excel 中的枚举文字在字典里不存在。
**解决:** `DictCacheService::getValue()` 反查不到会保留原值——检查字典项标签与 Excel 文字是否完全一致(含空格)。
@@ -1,7 +1,7 @@
# 7.5 前端文件组件
:::tip 概述
本节介绍前端项目中的文件处理组件,包括上传组件(BasicUpload)和图片预览组件的使用方法。
本节介绍前端项目(独立 EleVue 仓库)中的文件处理组件,包括上传组件(BasicUpload)、富文本编辑器(Editor)与 Excel 导入导出组件的使用方法。组件封装了 [7.1 文件上传全流程](/zh/tools/upload.html) 的接口调用。
:::
## Upload 上传组件
@@ -23,6 +23,19 @@ const formData = ref({ avatar: '' });
</script>
```
:::tip 字段值约定
`BasicUpload` 绑定的值建议存 **`filePath`(相对路径)**——提交表单后由 Service 的 `fileFields` 归一化,读取时自动补全 `FILE_DOMAIN_URL` 域名。上传需携带 `Authorization: Bearer <token>` 请求头(组件内部或 http 封装统一处理)。
:::
### 常用属性
| 属性 | 说明 |
|------|------|
| `v-model` | 绑定的文件值(相对路径) |
| `action` | 上传接口地址(`/api/upload/uploadFile`) |
| `maxCount` | 最大上传数量(1 = 单文件,如头像) |
| `:data="{ type: 'image' }"` | 上传类型(image 校验图片后缀白名单,两类均存入 `temp/` 临时目录) |
## Editor 富文本编辑器
```vue
@@ -37,6 +50,10 @@ const formData = ref({ content: '' });
</script>
```
:::tip 富文本字段
编辑器产出的 HTML 提交后,由 Service 的 `contentFields` 配置触发 `[IMG_URL]` 占位符处理(见 [7.3 富文本处理](/zh/tools/rich-text.html))。编辑器粘贴内容建议开启安全过滤模式防 XSS。
:::
## Excel 导入组件
```vue
@@ -48,18 +65,23 @@ const formData = ref({ content: '' });
import ImportExcel from '@/components/Excel/src/ImportExcel.vue';
const handleImportSuccess = (data) => {
// data 为解析后的数组
// data 为解析后的数组(前端本地解析预览场景)
console.log(data);
};
</script>
```
:::tip 前端解析 vs 后端导入
- **后端导入(推荐,走业务校验)**:`el-upload` 直传 `/api/{module}/import`,由 Service 逐行 `add()`(字典反查、唯一校验、事务),见 [7.4 数据导入导出](/zh/tools/excel.html);
- **前端解析**:`ImportExcel` 组件用 `xlsx` 库本地解析为数组,适合导入前预览、无业务校验的场景。
:::
## Excel 导出
```typescript
import { jsonToSheetXlsx } from '@/components/Excel/src/Export2Excel';
// 导出 Excel
// 前端直接导出(本地数据组装为 Excel,不经过后端)
jsonToSheetXlsx({
data: tableData.value,
header: {
@@ -69,4 +91,17 @@ jsonToSheetXlsx({
},
filename: '导出数据.xlsx',
});
```
```
```typescript
// 后端导出(大数据量 / 需要权限控制时):调用模块导出接口拿文件 URL
const res = await exampleExport(params); // 文件地址
window.open(res);
```
### 两种导出方式选择
| 方式 | 适用场景 | 特点 |
|------|---------|------|
| 前端 `jsonToSheetXlsx` | 当前页数据、无权限要求 | 零后端交互、即时下载 |
| 后端 `/export` 接口 | 全量数据、需权限与字典翻译 | `#[Permission]` 控制、`serializeMaps` 枚举转文字、分批防溢出 |
@@ -1,12 +1,12 @@
# 7.3 富文本处理
:::tip 概述
富文本(如文章内容)中的图片/视频也需要处理。使用 `[IMG_URL]` 占位符机制,支持域名变更场景。
富文本(如文章内容)中的图片/视频链接同样需要处理。项目采用 `[IMG_URL]` 占位符机制:入库时迁移临时文件并把域名替换为占位符,读取时再还原为当前配置的域名——支持域名变更场景。
:::
## 处理流程图
```html
```text
┌─────────────────────────────────────────────────────────────────┐
│ 写入流程(add/update) │
├─────────────────────────────────────────────────────────────────┤
@@ -14,20 +14,24 @@
│ 前端编辑器插入图片 │
│ │ │
│ ▼ │
│ 上传到临时目录 temp/日期/ │
│ 上传到临时目录(uploadFile 接口) │
│ │ │
│ ▼ │
│ 提交 HTML 内容到后端 │
│ │ │
│ ▼ │
│ BaseLogic::processContentFieldsOnSave() │
│ Service(use HasFileProcessing) │
│ processContentFieldsOnSave() │
│ │ │
│ ├─ 解析 <img> 标签的 src │
│ ├─ 解析 <video>/<source>/<embed> 标签的 src │
│ ├─ 解析 <a> 标签的文件 href │
│ │ │
│ ▼ │
│ save_file() 迁移临时文件 → 正式目录 │
│ save_content(&$content, $saveDir) ← 引用传递,原地修改 │
│ │ │
│ ├─ 站内临时文件 → save_file() 迁移至正式目录 │
│ ├─ 正式文件 → 去掉域名 │
│ │ │
│ ▼ │
│ 替换为 [IMG_URL]/相对路径 占位符 │
@@ -44,9 +48,9 @@
│ 数据库读取含占位符的 HTML │
│ │ │
│ ▼ │
│ BaseLogic::processContentFieldsOnRead() │
│ Service.processContentFieldsOnRead() │
│ │ │
│ ├─ 替换 [IMG_URL] 为实际域名 │
│ ├─ 替换 [IMG_URL] 为 config/file.php 的 domain_url │
│ │ 如 https://cdn.example.com │
│ │ │
│ ▼ │
@@ -55,11 +59,14 @@
└─────────────────────────────────────────────────────────────────┘
```
## 在 Logic 中配置
## 在 Service 中配置
```php
class ArticleLogic extends BaseLogic
class ArticleService extends BaseTenantService
{
// 必须 use HasFileProcessing(否则占位实现直接透传,不处理)
use HasFileProcessing, /* 其他 Trait */;
/**
* 富文本字段
*
@@ -69,19 +76,31 @@ class ArticleLogic extends BaseLogic
}
```
配置后 BaseLogic 自动处理:
- **add/update**:调用 `save_content()` 处理临时文件
- **detail/pageList/allList**:调用 `processContentFieldsOnRead()` 替换占位符
配置后自动处理:
- **add/update**:`processContentFieldsOnSave()` → `save_content()` 处理临时文件与占位符
- **detail/pageList/allList**:`processContentFieldsOnRead()` / `...ReadList()` 还原占位符
:::warning 未启用 Trait 不生效
`BaseService` 中的 `processContentFieldsOnSave` 是**占位实现**(直接返回原数据)。子类必须 `use HasFileProcessing` 真实实现才会处理——声明了 `contentFields` 却不 `use`,富文本将原样入库。
:::
## 为什么使用占位符?
:::tip 域名变更场景
假设初始配置 `FILE.DOMAIN_URL = https://cdn.example.com`,文章内容中图片 URL 为:
`https://cdn.example.com/article/20260920/abc.jpg`
假设初始配置 `FILE_DOMAIN_URL=https://cdn.example.com`,文章内容中图片 URL 为:
存储时替换为:`[IMG_URL]/article/20260920/abc.jpg`
```text
https://cdn.example.com/uploads/article/20261002/abc.jpg
```
如果后续域名变更为 `https://cdn2.example.com`,只需修改 `.env` 中的 `FILE.DOMAIN_URL`,已存储的内容无需逐条更新,读取时自动替换为新域名。
存储时替换为:
```text
[IMG_URL]/uploads/article/20261002/abc.jpg
```
如果后续域名变更为 `https://cdn2.example.com`,只需修改 `.env` 中的 `FILE_DOMAIN_URL`,**已存储的内容无需逐条更新**,读取时自动替换为新域名。
:::
## 处理范围
@@ -99,8 +118,15 @@ class ArticleLogic extends BaseLogic
## 代码示例
```php
// app/common.php 中的 save_content 函数
// app/Helpers/helpers.php 中的 save_content(来自项目实际代码的函数签名)
/**
* 处理富文本内容:站内临时文件转正、正式文件去掉域名,最终替换为 [IMG_URL] 占位。
*
* @param string $content 富文本 HTML(引用传递,原地修改)
* @param string $saveDir 正式保存子目录
* @return bool 是否发生替换
*/
function save_content(string &$content, string $saveDir = 'article'): bool
{
$config = config('file');
@@ -110,7 +136,6 @@ function save_content(string &$content, string $saveDir = 'article'): bool
return false;
}
// 收集所有需要处理的 URL
$urlMap = [];
// 1. 匹配 <img> 标签的 src
@@ -123,7 +148,7 @@ function save_content(string &$content, string $saveDir = 'article'): bool
save_content_match($content, '/<a[^>]+href=["\']?([^"\'>\s]+\.\w{2,5})["\']?/i', $domainUrl, $saveDir, $urlMap);
// 批量替换
if (!empty($urlMap)) {
if (! empty($urlMap)) {
$content = str_replace(array_keys($urlMap), array_values($urlMap), $content);
}
@@ -131,10 +156,25 @@ function save_content(string &$content, string $saveDir = 'article'): bool
}
```
**处理规则(来自 `save_content` 实现注释):**
- 站内临时文件 → 迁移到正式目录后记为 `[IMG_URL]` 占位
- 正式文件 → 去掉域名后记录为 `[IMG_URL]` 占位
- 外部 URL → 不处理(保持原样)
## 注意事项
:::warning 富文本安全
- 富文本内容可能包含 XSS 攻击代码,建议在前端使用编辑器的安全模式
- 富文本内容可能包含 XSS 攻击代码,建议在前端使用编辑器的安全模式(粘贴过滤)
- `save_content` 只处理本站域名下的 URL,外部 URL 不做处理
- 图片标签的 `src` 必须是完整 URL(包含域名),否则无法识别
:::
- 响应统一 `Content-Type: application/json`(ForceJsonResponse 中间件),内容不会被浏览器按 HTML 渲染
:::
:::tip 与文件字段的分工
- **单文件/多文件字段**(`fileFields`/`multiFileFields`)→ 结构化字段,见 [7.1 文件上传全流程](/zh/tools/upload.html)
- **富文本字段**(`contentFields`)→ HTML 内嵌资源,本页说明
- **配置项的 value**(按 `type` 分派,富文本类型为 `ueditor`)→ `HasConfigValueProcessing` Trait,见 [5.12 系统配置管理](/zh/core/config.html)
前两者由 `HasFileProcessing` Trait 提供,按属性声明分别启用;配置项不声明字段列表,而是由记录自身的 `type` 决定处理方式。
:::
@@ -1,21 +1,35 @@
# 7.2 上传配置详解
:::tip 概述
文件上传配置定义在 `config/file.php` 和 `.env` 中,控制上传目录、域名、大小限制、允许的文件类型等。所有值通过 `env()` 函数从 `.env` 文件读取,支持多环境切换。
文件上传配置定义在 `config/file.php` 和 `.env` 中,控制上传目录、访问域名、大小限制、允许的文件类型等。所有值通过 `env()` 函数从 `.env` 读取,支持多环境切换;`config/file.php` 中的注释明确标注了每个配置的**读取方**。
:::
## 配置文件
```php
// config/file.php — 文件上传配置
// 所有值通过 env() 函数从 .env 文件读取,支持多环境切换
// config/file.php — 文件上传配置(来自项目实际代码,节选)
return [
'upload_dir' => env('file.upload_dir', ''), // 上传根目录(绝对路径)
'domain_url' => env('file.domain_url', ''), // 文件访问域名(拼接完整 URL 用)
'temp_dir' => 'temp', // 临时目录名(相对 upload_dir)
'image_ext' => env('file.image_ext', 'gif,jpg,jpeg,png,bmp,webp,svg'), // 允许的图片后缀
'file_ext' => env('file.file_ext', 'gif,jpg,jpeg,png,bmp,webp,svg,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv,mp3,mp4,sql,js,css'), // 允许的文件后缀
'max_size' => env('file.max_size', 10) * 1024 * 1024, // 最大大小(MB → 字节)
// 文件上传根目录(相对 public),所有上传文件均存放于此
// 读取方:UploadController::uploadFile()、save_file()、save_remote_file()
'upload_dir' => env('FILE_UPLOAD_DIR', 'uploads'),
// 文件访问域名,用于拼接完整 URL;为空时返回相对路径
// 读取方:get_file_url()、save_content()、processContentFieldsOnRead()
'domain_url' => env('FILE_DOMAIN_URL', ''),
// 临时文件目录名(相对 upload_dir)
// 读取方:UploadController::uploadFile()(上传落盘目录)、save_file()(识别 temp/ 开头的临时文件后迁移到正式目录)
'temp_dir' => 'temp',
// 允许上传的图片后缀(逗号分隔)
// 安全说明:svg 已移除,因其可内嵌 JavaScript 导致 XSS 风险
'image_ext' => env('FILE_IMAGE_EXT', 'gif,jpg,jpeg,png,bmp,webp'),
// 允许上传的文件后缀(逗号分隔)
'file_ext' => env('FILE_EXT', 'gif,jpg,jpeg,png,bmp,webp,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv'),
// 单文件最大尺寸(MB),转换为字节存储
'max_size' => (int) env('FILE_MAX_SIZE', 10) * 1024 * 1024,
];
```
@@ -23,67 +37,76 @@ return [
| 配置项 | .env 变量 | 默认值 | 说明 |
|--------|----------|--------|------|
| `upload_dir` | `[FILE] UPLOAD_DIR` | - | 上传根目录(绝对路径),所有文件存储于此 |
| `domain_url` | `[FILE] DOMAIN_URL` | - | 文件访问域名,用于拼接完整 URL |
| `temp_dir` | - | `temp` | 临时目录名(相对 upload_dir) |
| `image_ext` | `[FILE] IMAGE_EXT` | `gif,jpg,jpeg,png,bmp,webp,svg` | 图片允许后缀 |
| `file_ext` | `[FILE] FILE_EXT` | 多种后缀 | 文件允许后缀 |
| `max_size` | `[FILE] MAX_SIZE` | `10` | 单文件最大大小(MB) |
| `upload_dir` | `FILE_UPLOAD_DIR` | `uploads` | 上传根目录(**相对 `public/`**),所有文件存储于此 |
| `domain_url` | `FILE_DOMAIN_URL` | (空) | 文件访问域名,用于拼接完整 URL |
| `temp_dir` | — | `temp` | 临时目录名(相对 upload_dir),上传接口的落盘目录,`save_file()` 据此识别待迁移文件 |
| `image_ext` | `FILE_IMAGE_EXT` | `gif,jpg,jpeg,png,bmp,webp` | 图片允许后缀(**无 svg**) |
| `file_ext` | `FILE_EXT` | 图片+文档+压缩包等 | 文件允许后缀 |
| `max_size` | `FILE_MAX_SIZE` | `10` | 单文件最大大小(MB,配置内自动转字节) |
### upload_dir — 上传根目录
```ini
; .env
[FILE]
UPLOAD_DIR = D:/uploads/rxthinkcmf
# .env
FILE_UPLOAD_DIR=uploads
```
**要求:**
- 必须是绝对路径
- 目录必须存在且有写入权限
- 生产环境建议放在项目目录外(防止被 Web 直接访问)
- 本项目为**相对 `public/` 的目录**,Web 可直接访问其中的静态文件
- 目录必须存在且有写入权限(Docker 镜像已创建并授权 `public/uploads`)
- 上传文件随项目目录持久化(Docker 下即宿主机目录,不依赖命名卷)
**各环境示例:**
| 环境 | 路径示例 |
|------|---------|
| Windows 开发 | `D:/uploads/rxthinkcmf` |
| Linux 开发 | `/home/www/uploads/rxthinkcmf` |
| Linux 生产 | `/data/uploads/rxthinkcmf` |
| Docker | `/uploads/rxthinkcmf`(挂载卷) |
| 环境 | 值 | 实际位置 |
|------|------|---------|
| 本地开发 | `uploads` | `项目/public/uploads/` |
| Linux 生产 | `uploads`(或绝对路径) | Nginx root 下可直接访问 |
| Docker | `uploads` | 挂载的项目目录内 |
### domain_url — 文件访问域名
```ini
; .env
[FILE]
DOMAIN_URL = https://cdn.example.com
# .env
FILE_DOMAIN_URL=https://cdn.example.com
```
**要求:**
- 不要以 `/` 结尾
- 不要以 `/` 结尾(读取方会 `rtrim` 处理,但配置时保持规范)
- 必须是前端可访问的域名
- 本地开发可留空(使用相对路径)
- 本地开发可留空(接口返回相对路径,由同源/Vite 代理直接访问)
**各环境示例:**
| 环境 | 域名示例 |
|------|---------|
| 本地开发 | (留空,使用相对路径) |
| 虚拟域名开发 | `http://file.laravel13.elevue` |
| 测试环境 | `https://test-cdn.example.com` |
| 生产环境 | `https://cdn.example.com` |
| 对象存储 | `https://bucket-name.oss-cn-hangzhou.aliyuncs.com` |
| 生产环境 | `https://cdn.example.com` 或对象存储域名 |
### temp_dir — 临时目录
```php
// config/file.php
'temp_dir' => 'temp',
```
**说明:**
- 上传接口 `uploadFile` 把文件写入 `{upload_dir}/temp/Ymd/`,不直接落正式目录
- `save_file()` / `save_content()` 依据 `temp/` 前缀把文件迁移到 Service 的 `fileSaveDir` 子目录
- 正式目录未被表单引用的临时文件可按周期清理(见下方「问题 4」)
### max_size — 文件大小限制
```ini
; .env
[FILE]
MAX_SIZE = 10
# .env(单位 MB,config 内 ×1024×1024 转字节)
FILE_MAX_SIZE=10
```
**单位:** MB(配置文件中会自动转换为字节:`10 * 1024 * 1024`)
**建议值:**
| 场景 | 建议值 | 说明 |
@@ -92,141 +115,95 @@ MAX_SIZE = 10
| 文章封面 | 5 MB | 压缩后的图片 |
| 通用文件上传 | 10 MB | 默认值 |
| 文档上传 | 20 MB | PDF/Word 等 |
| 视频上传 | 100 MB | 需配合分片上传 |
### image_ext — 图片允许后缀
:::warning 双层限制
业务层限制生效前,PHP 层必须放行:`php.ini` 的 `upload_max_filesize` / `post_max_size` 需 **大于等于** `FILE_MAX_SIZE`(建议 50M)。
:::
### image_ext / file_ext — 后缀白名单
```ini
; .env
[FILE]
IMAGE_EXT = gif,jpg,jpeg,png,bmp,webp,svg
# .env
FILE_IMAGE_EXT=gif,jpg,jpeg,png,bmp,webp # uploadFile?type=image 校验
FILE_EXT=gif,jpg,jpeg,png,bmp,webp,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv # uploadFile(默认 type=file)校验
```
**默认值:** `gif,jpg,jpeg,png,bmp,webp,svg`
**说明:**
**说明:** 用于 `uploadFile?type=image` 接口,只允许图片格式上传。
### file_ext — 文件允许后缀
```ini
; .env
[FILE]
FILE_EXT = gif,jpg,jpeg,png,bmp,webp,svg,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv,mp3,mp4,sql,js,css
```
**默认值:** 包含图片、文档、压缩包、音视频等常见格式。
**说明:** 用于 `uploadFile` 接口,允许所有合法文件格式。
- `image_ext` 用于 `type=image`,只允许图片格式;**svg 已移除**(内嵌 JS 的 XSS 风险)
- `file_ext` 用于 `type=file`,允许图片、文档、压缩包等常见格式
- 白名单为空数组时跳过校验(防御性兜底),生产环境务必保留非空配置
## .env 配置示例
### 开发环境
```ini
[FILE]
UPLOAD_DIR = D:/uploads/rxthinkcmf
DOMAIN_URL =
MAX_SIZE = 10
```
# 开发环境
FILE_UPLOAD_DIR=uploads
FILE_DOMAIN_URL=
FILE_MAX_SIZE=10
### 测试环境
```ini
[FILE]
UPLOAD_DIR = /home/www/uploads/rxthinkcmf
DOMAIN_URL = https://test-cdn.example.com
MAX_SIZE = 10
```
### 生产环境
```ini
[FILE]
UPLOAD_DIR = /data/uploads/rxthinkcmf
DOMAIN_URL = https://cdn.example.com
MAX_SIZE = 10
IMAGE_EXT = jpg,jpeg,png,gif,bmp,webp
FILE_EXT = jpg,jpeg,png,gif,bmp,webp,doc,docx,xls,xlsx,pdf,zip,rar
```
### 对象存储(OSS)
```ini
; 如果使用阿里云 OSS 等对象存储,domain_url 设为 OSS 域名
[FILE]
UPLOAD_DIR = /data/uploads/rxthinkcmf
DOMAIN_URL = https://your-bucket.oss-cn-hangzhou.aliyuncs.com
MAX_SIZE = 50
# 生产环境(示例)
FILE_UPLOAD_DIR=uploads
FILE_DOMAIN_URL=https://cdn.example.com
FILE_MAX_SIZE=10
FILE_IMAGE_EXT=gif,jpg,jpeg,png,bmp,webp
FILE_EXT=gif,jpg,jpeg,png,bmp,webp,doc,docx,xls,xlsx,pdf,zip,rar
```
## 目录结构
```text
{upload_dir}/ # upload_dir(上传根目录)
├── temp/ # 临时目录(temp_dir)
│ └── 20260920/ # 按日期分目录
│ ├── a1b2c3d4e5f6.jpg # 随机文件名(防止冲突)
│ └── f6e5d4c3b2a1.png
├── article/ # 正式目录(由 fileSaveDir 配置)
│ └── 20260920/
│ └── 143052_xK7pMn3q.jpg
├── example/
│ └── 20260920/
├── user/
│ └── avatar/
│ └── 20260920/
└── file_template/
└── 20260920/
public/
└── {upload_dir}/ # upload_dir(默认 uploads)
├── temp/ # 临时目录(UploadController 上传落盘)
│ └── 20261002/
│ └── 123456_a1b2c3d4e5f6.jpg
├── article/ # 正式目录(由 Service 的 fileSaveDir 配置)
│ └── 20261002/
│ └── 153052_9f8e7d6c5b4a.pdf
├── example/
├── user/
│ └── avatar/
└── file_template/
```
### 目录命名规则
| 目录 | 说明 | 示例 |
| 目录 | 说明 | 来源 |
|------|------|------|
| `temp/` | 临时文件目录 | 上传后暂存于此 |
| `{module}/` | 正式目录(按模块) | 由 Logic 的 `fileSaveDir` 配置 |
| `{date}/` | 日期子目录 | `20260920`(自动创建) |
| `{random}.{ext}` | 随机文件名 | `a1b2c3d4e5f6.jpg`(防止冲突) |
| `temp/` | 临时文件目录(上传接口落盘) | `config/file.php → temp_dir` |
| `{module}/` | 按模块的正式目录 | Service 的 `fileSaveDir`(未配置按模型名推导) |
| `{Ymd}/` | 日期子目录 | 自动创建 |
| `{His}_{random}.{ext}` | 时间+随机文件名 | 防冲突与可预测性 |
## 文件生命周期
```text
1. 前端上传 → upload_file()
保存到: {upload_dir}/temp/20260920/a1b2c3d4e5f6.jpg
返回: https://cdn.example.com/temp/20260920/a1b2c3d4e5f6.jpg
1. 前端上传 → POST /api/upload/uploadFile
保存到: public/uploads/temp/20261002/123456_a1b2c3d4e5f6.jpg
返回: filePath + fileUrl
2. 前端提交表单 → 携带 fileUrl
POST /api/example/add { name: "测试", avatar: "https://..." }
2. 前端提交表单 → 携带 filePath
POST /api/example/add { name: "测试", avatar: "temp/20261002/..." }
3. BaseLogic::add() → save_file()
检测到 temp/ → 移动到正式目录
{upload_dir}/temp/20260920/a1b2c3d4e5f6.jpg
→ {upload_dir}/example/20260920/a1b2c3d4e5f6.jpg
返回相对路径入库: /uploads/example/20260920/a1b2c3d4e5f6.jpg
3. ExampleService::add() → processFileFieldsOnSave → save_file()
temp/ → 迁移到 fileSaveDir(example/)后,正式目录相对路径入库
(其它分支:去掉域名直接用;外部 URL → 下载保存)
4. BaseLogic::detail() → get_file_url()
相对路径 → 拼接域名
/uploads/example/20260920/a1b2c3d4e5f6.jpg
→ https://cdn.example.com/uploads/example/20260920/a1b2c3d4e5f6.jpg
4. ExampleService::detail() → processFileFieldsOnRead → get_file_url()
相对路径 → 拼接 FILE_DOMAIN_URL
example/20261002/...
→ https://cdn.example.com/example/20261002/...
```
## 与 Logic 层的配合
## 与 Service 层的配合
```php
class ArticleLogic extends BaseTenantLogic
class ArticleService extends BaseTenantService
{
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['cover'];
use HasFileProcessing, /* 其他 Trait */;
/**
* 文件保存的子目录
*
* @var string
*/
protected array $fileFields = ['cover'];
protected string $fileSaveDir = 'article';
}
```
@@ -235,14 +212,14 @@ class ArticleLogic extends BaseTenantLogic
```text
配置:
upload_dir = /data/uploads/rxthinkcmf
fileSaveDir = article
FILE_UPLOAD_DIR = uploads
FILE_DOMAIN_URL = https://cdn.example.com
fileSaveDir = article
结果:
临时文件: /data/uploads/rxthinkcmf/temp/20260920/abc.jpg
正式文件: /data/uploads/rxthinkcmf/article/20260920/abc.jpg
数据库存: /uploads/article/20260920/abc.jpg
前端显示: https://cdn.example.com/uploads/article/20260920/abc.jpg
上传文件: public/uploads/temp/20261002/xxx.jpg
Service 归一化后入库: article/20261002/xxx.jpg
前端显示: https://cdn.example.com/article/20261002/xxx.jpg
```
## Nginx 配置
@@ -250,20 +227,18 @@ class ArticleLogic extends BaseTenantLogic
### 静态文件直接由 Nginx 返回
```nginx
# 配置 Nginx 直接处理文件请求,不经过 PHP
location /uploads/ {
alias /data/uploads/rxthinkcmf/;
expires 30d;
add_header Cache-Control "public, immutable";
# 项目方案:public/uploads 位于站点根内,location / 由 try_files 直接返回静态文件
location / {
try_files $uri $uri/ /index.php?$query_string;
}
```
### 禁止上传目录执行 PHP
```nginx
# 安全:禁止上传目录执行 PHP 文件
location ~* /uploads/.*\.php$ {
deny all;
# docker/nginx.conf 已内置:仅入口 index.php 可执行,其余 .php 一律 404
location ~ \.php$ {
return 404;
}
```
@@ -279,57 +254,60 @@ location ~ /\. {
### 问题 1:上传提示"目录不可写"
**原因:** `upload_dir` 目录没有写入权限。
**原因:** `public/uploads` 没有写入权限。
**解决:**
```bash
# Linux
chmod -R 755 /data/uploads/rxthinkcmf
chown -R www:www /data/uploads/rxthinkcmf
chmod -R 755 public/uploads
chown -R www-data:www-data public/uploads
# Docker:镜像内已 chown(storage / bootstrap/cache / public/uploads)
```
### 问题 2:上传后图片不显示
**原因:** `domain_url` 配置错误或 Nginx 未配置静态文件处理。
**原因:** `FILE_DOMAIN_URL` 配置错误或与前端不同源。
**解决:**
1. 检查 `.env` 中 `DOMAIN_URL` 是否正确
2. 检查 Nginx 是否配置了 `/uploads/` 的 alias
3. 浏览器打开图片 URL 看是否能直接访问
1. 检查 `.env` 中 `FILE_DOMAIN_URL` 是否为前端可访问的地址(改后 `php artisan config:clear`)
2. 留空时用相对路径——确认前端与后端同源或经代理
3. 浏览器直接打开图片 URL 验证
### 问题 3:文件大小限制不生效
**原因:** PHP 配置限制了上传大小。
**原因:** PHP 层限制小于业务层。
**解决:**
```ini
; php.ini
upload_max_filesize = 20M
post_max_size = 25M
; php.ini(修改后重启服务)
upload_max_filesize = 50M
post_max_size = 50M
max_execution_time = 300
```
### 问题 4:临时文件未清理
**原因:** `temp/` 目录中的文件在表单提交后未被 `save_file()` 迁移。
**原因:** 上传后未提交表单(或表单未引用)的文件停留在 `temp/`,不会被迁移。
**解决:** 定期清理 `temp/` 目录中超过 1 天的文件:
**解决:** 定期清理 `temp/` 中超过 1 天的文件:
```bash
# 定时任务:每天凌晨清理 1 天前的临时文件
find /data/uploads/rxthinkcmf/temp/ -type f -mtime +1 -delete
find public/uploads/temp/ -type f -mtime +1 -delete
```
## 安全建议
| 建议 | 说明 |
|------|------|
| 上传目录放在项目外 | 防止被 Web 直接访问源码 |
| Nginx 禁止执行 PHP | 防止上传恶意 PHP 文件并执行 |
| 扩展名白名单 | 只允许已知安全的文件类型 |
| MIME 类型检查 | `finfo` 检测真实 MIME,防止伪造扩展名 |
| 随机文件名 | 防止路径遍历和文件名注入 |
| 文件大小限制 | 防止大文件 DoS 攻击 |
| 定期清理临时文件 | 避免磁盘空间被占满 |
| 扩展名白名单 | 只允许已知安全的文件类型(`FILE_IMAGE_EXT`/`FILE_EXT`) |
| 移除高危后缀 | svg 已移除;生产白名单去掉 `sql/js/css` 等可执行类 |
| MIME 类型检查 | 导入等场景配合 `finfo` 检测真实 MIME(见 [7.4 数据导入导出](/zh/tools/excel.html)) |
| 随机文件名 | 服务端生成(时间+随机串),防止路径遍历和文件名注入 |
| 文件大小限制 | `FILE_MAX_SIZE` 防止大文件 DoS |
| Nginx 禁止执行 PHP | 仅入口 `index.php` 可执行(Docker 配置已内置) |
| 定期清理临时文件 | `temp/` 内未被引用的文件按周期清理,避免磁盘空间被占满 |
+183 -329
View File
@@ -1,7 +1,7 @@
# 7.1 文件上传全流程
:::tip 概述
文件处理分为两个阶段:上传到临时目录(upload_file)→ 入库时迁移到正式目录(save_file)。这种"两阶段"设计将文件上传与业务逻辑解耦,前端先上传获取临时 URL,提交表单时 Logic 层自动将临时文件迁移到正式目录。
文件处理分为三个阶段:**上传**(`uploadFile` 接口校验后存入 `temp/` 临时目录)→ **入库**(Service 的 `fileFields` 经 `save_file()` 归一化:去域名、迁移临时文件到正式目录)→ **读取**(`get_file_url()` 补全域名)。上传与业务逻辑解耦:前端先上传拿到 URL/路径,提交表单时框架自动完成字段处理。
:::
## 完整流程图
@@ -13,28 +13,32 @@
前端 后端
│ │
│── POST /api/upload ────────►│
│── POST /api/upload/ ───────►│ (auth.jwt 组内,需 sys:upload:file 权限)
│ uploadFile │
│ Content-Type: │
│ multipart/form-data │
│ file: <二进制> │
│ type: image | file │
│ │
│ ┌──────┴──────┐
│ │upload_file()│
│ │ │
│ │ 1. 检查上传状态
│ │ 2. finfo 检测真实 MIME
│ │ 3. 按类型校验扩展名
│ │ 4. 校验文件大小
│ │ 5. 图片额外 getimagesize 校验
│ │ 6. 保存到 temp/日期/随机文件名
│ └──────┬──────┘
│ ┌──────┴───────────┐
│ │ UploadController │
│ │ ::uploadFile() │
│ └──────┬───────────┘
│ │
│ 1. 校验 type 合法性(image / file)
│ 2. 文件存在且上传有效(isValid)
│ 3. 按类型读取扩展名白名单(config/file.php)
│ 4. 校验文件大小(file.max_size,默认 10MB)
│ 5. 生成路径:temp/Ymd/His_随机串.扩展名
│ 6. 采集元数据:原始名 / MIME(finfo)/ 大小
│ 7. mkdir + move 保存
│ │
│◄── { fileUrl, filePath, ────│
│ originalName, fileSize, │
│ fileExtension, fileType }│
│ fileExtension, fileType, │
│ fileName } │
│ │
│ 前端显示预览 │
│ 或暂存 URL │
│ 前端暂存 URL/路径 │
┌─────────────────────────────────────────────────────────────────────┐
│ 第二阶段:入库 │
@@ -44,39 +48,24 @@
│ │
│── POST /api/example/add ──►│
│ { name: "测试", │
│ avatar: "https:// │
│ cdn.example.com/ │
│ temp/20260920/ │
│ abc123.jpg" } │
│ avatar: "temp/ │
│ 20261002/123456_ab.jpg" }│
│ │
│ ┌──────┴──────┐
│ │ BaseLogic │
│ │ ::add() │
│ └──────┬──────┘
│ ┌──────┴──────────────┐
│ │ ExampleService::add │
│ │ (use HasFileProcessing)│
│ └──────┬──────────────┘
│ │
│ ┌──────┴──────┐
│ │ processFile │
│ │ FieldsOn │
│ │ Save() │
│ └──────┬──────┘
│ ┌──────┴──────────────┐
│ │ processFileFieldsOnSave │
│ │ → save_file() │
│ │ 1. 去掉域名得相对路径 │
│ │ 2. temp/ 开头 → 迁移 │
│ │ 3. 外部URL → 下载保存 │
│ │ 4. 正式路径 → 直接返回│
│ └──────┬──────────────┘
│ │
│ ┌──────┴──────┐
│ │ save_file() │
│ │ │
│ │ 1. 去掉域名得到相对路径
│ │ 2. 包含 temp/ → 移动到正式目录
│ │ 3. 外部URL → 下载保存
│ └──────┬──────┘
│ │
│ ┌──────┴──────┐
│ │ 数据库存储 │
│ │ /uploads/ │
│ │ example/ │
│ │ 20260920/ │
│ │ abc123.jpg │
│ └──────┬──────┘
│ │
│◄── { code:0, data:{id:1} }─│
│◄── { code:0, data:{id:1} }──│
┌─────────────────────────────────────────────────────────────────────┐
│ 第三阶段:读取 │
@@ -86,180 +75,130 @@
│ │
│── GET /api/example/detail/1►│
│ │
│ ┌──────┴──────┐
│ │processFile │
│ │FieldsOnRead │
│ │() │
│ └──────┬──────┘
│ │
│ ┌──────┴──────┐
│ │get_file_url │
│ │() │
│ │拼接域名 │
│ └──────┬──────┘
│ ┌──────┴───────────────┐
│ │ processFileFieldsOnRead │
│ │ → get_file_url() │
│ │ 拼接 FILE_DOMAIN_URL │
│ └──────┬───────────────┘
│ │
│◄── { avatar: "https://──────│
│ cdn.example.com/uploads/ │
│ example/20260920/ │
│ abc123.jpg" } │
│ example/20261002/..." } │
```
## upload_file 函数
:::tip 临时目录中转
上传接口把文件写入 `temp/` 临时目录,**不直接落正式目录**;提交表单时 `save_file()` 依据 `temp/` 前缀把文件迁移到 Service 的 `fileSaveDir` 子目录(`user/`、`article/`、`example/` …),正式目录与业务模块一一对应。上传后未被表单引用的文件停留在 `temp/`,可按周期清理。
:::
```php
// app/common.php
## 上传接口
/**
* 上传文件到临时目录
*
* 处理流程:
* 1. 检查上传状态,细分错误码
* 2. 用 finfo 检测真实 MIME(防止伪造扩展名)
* 3. 按类型校验扩展名白名单
* 4. 校验文件大小(config/file.php → max_size)
* 5. 图片额外用 getimagesize 校验(防止非图片伪装)
* 6. 在 temp/日期/ 下保存,随机文件名
*
* @param string $formName 表单字段名
* @param string $type 文件类型:image|file
* @return array 成功返回 [url, path, name, size, ext, mime];失败返回 [error]
*/
function upload_file(string $formName = 'file', string $type = 'file'): array
### 上传文件
```bash
POST /api/upload/uploadFile
Content-Type: multipart/form-data
Authorization: Bearer <token>
file: <二进制文件>
```
**参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `file` | File | 是 | 上传的文件 |
| `type` | string | 否 | `image`(校验图片后缀白名单)/ `file`(校验全量文件后缀白名单,默认) |
**响应(来自项目实际代码):**
```json
{
// ... 完整实现见 app/common.php
"code": 0,
"ok": true,
"msg": "上传成功",
"data": {
"originalName": "photo.jpg",
"fileExtension": "jpg",
"fileType": "image/jpeg",
"fileSize": 102400,
"fileName": "123456_a1b2c3d4e5f6.jpg",
"filePath": "temp/20261002/123456_a1b2c3d4e5f6.jpg",
"fileUrl": "http://cdn.example.com/temp/20261002/123456_a1b2c3d4e5f6.jpg"
}
}
```
### 参数说明
**响应字段说明:**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `$formName` | string | `'file'` | 表单字段名 |
| `$type` | string | `'file'` | 文件类型:`image`(仅图片)/ `file`(所有文件) |
| 字段 | 说明 | 示例 |
|------|------|------|
| `originalName` | 原始文件名 | `photo.jpg` |
| `fileExtension` | 扩展名 | `jpg` |
| `fileType` | MIME 类型(`getMimeType()`) | `image/jpeg` |
| `fileSize` | 文件大小(字节) | `102400` |
| `fileName` | 服务器文件名(时间+随机串) | `123456_a1b2c3d4e5f6.jpg` |
| `filePath` | 相对存储路径(**提交表单建议用此字段**) | `temp/20261002/...` |
| `fileUrl` | 可访问 URL(`FILE_DOMAIN_URL` 为空时等于相对路径) | `http://cdn.example.com/...` |
### 返回值
**成功:**
```php
[
'url' => 'https://cdn.example.com/temp/20260920/abc123.jpg', // 完整 URL
'path' => 'temp/20260920/abc123.jpg', // 相对路径
'name' => 'photo.jpg', // 原始文件名
'size' => 102400, // 文件大小(字节)
'ext' => 'jpg', // 扩展名
'mime' => 'image/jpeg', // MIME 类型
]
```
**失败:**
```php
['error' => '错误信息']
```
### 错误类型
| 错误信息 | 说明 |
|---------|------|
| 未选择文件 | 表单字段为空 |
| 文件上传失败 | PHP 上传错误(代码 1-7) |
| 不允许的文件类型 | 扩展名不在白名单中 |
| 文件大小超出限制 | 超过 config/file.php 中的 max_size |
| 图片文件不合法 | 图片类型校验失败(getimagesize 验证) |
## save_file 函数
```php
/**
* 保存文件(临时→正式)
*
* 处理逻辑:
* 1. 带域名 URL → 去掉域名得到相对路径
* 2. 包含 temp/ → 移动到正式目录
* 3. 外部 URL → 下载保存
* 4. 已在正式目录 → 直接返回
*
* @param string $fileUrl 文件 URL(带域名)或相对路径
* @param string $saveDir 正式保存的子目录
* @return string|false 入库路径(不带域名),失败返回 false
*/
function save_file(string $fileUrl, string $saveDir = '')
{
// 1. 带域名URL → 去掉域名得到相对路径
// 2. 包含 temp/ → 移动到正式目录
// 3. 外部URL → 下载保存
// 4. 已在正式目录 → 直接返回
}
```
### 处理逻辑
### 存储规则
```text
输入 URL: "https://cdn.example.com/temp/20260920/abc123.jpg"
│
▼ 去掉域名
相对路径: "temp/20260920/abc123.jpg"
│
▼ 包含 temp/ → 需要迁移
│
▼ 移动到正式目录
目标路径: "/uploads/example/20260920/abc123.jpg"
│
▼ 返回入库路径
输出: "/uploads/example/20260920/abc123.jpg"
{upload_dir}/ ← config/file.php → upload_dir(默认 uploads)
└── temp/ ← 所有类型统一存入(config/file.php → temp_dir)
└── 20261002/ ← Ymd 分目录
└── 123456_a1b2c3d4e5f6.jpg ← His_ + 随机8字节hex + 扩展名
{upload_dir}/ ← 正式目录,落库时由 save_file() 迁移生成
├── user/ article/ example/ … ← Service 的 fileSaveDir
└── 20261002/
└── 143052_a1b2c3d4e5f6.jpg
```
## get_file_url 函数
- `type` 只影响后缀白名单,存储目录始终是 `temp/`
- 随机文件名避免重名与**可预测性攻击**(时间戳 + `random_bytes(8)`)
- 目录不存在时自动 `mkdir(0755, true)` 创建
```php
/**
* 获取文件完整 URL
*
* 将数据库中的相对路径拼接为完整 URL:
* - 已是完整 URL → 原样返回
* - 相对路径 → 拼接配置的域名
* - 空值 → 返回空字符串
*
* @param string $path 数据库中的相对路径
* @return string 完整 URL
*/
function get_file_url(string $path): string
{
// 1. 空值 → 返回 ''
// 2. 已是 http/https 开头 → 原样返回
// 3. 拼接 config/file.php 中的 domain_url
}
```
### 转换示例
### 安全检查链
```text
数据库值: "/uploads/example/20260920/abc123.jpg"
上传文件安全检查(UploadController::uploadFile):
│
▼ get_file_url()
▼ 1. 权限校验(auth.jwt + #[Permission('sys:upload:file')])
│
▼ 拼接 domain_url
输出: "https://cdn.example.com/uploads/example/20260920/abc123.jpg"
▼ 2. type 参数合法性(仅 image / file)
│
▼ 3. 文件存在且上传有效(UploadedFile::isValid,捕获 PHP 上传错误)
│
▼ 4. 扩展名白名单(image → file.image_ext;file → file.file_ext)
│
▼ 5. 文件大小限制(config/file.php → max_size,默认 10MB)
│
▼ 6. 服务端生成随机路径(防路径遍历)
│
▼ 7. Nginx 仅允许入口 index.php 执行 —— 上传的 .php 一律 404
```
## 在 Logic 中配置
:::warning svg 已移除
`FILE_IMAGE_EXT` 默认白名单**不含 svg**——svg 可内嵌 JavaScript,上传后直接作为静态资源访问存在 XSS 风险。如业务必须使用,请做内容清洗或以 attachment 方式返回。
:::
## 入库处理(HasFileProcessing)
Service 配置文件字段后,保存/读取自动处理:
```php
class ExampleLogic extends BaseLogic
// app/Services/ExampleService.php(形态示例)
class ExampleService extends BaseService
{
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['avatar', 'cover'];
use HasFileProcessing, /* 其他 Trait */;
/**
* 文件保存的子目录
*
* @var string
*/
// 需要自动处理的文件字段
protected array $fileFields = ['avatar'];
// 多文件字段(逗号分隔的多个 URL)
protected array $multiFileFields = ['images'];
// 文件保存子目录(未指定时按模型类名小写推导)
protected string $fileSaveDir = 'example';
}
```
@@ -268,99 +207,44 @@ class ExampleLogic extends BaseLogic
| 操作 | 自动行为 |
|------|---------|
| `add()` | `processFileFieldsOnSave()` → 调用 `save_file()` 迁移临时文件 |
| `update()` | `processFileFieldsOnSave()` → 调用 `save_file()` 迁移临时文件 |
| `detail()` | `processFileFieldsOnRead()` → 调用 `get_file_url()` 补全域名 |
| `pageList()` | `processFileFieldsOnReadList()` → 调用 `get_file_url()` 补全域名 |
| `allList()` | `processFileFieldsOnReadList()` → 调用 `get_file_url()` 补全域名 |
| `add()` / `update()` | `processFileFieldsOnSave()` → `save_file()` 归一化(去域名、迁移 temp、下载外部 URL) |
| `detail()` | `processFileFieldsOnRead()` → `get_file_url()` 补全域名 |
| `pageList()` / `allList()` | `processFileFieldsOnReadList()` 逐条补全域名 |
### 多文件字段
**处理细节(来自 `HasFileProcessing` 源码):**
- 空值跳过;`save_file()` 返回 `false` 时**保留原值**(避免误清空)
- 多文件按逗号拆分逐个处理,单个失败保留原 URL
- 未 `use HasFileProcessing` 时占位方法直接透传(见 [4.7 横切机制](/zh/architecture/cross-cutting.html))
## 核心辅助函数(app/Helpers/helpers.php)
```php
class UserLogic extends BaseTenantLogic
{
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['avatar'];
/**
* 保存文件(归一化入库路径)
*
* 处理逻辑:
* 1. 带域名 URL → 去掉域名得到相对路径
* 2. 包含 temp/ → 移动到正式目录(fileSaveDir)
* 3. 外部 URL → save_remote_file() 下载保存
* 4. 已在正式目录 → 直接返回
*/
function save_file(string $fileUrl, string $saveDir = ''): string|false
/**
* 文件保存的子目录
*
* @var string
*/
protected string $fileSaveDir = 'user/avatar';
}
/**
* 获取文件完整 URL
* - 空值 → '';已是 http(s) 开头 → 原样返回
* - 相对路径 → 拼接 config/file.php 的 domain_url
*/
function get_file_url(string $path): string
class ArticleLogic extends BaseTenantLogic
{
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['cover', 'attachment'];
/** 生成存储路径:{saveDir}/Ymd/random.扩展名 */
function create_file_path(string $saveDir = '', string $ext = ''): string
/**
* 文件保存的子目录
*
* @var string
*/
protected string $fileSaveDir = 'article';
}
/** 下载外部 URL 并保存(配合 is_internal_ip 做内网地址拦截,防 SSRF) */
function save_remote_file(string $url, string $saveDir = ''): string|false
```
## 前端上传接口
### 上传文件
```bash
POST /api/upload/uploadFile
Content-Type: multipart/form-data
file: <二进制文件>
```
**响应:**
```json
{
"code": 0,
"data": {
"fileUrl": "https://cdn.example.com/temp/20260920/abc123.jpg",
"filePath": "temp/20260920/abc123.jpg",
"originalName": "photo.jpg",
"fileSize": 102400,
"fileExtension": "jpg",
"fileType": "image/jpeg",
"fileName": "abc123.jpg"
}
}
```
### 上传图片(指定 type=image)
```bash
POST /api/upload/uploadFile?type=image
Content-Type: multipart/form-data
file: <二进制图片>
```
### 响应字段说明
| 字段 | 说明 | 示例 |
|------|------|------|
| `fileUrl` | 完整 URL(含域名) | `https://cdn.example.com/temp/...` |
| `filePath` | 相对路径 | `temp/20260920/abc123.jpg` |
| `originalName` | 原始文件名 | `photo.jpg` |
| `fileSize` | 文件大小(字节) | `102400` |
| `fileExtension` | 扩展名 | `jpg` |
| `fileType` | MIME 类型 | `image/jpeg` |
| `fileName` | 服务器文件名 | `abc123.jpg` |
## 前端集成
### Element Plus 上传组件
@@ -369,11 +253,12 @@ file: <二进制图片>
<template>
<el-upload
:action="'/api/upload/uploadFile'"
:data="{ type: 'image' }"
:headers="{ Authorization: `Bearer ${token}` }"
:on-success="handleUploadSuccess"
:before-upload="beforeUpload"
>
<el-button type="primary">上传文件</el-button>
<el-button type="primary">上传图片</el-button>
</el-upload>
</template>
@@ -389,9 +274,8 @@ const beforeUpload = (file) => {
};
const handleUploadSuccess = (response) => {
// response.data.fileUrl 是完整 URL
// 提交表单时使用此 URL
form.value.avatar = response.data.fileUrl;
// 提交表单时保存相对路径(filePath)最稳妥
form.value.avatar = response.data.filePath;
};
</script>
```
@@ -399,66 +283,36 @@ const handleUploadSuccess = (response) => {
### 提交表单
```typescript
// 前端提交时,avatar 字段的值是上传接口返回的 fileUrl
const formData = {
name: '测试',
avatar: 'https://cdn.example.com/temp/20260920/abc123.jpg',
avatar: 'temp/20261002/123456_a1b2c3d4e5f6.jpg',
};
// 后端 Logic 层自动:
// 1. 从 avatar 字段检测到临时文件 URL
// 2. 调用 save_file() 迁移到正式目录
// 3. 去掉域名,只存相对路径入库
// 后端 Service 层自动:
// 1. 识别 avatar 为 fileFields 字段
// 2. save_file() 归一化(去域名 / 迁移 temp 到 fileSaveDir / 下载外部 URL)
// 3. 正式目录相对路径入库(如 example/20261002/123456_a1b2c3d4e5f6.jpg)
```
## 配置项
```ini
; .env
[FILE]
; 文件上传域名
DOMAIN_URL = https://cdn.example.com
; 最大文件大小(字节)
MAX_SIZE = 10485760
; 允许的图片扩展名
ALLOW_IMAGE_EXT = jpg,jpeg,png,gif,bmp,webp
; 允许的文件扩展名
ALLOW_FILE_EXT = jpg,jpeg,png,gif,bmp,webp,doc,docx,xls,xlsx,pdf,zip,rar
# .env(config/file.php 读取)
FILE_UPLOAD_DIR=uploads # 上传根目录(相对 public/)
FILE_DOMAIN_URL= # 文件访问域名(空则返回相对路径)
FILE_MAX_SIZE=10 # 单文件大小上限(MB)
FILE_IMAGE_EXT=gif,jpg,jpeg,png,bmp,webp # 图片后缀白名单(svg 已移除)
FILE_EXT=gif,jpg,jpeg,png,bmp,webp,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv
```
```php
// config/file.php
return [
'domain_url' => env('file.domain_url', ''),
'max_size' => env('file.max_size', 10485760), // 10MB
'allow_image_ext' => env('file.allow_image_ext', 'jpg,jpeg,png,gif,bmp,webp'),
'allow_file_ext' => env('file.allow_file_ext', 'jpg,jpeg,png,gif,bmp,webp,doc,docx,xls,xlsx,pdf,zip,rar'),
];
```
详见 [7.2 上传配置详解](/zh/tools/upload-config.html)。
## 安全检查链
:::warning 同时调整 PHP 限制
业务层 `FILE_MAX_SIZE=10` 生效的前提是 PHP 层放行,确认 `php.ini`:
```text
上传文件安全检查:
│
▼ 1. PHP 上传状态检查(UPLOAD_ERR_OK)
│
▼ 2. finfo 检测真实 MIME 类型(防止伪造扩展名)
│
▼ 3. 扩展名白名单校验
│
▼ 4. 文件大小限制(config/file.php → max_size)
│
▼ 5. 图片额外 getimagesize 校验(防止非图片伪装)
│
▼ 6. 生成随机文件名(防止路径遍历攻击)
│
▼ 7. 存储到 Web 根目录之外(或 Nginx 禁止执行 PHP)
```
```nginx
# Nginx 配置:上传目录禁止执行 PHP
location ~* /uploads/.*\.php$ {
deny all;
}
```ini
upload_max_filesize = 50M
post_max_size = 50M
max_execution_time = 300
```
:::