签入版本
This commit is contained in:
@@ -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` 是**活的迁移工具**,用于把一个已有数据库完整搬到另一个数据库(结构 + 数据 + 元数据),两者互补。
|
||||
|
||||
@@ -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/` 内未被引用的文件按周期清理,避免磁盘空间被占满 |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
:::
|
||||
|
||||
Reference in New Issue
Block a user