签入版本
This commit is contained in:
@@ -0,0 +1,857 @@
|
||||
# 7.6 数据库迁移工具
|
||||
|
||||
:::tip 概述
|
||||
`db:migrate` 是一个 CLI 命令行工具,支持在 MySQL / PostgreSQL / SQL Server / Oracle / SQLite 五种数据库之间任意组合迁移。完整迁移流程包含自动建库、建表结构(含索引、外键、注释、自增、collation)、分批迁移数据、重置自增序列、逐表行数校验 5 个步骤。数据迁移基于主键稳定排序,支持事务保护与自动回滚。目标库不存在时自动创建,支持含点号的数据库名。
|
||||
:::
|
||||
|
||||
## 迁移流程
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 数据库迁移 5 步流程 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
[1/5] 建目标库
|
||||
│ 目标库不存在时自动创建(所有驱动均支持)
|
||||
│ 自动从源库读取 collation 并应用(仅源与目标驱动相同时)
|
||||
│ MySQL: CREATE DATABASE ... COLLATE | PgSQL: LC_COLLATE
|
||||
│ SqlServer: CREATE DATABASE ... COLLATE | Oracle: CREATE USER
|
||||
│ SQLite: 自动创建文件
|
||||
▼
|
||||
[2/5] 建表结构 + 索引 + 注释 + 外键
|
||||
│ 源库 getTableFields() → DbSchemaBuilder 生成 DDL → 目标库执行
|
||||
│ 自动迁移字段注释(MySQL: COMMENT 子句 | PgSQL/Oracle: COMMENT ON COLUMN | SqlServer: sp_addextendedproperty)
|
||||
│ 自动迁移表注释(MySQL: ALTER TABLE COMMENT | PgSQL/Oracle: COMMENT ON TABLE)
|
||||
│ 自动迁移非主键索引(UNIQUE INDEX / 普通 INDEX / 全文索引)
|
||||
│ 自动迁移外键约束(所有表创建后统一添加,避免引用顺序问题)
|
||||
│ 字段默认值完整保留(含空字符串 DEFAULT '',函数型默认值自动识别)
|
||||
│ enum/set 类型自动迁移候选值(生成 CHECK 约束)
|
||||
│ decimal/datetime 精度保留(如 decimal(10,2)、datetime(3))
|
||||
│ 排序规则(collation)自动迁移:表级 + 列级(MySQL/PostgreSQL)
|
||||
│ Oracle 自增:自动创建序列+触发器
|
||||
│ 支持 --drop-target-first 先删后建
|
||||
▼
|
||||
[3/5] 逐表迁移数据(事务保护)
|
||||
│ 按 batch-size(默认 1000)分页读取,每页再拆为 100 行的 INSERT 语句
|
||||
│ 每个 batch-size 为一个独立事务,提交后即生效
|
||||
│ 当前批次失败时回滚,已提交的历史批次不受影响
|
||||
│ 基于主键 ORDER BY 稳定排序,避免分页漏行/重复
|
||||
│ blob 字段使用 PDO::PARAM_LOB 绑定
|
||||
│ SQL Server: 自动开关 IDENTITY_INSERT
|
||||
▼
|
||||
[4/5] 重置自增序列
|
||||
│ PostgreSQL: SELECT setval 重置序列到 MAX(id)+1
|
||||
│ SQL Server: DBCC CHECKIDENT 重置标识种子
|
||||
│ Oracle: ALTER SEQUENCE 重置序列
|
||||
│ MySQL / SQLite: 跳过(无需重置)
|
||||
▼
|
||||
[5/5] 逐表行数校验
|
||||
SELECT COUNT(*) 对比源和目标,输出 PASS/FAIL
|
||||
```
|
||||
|
||||
## 命令格式
|
||||
|
||||
```bash
|
||||
# =============================================================================
|
||||
# 数据库迁移命令
|
||||
# =============================================================================
|
||||
# php think db:migrate → 调用 ThinkPHP 命令行的数据库迁移工具
|
||||
# [选项] → 通过 --key=value 格式传入源/目标数据库连接参数
|
||||
# =============================================================================
|
||||
php think db:migrate [选项]
|
||||
```
|
||||
|
||||
### 必填选项
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `--src-type` | 源数据库驱动:`mysql` / `pgsql` / `sqlsrv` / `oracle` / `sqlite` |
|
||||
| `--src-database` | 源数据库名 |
|
||||
| `--dst-type` | 目标数据库驱动:`mysql` / `pgsql` / `sqlsrv` / `oracle` / `sqlite` |
|
||||
| `--dst-database` | 目标数据库名 |
|
||||
|
||||
### 源连接选项
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--src-host` | `127.0.0.1` | 源主机地址 |
|
||||
| `--src-hostport` | 自动推断 | 源端口(mysql=3306, pgsql=5432, sqlsrv=1433, oracle=1521) |
|
||||
| `--src-username` | - | 源用户名(SQLite 除外) |
|
||||
| `--src-password` | - | 源密码(SQLite 除外) |
|
||||
|
||||
### 目标连接选项
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--dst-host` | `127.0.0.1` | 目标主机地址 |
|
||||
| `--dst-hostport` | 自动推断 | 目标端口 |
|
||||
| `--dst-username` | - | 目标用户名(SQLite 除外) |
|
||||
| `--dst-password` | - | 目标密码(SQLite 除外) |
|
||||
|
||||
### 迁移选项
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--drop-target-first` | 否 | 迁移前先删除目标库中的同名表 |
|
||||
| `--batch-size` | `1000` | 每批读取的行数(影响内存占用与迁移速度) |
|
||||
| `--tables` | 全部 | 只迁移指定表(逗号分隔) |
|
||||
|
||||
## 迁移命令签名
|
||||
|
||||
### MySQL → PostgreSQL
|
||||
|
||||
```bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# MySQL 迁移到 PostgreSQL
|
||||
# -----------------------------------------------------------------------------
|
||||
# --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 \
|
||||
--drop-target-first
|
||||
```
|
||||
|
||||
### MySQL → SQL Server
|
||||
|
||||
```bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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 \
|
||||
--drop-target-first
|
||||
```
|
||||
|
||||
### MySQL → SQLite
|
||||
|
||||
```bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# MySQL 迁移到 SQLite
|
||||
# -----------------------------------------------------------------------------
|
||||
# --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
|
||||
```
|
||||
|
||||
### MySQL → MySQL(同库复制)
|
||||
|
||||
```bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# MySQL 同库复制(源和目标在同一 MySQL 实例,数据库名不同)
|
||||
# -----------------------------------------------------------------------------
|
||||
# 不带 --drop-target-first → 目标表已存在时跳过(不删除)
|
||||
php think db:migrate \
|
||||
--src-type=mysql --src-host=127.0.0.1 --src-hostport=3306 \
|
||||
--src-database=source_db --src-username=root --src-password=root \
|
||||
--dst-type=mysql --dst-host=127.0.0.1 --dst-hostport=3306 \
|
||||
--dst-database=target_db --dst-username=root --dst-password=root
|
||||
```
|
||||
|
||||
### PostgreSQL → MySQL
|
||||
|
||||
```bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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 \
|
||||
--drop-target-first
|
||||
```
|
||||
|
||||
### SQL Server → MySQL
|
||||
|
||||
```bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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 \
|
||||
--drop-target-first
|
||||
```
|
||||
|
||||
### 只迁移指定表
|
||||
|
||||
```bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# 只迁移指定表(逗号分隔表名,不含前缀)
|
||||
# -----------------------------------------------------------------------------
|
||||
# --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 \
|
||||
--drop-target-first
|
||||
```
|
||||
|
||||
## 迁移输出日志
|
||||
|
||||
### 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 \
|
||||
--drop-target-first
|
||||
|
||||
==============================================================
|
||||
[迁移开始] 源: mysql:rxthinkcmf.thinkphp6.elevue → 目标: pgsql:rxthinkcmf.thinkphp6.elevue
|
||||
[迁移开始] 待迁移 25 张表
|
||||
==============================================================
|
||||
[·] [1/5] 建目标库...
|
||||
· 目标库 "rxthinkcmf.thinkphp6.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)
|
||||
...(大表分批输出进度,每批 1000 行)...
|
||||
[think_city] +562(累计 783562)
|
||||
[✓ think_city] 共 783562 行(耗时 10m10s)
|
||||
· [4/25] 正在迁移 think_config...
|
||||
[think_config] +1(累计 1)
|
||||
[✓ think_config] 共 1 行(耗时 11ms)
|
||||
...(其余表类似)...
|
||||
· [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
|
||||
...(其余表类似)...
|
||||
[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 \
|
||||
--dst-database=rxthinkcmf --dst-username=system --dst-password=123456 \
|
||||
--drop-target-first
|
||||
|
||||
==============================================================
|
||||
[迁移开始] 源: mysql:rxthinkcmf.thinkphp6.elevue → 目标: oracle:rxthinkcmf.thinkphp6.elevue
|
||||
[迁移开始] 待迁移 25 张表
|
||||
==============================================================
|
||||
[·] [1/5] 建目标库...
|
||||
· 目标库 "rxthinkcmf" 已就绪
|
||||
[✓] [1/5] 建目标库 完成(耗时 0ms)
|
||||
[·] [2/5] 建表结构(25 张表)...
|
||||
· 为 think_article 创建序列+触发器(自增列 id)
|
||||
· 为 think_city 创建序列+触发器(自增列 id)
|
||||
...(其余含自增列的表类似)...
|
||||
[✓] [2/5] 建表结构 完成(耗时 2.1s),25 张表,118 个索引,0 个外键
|
||||
[·] [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
|
||||
...(其余序列类似)...
|
||||
[✓] [4/5] 重置自增序列 完成(耗时 180ms)
|
||||
[·] [5/5] 逐表行数校验...
|
||||
...(25 张表全部 PASS)...
|
||||
[✓] [5/5] 逐表行数校验 完成(耗时 820ms),25 张表全部 PASS
|
||||
==============================================================
|
||||
[迁移完成] 成功:785215 行 / 25 张表 / 总耗时 15m33s
|
||||
==============================================================
|
||||
```
|
||||
|
||||
:::tip Oracle 迁移要点
|
||||
- **建库**:Oracle 通过 `CREATE USER` 创建用户,库名即用户名,需要 DBA 权限
|
||||
- **自增列**:自动创建序列(`_seq`)和触发器(`_trg`),插入时自动填充
|
||||
- **序列重置**:迁移完成后通过 `ALTER SEQUENCE ... RESTART` 重置到 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 \
|
||||
--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
|
||||
[✓] [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)
|
||||
[✓] [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
|
||||
[✓] [5/5] 逐表行数校验 完成(耗时 60ms),3 张表全部 PASS
|
||||
==============================================================
|
||||
[迁移完成] 成功:274 行 / 3 张表 / 总耗时 250ms
|
||||
==============================================================
|
||||
```
|
||||
|
||||
## 类型映射
|
||||
|
||||
| 源类型 | MySQL | PostgreSQL | SqlServer | Oracle | SQLite |
|
||||
|--------|-------|------------|-----------|--------|--------|
|
||||
| bigint | bigint | bigint | bigint | NUMBER(19) | INTEGER |
|
||||
| int | int | integer | int | NUMBER(10) | INTEGER |
|
||||
| smallint | smallint | smallint | smallint | NUMBER(5) | INTEGER |
|
||||
| tinyint | tinyint | smallint | tinyint | NUMBER(3) | INTEGER |
|
||||
| decimal | decimal | numeric | decimal | NUMBER | REAL |
|
||||
| float | float | real | float | BINARY_FLOAT | REAL |
|
||||
| double | double | double precision | float | BINARY_DOUBLE | REAL |
|
||||
| varchar | varchar | varchar | nvarchar | VARCHAR2 | TEXT |
|
||||
| char | char | char | nchar | CHAR | TEXT |
|
||||
| text | text | text | ntext | CLOB | TEXT |
|
||||
| blob | blob | bytea | varbinary | BLOB | BLOB |
|
||||
| datetime | datetime | timestamp | datetime | TIMESTAMP | TEXT |
|
||||
| timestamp | timestamp | timestamp | datetime | TIMESTAMP | TEXT |
|
||||
| date | date | date | date | DATE | TEXT |
|
||||
| boolean | tinyint(1) | boolean | bit | NUMBER(1) | INTEGER |
|
||||
| json | json | jsonb | ntext | CLOB | TEXT |
|
||||
|
||||
:::tip 精度保留
|
||||
- `decimal(10,2)` → PostgreSQL `numeric(10,2)`(精度不丢失)
|
||||
- `datetime(3)` → PostgreSQL `timestamp(3)`(毫秒精度保留)
|
||||
- `enum('a','b','c')` → PostgreSQL `varchar` + `CHECK (col IN ('a','b','c'))`(候选值保留)
|
||||
:::
|
||||
|
||||
## 各驱动特殊处理
|
||||
|
||||
| 驱动 | 建库方式 | 分页排序 | 自增语法 | 索引 | 外键 | 字段注释 | 表注释 | collation | 序列重置 |
|
||||
|------|----------|----------|----------|------|------|----------|--------|-----------|----------|
|
||||
| MySQL | `CREATE DATABASE ... COLLATE` | 主键 `ORDER BY` | `AUTO_INCREMENT` | ✅ | ✅ | ✅ `COMMENT` | ✅ | ✅ 库/表/列 | 跳过 |
|
||||
| PostgreSQL | `LC_COLLATE` | 主键 `ORDER BY` | `GENERATED BY DEFAULT AS IDENTITY` | ✅ | ✅ | ✅ `COMMENT ON COLUMN` | ✅ | ✅ 库/列 | ✅ `setval` |
|
||||
| SQL Server | `COLLATE` | 主键 `ORDER BY` | `IDENTITY(1,1)` | ✅ | ✅ | ✅ `sp_addextendedproperty` | ✅ | ✅ 库级 | ✅ `DBCC CHECKIDENT` |
|
||||
| Oracle | `CREATE USER` + `GRANT` | `ROW_NUMBER()` | 序列+触发器 | ✅ | ✅ | ✅ `COMMENT ON COLUMN` | ✅ | ❌ | ✅ `ALTER SEQUENCE` |
|
||||
| SQLite | 自动创建文件 | 主键 `ORDER BY` | `INTEGER PRIMARY KEY AUTOINCREMENT` | ✅ | ✅ | ❌ | ❌ | ❌ | 跳过 |
|
||||
|
||||
## PostgreSQL 特殊处理
|
||||
|
||||
### 建库
|
||||
|
||||
迁移到 PostgreSQL 时,工具会自动连接 `postgres` 维护库执行:
|
||||
|
||||
```sql
|
||||
-- 创建目标数据库(UTF-8 编码)
|
||||
-- 若数据库已存在则跳过
|
||||
-- LC_COLLATE / LC_CTYPE:仅当源与目标驱动相同时,从源库读取 collation 并应用
|
||||
CREATE DATABASE "数据库名" WITH ENCODING 'UTF8'
|
||||
```
|
||||
|
||||
:::warning PgSQL 数据库不存在的判定
|
||||
PostgreSQL 数据库不存在时返回的 SQLSTATE 可能是 `3D000`(明确不存在)或 `08006`(连接异常,中文环境下错误信息可能乱码)。工具会主动连接 `postgres` 维护库查询 `pg_database` 确认目标库是否真的不存在,避免误判。
|
||||
:::
|
||||
|
||||
### 序列重置
|
||||
|
||||
迁移完成后,对每张有自增主键的表分两步重置序列:
|
||||
|
||||
```php
|
||||
// 第一步:通过 pg_get_serial_sequence() 获取列关联的实际序列名
|
||||
// 兼容 serial(序列名如 table_id_seq)和 IDENTITY(序列名由系统自动生成)两种风格
|
||||
$seqName = $dstPdo->query(
|
||||
"SELECT pg_get_serial_sequence('表名', '字段名') AS s"
|
||||
)->fetch()['s'];
|
||||
|
||||
// 第二步:查询当前最大值,将序列重置到 max+1
|
||||
// setval 的第三个参数 false 表示下一个 nextval 返回给定值
|
||||
$maxId = $dstPdo->query(
|
||||
"SELECT COALESCE(MAX(\"字段名\"), 0) AS m FROM \"表名\""
|
||||
)->fetch()['m'];
|
||||
$dstPdo->exec("SELECT setval({$quotedSeq}, {$maxId} + 1, false)");
|
||||
```
|
||||
|
||||
## SQL Server 特殊处理
|
||||
|
||||
### 建库
|
||||
|
||||
```sql
|
||||
-- 连接 master 库,创建目标数据库
|
||||
-- COLLATE:仅当源与目标驱动相同时,从源库读取 collation 并应用
|
||||
CREATE DATABASE [数据库名]
|
||||
```
|
||||
|
||||
:::warning SQL Server "登录失败"的判定
|
||||
SQL Server 数据库不存在时返回 SQLSTATE `28000` 并提示"登录失败",容易与密码错误混淆。工具会尝试连接 `master` 库验证凭据:若 master 连接成功说明凭据正确、是数据库不存在;若 master 也失败则为密码错误。
|
||||
:::
|
||||
|
||||
### 自增列迁移
|
||||
|
||||
数据迁移时自动处理 `IDENTITY_INSERT`:
|
||||
|
||||
```sql
|
||||
-- 迁移前开启(允许显式写入自增列的值)
|
||||
SET IDENTITY_INSERT [表名] ON
|
||||
-- 批量 INSERT ...
|
||||
-- 迁移后关闭
|
||||
SET IDENTITY_INSERT [表名] OFF
|
||||
```
|
||||
|
||||
### 序列重置
|
||||
|
||||
```sql
|
||||
-- 重置标识种子到指定值,下一次插入自动加一
|
||||
-- 第一个参数:表名(带方括号)
|
||||
-- 第三个参数:RESEED 值(当前最大 ID)
|
||||
DBCC CHECKIDENT ('[表名]', RESEED, {$maxId})
|
||||
```
|
||||
|
||||
## Oracle 特殊处理
|
||||
|
||||
### 建库
|
||||
|
||||
Oracle 的"建库"实际上是创建用户并授权。工具自动连接 Oracle 实例(默认服务名 `XE`),执行:
|
||||
|
||||
```sql
|
||||
-- 创建用户(库名即用户名,密码与连接密码一致)
|
||||
CREATE USER "用户名" IDENTIFIED BY "密码" DEFAULT TABLESPACE USERS;
|
||||
|
||||
-- 授权:CONNECT(连接)、RESOURCE(建表等基础对象操作)
|
||||
GRANT CONNECT, RESOURCE TO "用户名";
|
||||
```
|
||||
|
||||
:::warning Oracle 连接串
|
||||
Oracle 的 DSN 格式为 `oci:dbname=//host:port/service_name`,其中 `service_name` 默认为 `XE`(Express Edition)。生产环境需根据实际的 Oracle 服务名修改,可通过 `lsnrctl status` 查看。
|
||||
:::
|
||||
|
||||
### 自增列
|
||||
|
||||
Oracle 没有原生自增语法,工具自动创建序列+触发器实现等效功能:
|
||||
|
||||
```sql
|
||||
-- 创建序列(从 1 开始,每次递增 1,不缓存)
|
||||
-- 序列名规则:表名_列名_SEQ(大写)
|
||||
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
|
||||
IF :NEW.ID IS NULL THEN
|
||||
SELECT "TABLE_ID_SEQ".NEXTVAL INTO :NEW.ID FROM dual;
|
||||
END IF;
|
||||
END;
|
||||
```
|
||||
|
||||
### 序列重置
|
||||
|
||||
```sql
|
||||
-- 重置序列到 MAX(id)+1
|
||||
-- ALTER SEQUENCE ... RESTART:将序列起点设置为指定值,下一次 nextval 返回该值
|
||||
ALTER SEQUENCE "TABLE_ID_SEQ" RESTART START WITH 1234;
|
||||
```
|
||||
|
||||
:::warning Oracle 建库权限
|
||||
Oracle 建库实际上是通过 `CREATE USER` + `GRANT CONNECT, RESOURCE` 创建用户。需要 DBA 权限或具有 CREATE USER 权限的账号。连接串中的服务名默认为 `XE`(Express Edition),正式环境需根据实际配置修改。
|
||||
:::
|
||||
|
||||
## 驱动安装与环境配置
|
||||
|
||||
迁移工具基于 PDO 直连数据库,各驱动需要对应的 PDO 扩展。XAMPP 默认只自带 MySQL、PostgreSQL 和 SQLite 驱动,SQL Server 和 Oracle 需手动安装。
|
||||
|
||||
运行以下命令检查已安装的驱动:
|
||||
|
||||
```bash
|
||||
# 检查已安装的 PDO 驱动(-m 列出扩展,findstr /i 不区分大小写过滤)
|
||||
D:\xampp8.2\php\php.exe -m | findstr /i pdo
|
||||
```
|
||||
|
||||
### MySQL(XAMPP 默认自带)
|
||||
|
||||
无需额外操作。验证:
|
||||
|
||||
```bash
|
||||
# 检查 MySQL 驱动是否已加载
|
||||
D:\xampp8.2\php\php.exe -m | findstr mysql
|
||||
```
|
||||
|
||||
### PostgreSQL(XAMPP 默认自带)
|
||||
|
||||
无需额外操作。验证:
|
||||
|
||||
```bash
|
||||
# 检查 PostgreSQL 驱动是否已加载
|
||||
D:\xampp8.2\php\php.exe -m | findstr pgsql
|
||||
```
|
||||
|
||||
若未安装,编辑 `D:\xampp8.2\php\php.ini`,找到 `;extension=pdo_pgsql` 去掉分号启用。
|
||||
|
||||
### SQL Server(需手动安装)
|
||||
|
||||
XAMPP 默认不带 SQL Server 驱动,需要安装两个组件。
|
||||
|
||||
:::warning 先决条件
|
||||
安装 ODBC 驱动前,系统需要 Microsoft Visual C++ Redistributable。大多数 Windows 已预装,如果安装失败,先安装它再重试。
|
||||
:::
|
||||
|
||||
**步骤 1:安装 Microsoft ODBC Driver for SQL Server**
|
||||
|
||||
这是底层通信组件,sqlsrv 扩展依赖它,必须安装。
|
||||
|
||||
- 下载地址:https://learn.microsoft.com/zh-cn/sql/connect/odbc/download-odbc-driver-for-sql-server
|
||||
- 版本选择:推荐 ODBC Driver 18(最新),两者可与 17 并存
|
||||
- 安装:下载 x64 版本的 `msodbcsql.msi`,双击按向导完成
|
||||
|
||||
**步骤 2:下载 PHP 扩展 DLL**
|
||||
|
||||
从微软官方 GitHub 下载:
|
||||
|
||||
- 下载地址(Releases 页面):https://github.com/Microsoft/msphpsql/releases
|
||||
- 下载文件:`Windows_5.12.0RTW.zip`
|
||||
|
||||
解压后目录结构(平铺,无子目录):
|
||||
|
||||
```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`
|
||||
|
||||
- 驱动:`sqlsrv` 或 `pdo_sqlsrv`
|
||||
- PHP 版本:`81` / `82` / `83`
|
||||
- 线程安全:`ts`(Thread Safe)/ `nts`(Non-Thread Safe)
|
||||
- 架构:`x64` / `x86`
|
||||
|
||||
将以下两个文件复制到 `D:\xampp8.2\php\ext\`:
|
||||
|
||||
- `php_sqlsrv_82_ts_x64.dll`
|
||||
- `php_pdo_sqlsrv_82_ts_x64.dll`
|
||||
|
||||
:::tip 版本匹配(PHP 8.2 + TS + x64)
|
||||
- PHP 版本:`82` = PHP 8.2
|
||||
- 线程安全:XAMPP 默认 TS(运行 `php -v` 确认,输出含 `Thread Safety => enabled`)
|
||||
- 架构:`x64`(64 位系统)
|
||||
:::
|
||||
|
||||
**步骤 3:在 php.ini 中启用扩展**
|
||||
|
||||
编辑 `D:\xampp8.2\php\php.ini`(注意不是 `apache\bin\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 驱动(迁移工具使用)
|
||||
```
|
||||
|
||||
3. 保存后重启 XAMPP 中的 Apache 服务(命令行验证无需重启 Apache,只需重新运行 php 命令)
|
||||
|
||||
**步骤 4:验证安装**
|
||||
|
||||
```bash
|
||||
# 检查 SQL Server 驱动是否已加载(应看到 sqlsrv 和 pdo_sqlsrv 两行)
|
||||
D:\xampp8.2\php\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` 即可。
|
||||
|
||||
**步骤 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. 重启终端或重启电脑使环境变量生效
|
||||
|
||||
:::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"
|
||||
|
||||
# 列出所有可用的 PDO 驱动(应包含 oci)
|
||||
D:\xampp8.2\php\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 默认自带)
|
||||
|
||||
无需额外操作。验证:
|
||||
|
||||
```bash
|
||||
# 检查 SQLite 驱动是否已加载
|
||||
D:\xampp8.2\php\php.exe -m | findstr sqlite
|
||||
```
|
||||
|
||||
### 驱动安装速查表
|
||||
|
||||
| 驱动 | XAMPP 默认 | 安装方式 | 验证命令 |
|
||||
|------|-----------|----------|----------|
|
||||
| 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_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` | 命令注册 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
:::warning 迁移前检查
|
||||
1. 确保目标数据库用户有 CREATE TABLE / INSERT / CREATE INDEX / ALTER TABLE(外键)权限
|
||||
2. PostgreSQL 迁移还需要 CREATE DATABASE 权限
|
||||
3. SQL Server 迁移需要 sa 或有 CREATE DATABASE 权限的用户
|
||||
4. Oracle 迁移需要 DBA 权限或具有 CREATE USER / CREATE SEQUENCE / CREATE TRIGGER 权限的账号
|
||||
5. `--drop-target-first` 会删除目标表的全部数据(含索引、外键和注释),请谨慎使用
|
||||
6. 大表迁移建议调大 `--batch-size`(如 5000)以提高效率
|
||||
7. 迁移过程中如遇中断,已提交的 `--batch-size` 批次数据不会回滚,建议使用 `--drop-target-first` 重试
|
||||
8. 含点号的数据库名(如 `rxthinkcmf.thinkphp6.elevue`)各驱动均已适配
|
||||
9. 索引、外键和注释在建表阶段同步迁移,使用 `--drop-target-first` 重建时会一并重建
|
||||
10. 外键约束在所有表创建后统一添加,避免引用顺序问题
|
||||
11. 命令退出码:成功返回 0,失败或行数校验未通过返回 1(可用于 CI/CD 判断)
|
||||
:::
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 迁移失败后如何重试?
|
||||
|
||||
A: 使用 `--drop-target-first` 选项重新执行,会先删除目标表再重建。
|
||||
|
||||
### Q: 如何只迁移部分表?
|
||||
|
||||
A: 使用 `--tables` 选项指定表名,逗号分隔:
|
||||
```bash
|
||||
php think db:migrate ... --tables=think_user,think_role
|
||||
```
|
||||
|
||||
### Q: 大表迁移很慢怎么办?
|
||||
|
||||
A: 调大 `--batch-size`,如 `--batch-size=5000`。默认 1000 行一批,增大批量可减少数据库交互次数。
|
||||
|
||||
### Q: 目标库不存在会自动创建吗?
|
||||
|
||||
A: 会。所有驱动(MySQL / PostgreSQL / SQL Server / Oracle / SQLite)在目标库不存在时都会自动创建,无需手动建库。建库时会自动从源库读取 collation 并应用(仅当源与目标驱动相同时)。
|
||||
|
||||
### Q: PostgreSQL 报 SQLSTATE 08006 错误怎么办?
|
||||
|
||||
A: `08006` 是连接异常的通用错误码,在中文环境下错误信息可能乱码。工具会主动连接 `postgres` 维护库查询 `pg_database` 确认目标库是否不存在。如果确认不存在会自动建库;如果维护库也连不上,请检查 PostgreSQL 服务是否启动、端口是否正确、密码是否正确。
|
||||
|
||||
### Q: SQL Server 报"登录失败"怎么办?
|
||||
|
||||
A: SQL Server 数据库不存在时会返回"登录失败"(SQLSTATE 28000),工具会自动连接 `master` 库验证凭据并区分。如果确实是密码错误,请检查 `--dst-password`。
|
||||
|
||||
### 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()`)等
|
||||
- **enum/set**:自动迁移候选值,生成 `CHECK` 约束
|
||||
- **decimal/datetime 精度**:保留精度参数(如 `decimal(10,2)` → `numeric(10,2)`,`datetime(3)` → `timestamp(3)`)
|
||||
- **collation**:建库时自动应用源库 collation(仅同驱动时);建表时迁移表级和列级 collation(MySQL / PostgreSQL)
|
||||
|
||||
### Q: 数据迁移的可靠性如何保证?
|
||||
|
||||
A: 迁移工具有以下保障机制:
|
||||
- **事务保护**:每个 `--batch-size`(默认 1000 行)为一个独立事务,提交后即生效;当前批次失败时回滚,已提交的历史批次不受影响
|
||||
- **稳定排序**:分页查询基于主键列 `ORDER BY`,确保大表分批迁移不会漏行或重复
|
||||
- **退出码**:命令成功返回 0,失败或行数校验未通过返回 1,可用于 CI/CD 自动化判断
|
||||
- **行数校验**:迁移完成后逐表对比源和目标的行数,输出 PASS/FAIL
|
||||
|
||||
### Q: Oracle 自增列如何处理?
|
||||
|
||||
A: Oracle 没有原生自增语法。迁移工具会自动为自增列创建序列(`CREATE SEQUENCE`)和触发器(`CREATE TRIGGER`),实现等效的自增功能。
|
||||
|
||||
### Q: 含点号的数据库名支持吗?
|
||||
|
||||
A: 支持。如 `rxthinkcmf.thinkphp6.elevue` 这类含点号的库名,各驱动均已适配。
|
||||
|
||||
### Q: SQLite 作为目标有什么限制?
|
||||
|
||||
A: SQLite 不支持字段注释、表注释和 collation。迁移完成后数据完整,索引和外键正常迁移。
|
||||
@@ -0,0 +1,451 @@
|
||||
# 7.4 数据导入导出
|
||||
|
||||
:::tip 概述
|
||||
ExcelService 基于 PhpSpreadsheet 封装导入导出能力,支持普通导入导出和分批导出(大数据量)。导入支持表头映射,导出支持枚举显示名自动翻译。
|
||||
:::
|
||||
|
||||
## 导入流程
|
||||
|
||||
### 完整流程图
|
||||
|
||||
```text
|
||||
前端上传 Excel 文件
|
||||
│
|
||||
▼
|
||||
Controller::import()
|
||||
│ 校验文件扩展名(xls/xlsx)
|
||||
▼
|
||||
ExcelService::importWithMap($filePath, $headerMap)
|
||||
│ 读取 Excel → 按表头映射转为关联数组
|
||||
▼
|
||||
Logic::import()
|
||||
│
|
||||
├─ 枚举字段反向映射(文字 → 数字)
|
||||
│ DictService::getValue('gender', '男') → '1'
|
||||
│
|
||||
├─ 逐行调用 $this->add($row)
|
||||
│ ├─ 成功 → 计数 +1
|
||||
│ └─ 失败 → 收集错误(行号 + 原因)
|
||||
│
|
||||
▼
|
||||
返回 { count: 成功数, errors: [失败行列表] }
|
||||
```
|
||||
|
||||
### Logic 层实现
|
||||
|
||||
```php
|
||||
// ExampleLogic::import()
|
||||
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
// 1. 定义表头映射:Excel 表头 → 数据库字段
|
||||
$headerMap = [
|
||||
'案例名称' => 'name',
|
||||
'案例类型' => 'type',
|
||||
'案例状态' => 'status',
|
||||
'案例排序' => 'sort',
|
||||
];
|
||||
|
||||
// 2. 解析 Excel 为关联数组
|
||||
$data = ExcelService::importWithMap($filePath, $headerMap);
|
||||
|
||||
// 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']);
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
// 4. 逐行导入
|
||||
$count = 0;
|
||||
$errors = [];
|
||||
foreach ($data as $index => $row) {
|
||||
try {
|
||||
$this->add($row); // 调用 BaseLogic::add(),触发 beforeAdd/validateData/afterAdd
|
||||
$count++;
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = [
|
||||
'row' => $index + 2, // +2 因为第1行是表头,索引从0开始
|
||||
'reason' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['count' => $count, 'errors' => $errors];
|
||||
}
|
||||
```
|
||||
|
||||
### Controller 层实现
|
||||
|
||||
```php
|
||||
// ExampleController::import()
|
||||
|
||||
#[Log('案例演示-导入数据', Log::TYPE_IMPORT)]
|
||||
#[Permission('sys:example:import', '导入案例演示')]
|
||||
public function import(): Json
|
||||
{
|
||||
$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());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 导入响应示例
|
||||
|
||||
**全部成功:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "成功导入 10 条数据",
|
||||
"data": {
|
||||
"count": 10,
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**部分失败:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "成功导入 8 条,失败 2 条",
|
||||
"data": {
|
||||
"count": 8,
|
||||
"errors": [
|
||||
{ "row": 3, "reason": "标题不能为空" },
|
||||
{ "row": 7, "reason": "编码已存在" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 导出流程
|
||||
|
||||
### 分批导出(推荐)
|
||||
|
||||
```php
|
||||
// ExampleLogic::export()
|
||||
|
||||
public function export(array $params = []): string
|
||||
{
|
||||
// 1. 定义导出表头:字段名 → Excel 表头
|
||||
// 枚举字段使用 {字段名}Text(自动翻译后的字段)
|
||||
$headers = [
|
||||
'name' => '案例名称',
|
||||
'typeText' => '案例类型', // 枚举显示名
|
||||
'statusText' => '案例状态', // 枚举显示名
|
||||
'sort' => '案例排序',
|
||||
'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);
|
||||
}
|
||||
```
|
||||
|
||||
### Controller 层实现
|
||||
|
||||
```php
|
||||
// ExampleController::export()
|
||||
|
||||
#[Log('案例演示-导出数据', Log::TYPE_EXPORT)]
|
||||
#[Permission('sys:example:export', '导出案例演示')]
|
||||
public function export(): Json
|
||||
{
|
||||
try {
|
||||
$filePath = $this->logic->export($this->getParams());
|
||||
return $this->success($filePath, '导出成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail('导出失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 一次性导出(小数据量)
|
||||
|
||||
```php
|
||||
// 适用于数据量较小(< 1000 条)的场景
|
||||
$data = $this->allList($params);
|
||||
return ExcelService::export($data, $headers, '导出数据.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 参数详解
|
||||
|
||||
```php
|
||||
ExcelService::importWithMap($filePath, $headerMap);
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `$filePath` | string | Excel 文件的绝对路径 |
|
||||
| `$headerMap` | array | `[Excel表头 => 数据库字段]` 映射 |
|
||||
|
||||
**表头映射示例:**
|
||||
|
||||
```php
|
||||
$headerMap = [
|
||||
'用户名' => 'username', // Excel 中"用户名"列 → $row['username']
|
||||
'姓名' => 'realname', // Excel 中"姓名"列 → $row['realname']
|
||||
'性别' => 'gender', // Excel 中"性别"列 → $row['gender']
|
||||
'手机号' => 'mobile', // Excel 中"手机号"列 → $row['mobile']
|
||||
];
|
||||
```
|
||||
|
||||
### chunkExport 参数详解
|
||||
|
||||
```php
|
||||
ExcelService::chunkExport($headers, $callback, $fileName, $pageSize);
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `$headers` | array | `[字段名 => Excel表头]` 映射 |
|
||||
| `$callback` | callable | 分批回调,接收 `$page` 参数,返回该批数据 |
|
||||
| `$fileName` | string | 导出文件名 |
|
||||
| `$pageSize` | int | 每批数据量(默认 500) |
|
||||
|
||||
## 枚举字段处理
|
||||
|
||||
### 导入时:文字 → 值
|
||||
|
||||
```php
|
||||
// Excel 中"男" → 数据库中 1
|
||||
foreach ($data as &$row) {
|
||||
if (!empty($row['gender']) && !is_numeric($row['gender'])) {
|
||||
$row['gender'] = DictService::getValue('gender', $row['gender']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 导出时:值 → 文字
|
||||
|
||||
```php
|
||||
// 配置 serializeMaps 后自动翻译
|
||||
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $serializeMaps = [
|
||||
'gender' => 'gender',
|
||||
'status' => 'example_status',
|
||||
];
|
||||
|
||||
/**
|
||||
* 导出表头:使用 Text 后缀字段,自动翻译枚举值
|
||||
*/
|
||||
$headers = [
|
||||
'name' => '姓名',
|
||||
'genderText' => '性别',
|
||||
'statusText' => '状态',
|
||||
];
|
||||
```
|
||||
|
||||
## 完整示例:用户导入导出
|
||||
|
||||
### 导入
|
||||
|
||||
```php
|
||||
// UserLogic::import()
|
||||
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
$headerMap = [
|
||||
'用户名' => 'username',
|
||||
'姓名' => 'realname',
|
||||
'性别' => 'gender',
|
||||
'手机号' => 'mobile',
|
||||
'邮箱' => 'email',
|
||||
'状态' => 'status',
|
||||
];
|
||||
|
||||
$data = ExcelService::importWithMap($filePath, $headerMap);
|
||||
|
||||
// 枚举反向映射
|
||||
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'] = DictService::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];
|
||||
}
|
||||
```
|
||||
|
||||
### 导出
|
||||
|
||||
```php
|
||||
// UserLogic::export()
|
||||
|
||||
public function export(array $params = []): string
|
||||
{
|
||||
$headers = [
|
||||
'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);
|
||||
}
|
||||
```
|
||||
|
||||
## 前端集成
|
||||
|
||||
### 导入
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-upload
|
||||
:action="'/api/example/import'"
|
||||
:headers="{ Authorization: `Bearer ${token}` }"
|
||||
:on-success="handleImportSuccess"
|
||||
:on-error="handleImportError"
|
||||
accept=".xls,.xlsx"
|
||||
>
|
||||
<el-button type="primary">导入</el-button>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const handleImportSuccess = (response) => {
|
||||
if (response.code === 0) {
|
||||
const { count, errors } = response.data;
|
||||
if (errors.length === 0) {
|
||||
ElMessage.success(`成功导入 ${count} 条数据`);
|
||||
} else {
|
||||
ElMessage.warning(`成功 ${count} 条,失败 ${errors.length} 条`);
|
||||
// 可以展示错误详情
|
||||
}
|
||||
refreshTable(); // 刷新列表
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
### 导出
|
||||
|
||||
```typescript
|
||||
// 导出接口返回文件 URL,前端直接下载
|
||||
const handleExport = async () => {
|
||||
const res = await exportExample(params);
|
||||
window.open(res); // res 是文件 URL
|
||||
};
|
||||
```
|
||||
|
||||
## 性能建议
|
||||
|
||||
| 场景 | 方式 | 说明 |
|
||||
|------|------|------|
|
||||
| 数据量 < 1000 | `export()` 一次性导出 | 简单直接 |
|
||||
| 数据量 ≥ 1000 | `chunkExport()` 分批导出 | 避免内存溢出 |
|
||||
| 导入大量数据 | 逐行调用 `add()` | 单行失败不影响其他行 |
|
||||
| 关联字段导出 | 预加载名称映射 | 避免 N+1 查询 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:导入提示"文件格式不正确"
|
||||
|
||||
**原因:** 文件扩展名不是 `.xls` 或 `.xlsx`。
|
||||
|
||||
**解决:** Controller 中校验扩展名,前端 `accept=".xls,.xlsx"` 限制文件类型。
|
||||
|
||||
### 问题 2:导入时中文乱码
|
||||
|
||||
**原因:** Excel 文件编码问题(旧版 .xls 可能是 GBK 编码)。
|
||||
|
||||
**解决:** PhpSpreadsheet 自动处理编码,通常不需要手动处理。如仍有问题,检查 Excel 文件是否损坏。
|
||||
|
||||
### 问题 3:导出内存溢出
|
||||
|
||||
**原因:** 使用 `export()` 一次性导出大数据量。
|
||||
|
||||
**解决:** 改用 `chunkExport()` 分批导出,每批 500 条。
|
||||
|
||||
### 问题 4:枚举字段导出为数字
|
||||
|
||||
**原因:** 导出表头未使用 `Text` 后缀字段。
|
||||
|
||||
**解决:** 确保 Logic 中配置了 `serializeMaps`,导出表头使用 `{字段名}Text`。
|
||||
@@ -0,0 +1,72 @@
|
||||
# 7.5 前端文件组件
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目中的文件处理组件,包括上传组件(BasicUpload)和图片预览组件的使用方法。
|
||||
:::
|
||||
|
||||
## Upload 上传组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicUpload
|
||||
v-model="formData.avatar"
|
||||
:action="uploadUrl"
|
||||
:maxCount="1"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BasicUpload } from '@/components/Upload';
|
||||
|
||||
const uploadUrl = '/api/upload/uploadFile';
|
||||
const formData = ref({ avatar: '' });
|
||||
</script>
|
||||
```
|
||||
|
||||
## Editor 富文本编辑器
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Editor v-model="formData.content" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Editor from '@/components/Editor/index.vue';
|
||||
|
||||
const formData = ref({ content: '' });
|
||||
</script>
|
||||
```
|
||||
|
||||
## Excel 导入组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ImportExcel @success="handleImportSuccess" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ImportExcel from '@/components/Excel/src/ImportExcel.vue';
|
||||
|
||||
const handleImportSuccess = (data) => {
|
||||
// data 为解析后的数组
|
||||
console.log(data);
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## Excel 导出
|
||||
|
||||
```typescript
|
||||
import { jsonToSheetXlsx } from '@/components/Excel/src/Export2Excel';
|
||||
|
||||
// 导出 Excel
|
||||
jsonToSheetXlsx({
|
||||
data: tableData.value,
|
||||
header: {
|
||||
name: '名称',
|
||||
statusText: '状态',
|
||||
createTime: '创建时间',
|
||||
},
|
||||
filename: '导出数据.xlsx',
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,140 @@
|
||||
# 7.3 富文本处理
|
||||
|
||||
:::tip 概述
|
||||
富文本(如文章内容)中的图片/视频也需要处理。使用 `[IMG_URL]` 占位符机制,支持域名变更场景。
|
||||
:::
|
||||
|
||||
## 处理流程图
|
||||
|
||||
```html
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 写入流程(add/update) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 前端编辑器插入图片 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 上传到临时目录 temp/日期/ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 提交 HTML 内容到后端 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ BaseLogic::processContentFieldsOnSave() │
|
||||
│ │ │
|
||||
│ ├─ 解析 <img> 标签的 src │
|
||||
│ ├─ 解析 <video>/<source>/<embed> 标签的 src │
|
||||
│ ├─ 解析 <a> 标签的文件 href │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ save_file() 迁移临时文件 → 正式目录 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 替换为 [IMG_URL]/相对路径 占位符 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 数据库存储含占位符的 HTML │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 读取流程(detail/pageList) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 数据库读取含占位符的 HTML │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ BaseLogic::processContentFieldsOnRead() │
|
||||
│ │ │
|
||||
│ ├─ 替换 [IMG_URL] 为实际域名 │
|
||||
│ │ 如 https://cdn.example.com │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 返回完整 URL 的 HTML 给前端 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 在 Logic 中配置
|
||||
|
||||
```php
|
||||
class ArticleLogic extends BaseLogic
|
||||
{
|
||||
/**
|
||||
* 富文本字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $contentFields = ['content'];
|
||||
}
|
||||
```
|
||||
|
||||
配置后 BaseLogic 自动处理:
|
||||
- **add/update**:调用 `save_content()` 处理临时文件
|
||||
- **detail/pageList/allList**:调用 `processContentFieldsOnRead()` 替换占位符
|
||||
|
||||
## 为什么使用占位符?
|
||||
|
||||
:::tip 域名变更场景
|
||||
假设初始配置 `FILE.DOMAIN_URL = https://cdn.example.com`,文章内容中图片 URL 为:
|
||||
`https://cdn.example.com/article/20260920/abc.jpg`
|
||||
|
||||
存储时替换为:`[IMG_URL]/article/20260920/abc.jpg`
|
||||
|
||||
如果后续域名变更为 `https://cdn2.example.com`,只需修改 `.env` 中的 `FILE.DOMAIN_URL`,已存储的内容无需逐条更新,读取时自动替换为新域名。
|
||||
:::
|
||||
|
||||
## 处理范围
|
||||
|
||||
`save_content()` 函数处理以下 HTML 标签:
|
||||
|
||||
| 标签 | 属性 | 说明 |
|
||||
|------|------|------|
|
||||
| `<img>` | `src` | 图片 |
|
||||
| `<video>` | `src` | 视频 |
|
||||
| `<source>` | `src` | 视频源 |
|
||||
| `<embed>` | `src` | 嵌入内容 |
|
||||
| `<a>` | `href` | 文件链接(仅处理带扩展名的链接) |
|
||||
|
||||
## 代码示例
|
||||
|
||||
```php
|
||||
// app/common.php 中的 save_content 函数
|
||||
|
||||
function save_content(string &$content, string $saveDir = 'article'): bool
|
||||
{
|
||||
$config = config('file');
|
||||
$domainUrl = rtrim($config['domain_url'] ?? '', '/');
|
||||
|
||||
if (empty($domainUrl) || empty($content)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 收集所有需要处理的 URL
|
||||
$urlMap = [];
|
||||
|
||||
// 1. 匹配 <img> 标签的 src
|
||||
save_content_match($content, '/<img[^>]+src=["\']?([^"\'>\s]+)/i', $domainUrl, $saveDir, $urlMap);
|
||||
|
||||
// 2. 匹配 <video>/<source>/<embed> 标签的 src
|
||||
save_content_match($content, '/<(?:video|source|embed)[^>]+src=["\']?([^"\'>\s]+)/i', $domainUrl, $saveDir, $urlMap);
|
||||
|
||||
// 3. 匹配 <a> 标签的 href(指向文件的情况)
|
||||
save_content_match($content, '/<a[^>]+href=["\']?([^"\'>\s]+\.\w{2,5})["\']?/i', $domainUrl, $saveDir, $urlMap);
|
||||
|
||||
// 批量替换
|
||||
if (!empty($urlMap)) {
|
||||
$content = str_replace(array_keys($urlMap), array_values($urlMap), $content);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
:::warning 富文本安全
|
||||
- 富文本内容可能包含 XSS 攻击代码,建议在前端使用编辑器的安全模式
|
||||
- `save_content` 只处理本站域名下的 URL,外部 URL 不做处理
|
||||
- 图片标签的 `src` 必须是完整 URL(包含域名),否则无法识别
|
||||
:::
|
||||
@@ -0,0 +1,335 @@
|
||||
# 7.2 上传配置详解
|
||||
|
||||
:::tip 概述
|
||||
文件上传配置定义在 `config/file.php` 和 `.env` 中,控制上传目录、域名、大小限制、允许的文件类型等。所有值通过 `env()` 函数从 `.env` 文件读取,支持多环境切换。
|
||||
:::
|
||||
|
||||
## 配置文件
|
||||
|
||||
```php
|
||||
// config/file.php — 文件上传配置
|
||||
// 所有值通过 env() 函数从 .env 文件读取,支持多环境切换
|
||||
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 → 字节)
|
||||
];
|
||||
```
|
||||
|
||||
## 配置项详解
|
||||
|
||||
| 配置项 | .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 — 上传根目录
|
||||
|
||||
```ini
|
||||
; .env
|
||||
[FILE]
|
||||
UPLOAD_DIR = D:/uploads/rxthinkcmf
|
||||
```
|
||||
|
||||
**要求:**
|
||||
- 必须是绝对路径
|
||||
- 目录必须存在且有写入权限
|
||||
- 生产环境建议放在项目目录外(防止被 Web 直接访问)
|
||||
|
||||
**各环境示例:**
|
||||
|
||||
| 环境 | 路径示例 |
|
||||
|------|---------|
|
||||
| Windows 开发 | `D:/uploads/rxthinkcmf` |
|
||||
| Linux 开发 | `/home/www/uploads/rxthinkcmf` |
|
||||
| Linux 生产 | `/data/uploads/rxthinkcmf` |
|
||||
| Docker | `/uploads/rxthinkcmf`(挂载卷) |
|
||||
|
||||
### domain_url — 文件访问域名
|
||||
|
||||
```ini
|
||||
; .env
|
||||
[FILE]
|
||||
DOMAIN_URL = https://cdn.example.com
|
||||
```
|
||||
|
||||
**要求:**
|
||||
- 不要以 `/` 结尾
|
||||
- 必须是前端可访问的域名
|
||||
- 本地开发可留空(使用相对路径)
|
||||
|
||||
**各环境示例:**
|
||||
|
||||
| 环境 | 域名示例 |
|
||||
|------|---------|
|
||||
| 本地开发 | (留空,使用相对路径) |
|
||||
| 测试环境 | `https://test-cdn.example.com` |
|
||||
| 生产环境 | `https://cdn.example.com` |
|
||||
| 对象存储 | `https://bucket-name.oss-cn-hangzhou.aliyuncs.com` |
|
||||
|
||||
### max_size — 文件大小限制
|
||||
|
||||
```ini
|
||||
; .env
|
||||
[FILE]
|
||||
MAX_SIZE = 10
|
||||
```
|
||||
|
||||
**单位:** MB(配置文件中会自动转换为字节:`10 * 1024 * 1024`)
|
||||
|
||||
**建议值:**
|
||||
|
||||
| 场景 | 建议值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 头像上传 | 2 MB | 图片通常不会太大 |
|
||||
| 文章封面 | 5 MB | 压缩后的图片 |
|
||||
| 通用文件上传 | 10 MB | 默认值 |
|
||||
| 文档上传 | 20 MB | PDF/Word 等 |
|
||||
| 视频上传 | 100 MB | 需配合分片上传 |
|
||||
|
||||
### image_ext — 图片允许后缀
|
||||
|
||||
```ini
|
||||
; .env
|
||||
[FILE]
|
||||
IMAGE_EXT = gif,jpg,jpeg,png,bmp,webp,svg
|
||||
```
|
||||
|
||||
**默认值:** `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` 接口,允许所有合法文件格式。
|
||||
|
||||
## .env 配置示例
|
||||
|
||||
### 开发环境
|
||||
|
||||
```ini
|
||||
[FILE]
|
||||
UPLOAD_DIR = D:/uploads/rxthinkcmf
|
||||
DOMAIN_URL =
|
||||
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
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```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/
|
||||
```
|
||||
|
||||
### 目录命名规则
|
||||
|
||||
| 目录 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `temp/` | 临时文件目录 | 上传后暂存于此 |
|
||||
| `{module}/` | 正式目录(按模块) | 由 Logic 的 `fileSaveDir` 配置 |
|
||||
| `{date}/` | 日期子目录 | `20260920`(自动创建) |
|
||||
| `{random}.{ext}` | 随机文件名 | `a1b2c3d4e5f6.jpg`(防止冲突) |
|
||||
|
||||
## 文件生命周期
|
||||
|
||||
```text
|
||||
1. 前端上传 → upload_file()
|
||||
保存到: {upload_dir}/temp/20260920/a1b2c3d4e5f6.jpg
|
||||
返回: https://cdn.example.com/temp/20260920/a1b2c3d4e5f6.jpg
|
||||
|
||||
2. 前端提交表单 → 携带 fileUrl
|
||||
POST /api/example/add { name: "测试", avatar: "https://..." }
|
||||
|
||||
3. BaseLogic::add() → save_file()
|
||||
检测到 temp/ → 移动到正式目录
|
||||
{upload_dir}/temp/20260920/a1b2c3d4e5f6.jpg
|
||||
→ {upload_dir}/example/20260920/a1b2c3d4e5f6.jpg
|
||||
返回相对路径入库: /uploads/example/20260920/a1b2c3d4e5f6.jpg
|
||||
|
||||
4. BaseLogic::detail() → get_file_url()
|
||||
相对路径 → 拼接域名
|
||||
/uploads/example/20260920/a1b2c3d4e5f6.jpg
|
||||
→ https://cdn.example.com/uploads/example/20260920/a1b2c3d4e5f6.jpg
|
||||
```
|
||||
|
||||
## 与 Logic 层的配合
|
||||
|
||||
```php
|
||||
class ArticleLogic extends BaseTenantLogic
|
||||
{
|
||||
/**
|
||||
* 需要自动处理的文件上传字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $fileFields = ['cover'];
|
||||
|
||||
/**
|
||||
* 文件保存的子目录
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $fileSaveDir = 'article';
|
||||
}
|
||||
```
|
||||
|
||||
**实际路径映射:**
|
||||
|
||||
```text
|
||||
配置:
|
||||
upload_dir = /data/uploads/rxthinkcmf
|
||||
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
|
||||
```
|
||||
|
||||
## Nginx 配置
|
||||
|
||||
### 静态文件直接由 Nginx 返回
|
||||
|
||||
```nginx
|
||||
# 配置 Nginx 直接处理文件请求,不经过 PHP
|
||||
location /uploads/ {
|
||||
alias /data/uploads/rxthinkcmf/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
### 禁止上传目录执行 PHP
|
||||
|
||||
```nginx
|
||||
# 安全:禁止上传目录执行 PHP 文件
|
||||
location ~* /uploads/.*\.php$ {
|
||||
deny all;
|
||||
}
|
||||
```
|
||||
|
||||
### 禁止访问隐藏文件
|
||||
|
||||
```nginx
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:上传提示"目录不可写"
|
||||
|
||||
**原因:** `upload_dir` 目录没有写入权限。
|
||||
|
||||
**解决:**
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
chmod -R 755 /data/uploads/rxthinkcmf
|
||||
chown -R www:www /data/uploads/rxthinkcmf
|
||||
```
|
||||
|
||||
### 问题 2:上传后图片不显示
|
||||
|
||||
**原因:** `domain_url` 配置错误或 Nginx 未配置静态文件处理。
|
||||
|
||||
**解决:**
|
||||
1. 检查 `.env` 中 `DOMAIN_URL` 是否正确
|
||||
2. 检查 Nginx 是否配置了 `/uploads/` 的 alias
|
||||
3. 浏览器打开图片 URL 看是否能直接访问
|
||||
|
||||
### 问题 3:文件大小限制不生效
|
||||
|
||||
**原因:** PHP 配置限制了上传大小。
|
||||
|
||||
**解决:**
|
||||
|
||||
```ini
|
||||
; php.ini
|
||||
upload_max_filesize = 20M
|
||||
post_max_size = 25M
|
||||
max_execution_time = 300
|
||||
```
|
||||
|
||||
### 问题 4:临时文件未清理
|
||||
|
||||
**原因:** `temp/` 目录中的文件在表单提交后未被 `save_file()` 迁移。
|
||||
|
||||
**解决:** 定期清理 `temp/` 目录中超过 1 天的文件:
|
||||
|
||||
```bash
|
||||
# 定时任务:每天凌晨清理 1 天前的临时文件
|
||||
find /data/uploads/rxthinkcmf/temp/ -type f -mtime +1 -delete
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
| 建议 | 说明 |
|
||||
|------|------|
|
||||
| 上传目录放在项目外 | 防止被 Web 直接访问源码 |
|
||||
| Nginx 禁止执行 PHP | 防止上传恶意 PHP 文件并执行 |
|
||||
| 扩展名白名单 | 只允许已知安全的文件类型 |
|
||||
| MIME 类型检查 | `finfo` 检测真实 MIME,防止伪造扩展名 |
|
||||
| 随机文件名 | 防止路径遍历和文件名注入 |
|
||||
| 文件大小限制 | 防止大文件 DoS 攻击 |
|
||||
| 定期清理临时文件 | 避免磁盘空间被占满 |
|
||||
@@ -0,0 +1,464 @@
|
||||
# 7.1 文件上传全流程
|
||||
|
||||
:::tip 概述
|
||||
文件处理分为两个阶段:上传到临时目录(upload_file)→ 入库时迁移到正式目录(save_file)。这种"两阶段"设计将文件上传与业务逻辑解耦,前端先上传获取临时 URL,提交表单时 Logic 层自动将临时文件迁移到正式目录。
|
||||
:::
|
||||
|
||||
## 完整流程图
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 第一阶段:上传 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
前端 后端
|
||||
│ │
|
||||
│── POST /api/upload ────────►│
|
||||
│ Content-Type: │
|
||||
│ multipart/form-data │
|
||||
│ file: <二进制> │
|
||||
│ │
|
||||
│ ┌──────┴──────┐
|
||||
│ │upload_file()│
|
||||
│ │ │
|
||||
│ │ 1. 检查上传状态
|
||||
│ │ 2. finfo 检测真实 MIME
|
||||
│ │ 3. 按类型校验扩展名
|
||||
│ │ 4. 校验文件大小
|
||||
│ │ 5. 图片额外 getimagesize 校验
|
||||
│ │ 6. 保存到 temp/日期/随机文件名
|
||||
│ └──────┬──────┘
|
||||
│ │
|
||||
│◄── { fileUrl, filePath, ────│
|
||||
│ originalName, fileSize, │
|
||||
│ fileExtension, fileType }│
|
||||
│ │
|
||||
│ 前端显示预览 │
|
||||
│ 或暂存 URL │
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 第二阶段:入库 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
前端 后端
|
||||
│ │
|
||||
│── POST /api/example/add ──►│
|
||||
│ { name: "测试", │
|
||||
│ avatar: "https:// │
|
||||
│ cdn.example.com/ │
|
||||
│ temp/20260920/ │
|
||||
│ abc123.jpg" } │
|
||||
│ │
|
||||
│ ┌──────┴──────┐
|
||||
│ │ BaseLogic │
|
||||
│ │ ::add() │
|
||||
│ └──────┬──────┘
|
||||
│ │
|
||||
│ ┌──────┴──────┐
|
||||
│ │ processFile │
|
||||
│ │ FieldsOn │
|
||||
│ │ Save() │
|
||||
│ └──────┬──────┘
|
||||
│ │
|
||||
│ ┌──────┴──────┐
|
||||
│ │ save_file() │
|
||||
│ │ │
|
||||
│ │ 1. 去掉域名得到相对路径
|
||||
│ │ 2. 包含 temp/ → 移动到正式目录
|
||||
│ │ 3. 外部URL → 下载保存
|
||||
│ └──────┬──────┘
|
||||
│ │
|
||||
│ ┌──────┴──────┐
|
||||
│ │ 数据库存储 │
|
||||
│ │ /uploads/ │
|
||||
│ │ example/ │
|
||||
│ │ 20260920/ │
|
||||
│ │ abc123.jpg │
|
||||
│ └──────┬──────┘
|
||||
│ │
|
||||
│◄── { code:0, data:{id:1} }─│
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 第三阶段:读取 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
前端 后端
|
||||
│ │
|
||||
│── GET /api/example/detail/1►│
|
||||
│ │
|
||||
│ ┌──────┴──────┐
|
||||
│ │processFile │
|
||||
│ │FieldsOnRead │
|
||||
│ │() │
|
||||
│ └──────┬──────┘
|
||||
│ │
|
||||
│ ┌──────┴──────┐
|
||||
│ │get_file_url │
|
||||
│ │() │
|
||||
│ │拼接域名 │
|
||||
│ └──────┬──────┘
|
||||
│ │
|
||||
│◄── { avatar: "https://──────│
|
||||
│ cdn.example.com/uploads/ │
|
||||
│ example/20260920/ │
|
||||
│ abc123.jpg" } │
|
||||
```
|
||||
|
||||
## upload_file 函数
|
||||
|
||||
```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
|
||||
{
|
||||
// ... 完整实现见 app/common.php
|
||||
}
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `$formName` | string | `'file'` | 表单字段名 |
|
||||
| `$type` | string | `'file'` | 文件类型:`image`(仅图片)/ `file`(所有文件) |
|
||||
|
||||
### 返回值
|
||||
|
||||
**成功:**
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
## get_file_url 函数
|
||||
|
||||
```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"
|
||||
│
|
||||
▼ get_file_url()
|
||||
│
|
||||
▼ 拼接 domain_url
|
||||
输出: "https://cdn.example.com/uploads/example/20260920/abc123.jpg"
|
||||
```
|
||||
|
||||
## 在 Logic 中配置
|
||||
|
||||
```php
|
||||
class ExampleLogic extends BaseLogic
|
||||
{
|
||||
/**
|
||||
* 需要自动处理的文件上传字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $fileFields = ['avatar', 'cover'];
|
||||
|
||||
/**
|
||||
* 文件保存的子目录
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $fileSaveDir = 'example';
|
||||
}
|
||||
```
|
||||
|
||||
### 自动行为
|
||||
|
||||
| 操作 | 自动行为 |
|
||||
|------|---------|
|
||||
| `add()` | `processFileFieldsOnSave()` → 调用 `save_file()` 迁移临时文件 |
|
||||
| `update()` | `processFileFieldsOnSave()` → 调用 `save_file()` 迁移临时文件 |
|
||||
| `detail()` | `processFileFieldsOnRead()` → 调用 `get_file_url()` 补全域名 |
|
||||
| `pageList()` | `processFileFieldsOnReadList()` → 调用 `get_file_url()` 补全域名 |
|
||||
| `allList()` | `processFileFieldsOnReadList()` → 调用 `get_file_url()` 补全域名 |
|
||||
|
||||
### 多文件字段
|
||||
|
||||
```php
|
||||
class UserLogic extends BaseTenantLogic
|
||||
{
|
||||
/**
|
||||
* 需要自动处理的文件上传字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $fileFields = ['avatar'];
|
||||
|
||||
/**
|
||||
* 文件保存的子目录
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $fileSaveDir = 'user/avatar';
|
||||
}
|
||||
|
||||
class ArticleLogic extends BaseTenantLogic
|
||||
{
|
||||
/**
|
||||
* 需要自动处理的文件上传字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $fileFields = ['cover', 'attachment'];
|
||||
|
||||
/**
|
||||
* 文件保存的子目录
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $fileSaveDir = 'article';
|
||||
}
|
||||
```
|
||||
|
||||
## 前端上传接口
|
||||
|
||||
### 上传文件
|
||||
|
||||
```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 上传组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-upload
|
||||
:action="'/api/upload/uploadFile'"
|
||||
:headers="{ Authorization: `Bearer ${token}` }"
|
||||
:on-success="handleUploadSuccess"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<el-button type="primary">上传文件</el-button>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isLt10M) {
|
||||
ElMessage.error('文件大小不能超过 10MB');
|
||||
}
|
||||
return isLt10M;
|
||||
};
|
||||
|
||||
const handleUploadSuccess = (response) => {
|
||||
// response.data.fileUrl 是完整 URL
|
||||
// 提交表单时使用此 URL
|
||||
form.value.avatar = response.data.fileUrl;
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
### 提交表单
|
||||
|
||||
```typescript
|
||||
// 前端提交时,avatar 字段的值是上传接口返回的 fileUrl
|
||||
const formData = {
|
||||
name: '测试',
|
||||
avatar: 'https://cdn.example.com/temp/20260920/abc123.jpg',
|
||||
};
|
||||
|
||||
// 后端 Logic 层自动:
|
||||
// 1. 从 avatar 字段检测到临时文件 URL
|
||||
// 2. 调用 save_file() 迁移到正式目录
|
||||
// 3. 去掉域名,只存相对路径入库
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
```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'),
|
||||
];
|
||||
```
|
||||
|
||||
## 安全检查链
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user