签入版本
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
# 7.6 数据库迁移工具
|
||||
|
||||
:::tip 概述
|
||||
整库迁移器(internal/migrate + cmd/migrator)实现五方言互迁:源库内省 → 中间表示 → 目标方言 DDL → 分批搬运 → 序列修复 → 行数校验。纯 CLI、gdb 动态建连裸读裸写(不走 dao,不触发全局钩子)。
|
||||
:::
|
||||
|
||||
## 五阶段流程
|
||||
|
||||
```text
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ 1/5 建目标库│──▶│ 2/5 建表结构│──▶│ 3/5 搬运数据│──▶│ 4/5 序列修复│──▶│ 5/5 行数校验│
|
||||
│ ensureDB │ │ 内省→建表 │ │ 分批 INSERT│ │ setval等 │ │ COUNT 对比 │
|
||||
│ (不存在才建)│ │ →建索引 │ │ 进度回调 │ │ │ │ PASS/FAIL │
|
||||
└────────────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘
|
||||
drop-first: 无主键表报错跳过 pgsql/mssql 专项 不一致→
|
||||
先 DropAllTables 其余方言免修 整体失败
|
||||
非清空: 同名表拦截跳过
|
||||
```
|
||||
|
||||
## CLI 参数
|
||||
|
||||
```sh
|
||||
go run ./cmd/migrator \
|
||||
-src-type=mysql -src-host=127.0.0.1 -src-hostport=3306 \
|
||||
-src-database=easygoadmin.goframe.elevue -src-username=root -src-password=root \
|
||||
-dst-type=pgsql -dst-host=127.0.0.1 -dst-hostport=5432 \
|
||||
-dst-database=easygoadmin.goframe.elevue -dst-username=postgres -dst-password=123456 \
|
||||
-drop-target-first
|
||||
```
|
||||
|
||||
| 参数组 | 键 | 默认 |
|
||||
|--------|-----|------|
|
||||
| 源库 | -src-type / host / hostport / database / username / password | mysql / 127.0.0.1 / 3306 / — / root / — |
|
||||
| 目标库 | -dst-type / host / hostport / database / username / password | pgsql / 127.0.0.1 / 5432 / — / postgres / — |
|
||||
| 行为 | -drop-target-first | 迁移前清空目标库**全部表**(慎用) |
|
||||
| | -tables | 只迁指定表(逗号分隔白名单,留空迁全部) |
|
||||
| | -batch-size | 数据搬运批大小(默认 1000) |
|
||||
|
||||
## 核心设计
|
||||
|
||||
```text
|
||||
独立于业务基座:
|
||||
gdb.New 动态连接源库与目标库(独立命名节点 migrate_src/migrate_dst,
|
||||
不触碰 config.yaml 默认连接),裸读裸写——
|
||||
全程不触发全局钩子(租户隔离/软删/审计盖章),数据原样落库
|
||||
|
||||
五方言目标渲染器(Renderer 接口:Quote/Column/CreateTable/CreateIndex/
|
||||
DropAllTables/FixSequence):
|
||||
mysql AUTO_INCREMENT + COMMENT 内联
|
||||
pgsql IDENTITY + COMMENT ON 独立语句 + setval 序列修复 + CASCADE
|
||||
sqlite 极简类型 + AUTOINCREMENT + 免修序
|
||||
mssql IDENTITY + NVARCHAR + 扩展属性注释 + 内联 INSERT(绕 2100 参数上限)
|
||||
+ DBCC CHECKIDENT 修序
|
||||
oracle IDENTITY + NUMBER/NVARCHAR2 + COMMENT ON + 免修序
|
||||
|
||||
中间表示(IR):20 种归一类型;剥离 unsigned/pgsql 时区修饰;
|
||||
tinyint(1)→bool;索引命名统一 uk_/idx_ 规范(迁移后全库一种命名)
|
||||
|
||||
数据搬运 doCopy:主键游标分页;无主键表直接报错("请先补主键")
|
||||
```
|
||||
|
||||
## 数据搬运循环图(doCopy,主键游标分页)
|
||||
|
||||
```text
|
||||
lastPK=nil
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ SELECT 列集 FROM src.table │
|
||||
│ WHERE pk > lastPK ORDER BY pk LIMIT N │◀──────┐
|
||||
└──────────────┬───────────────────────────┘ │
|
||||
行数=0?─ 是 ─▶ 返回 total │
|
||||
否 │
|
||||
▼ │
|
||||
batch=[{列:值(字节转字符串)}…],lastPK=末行 pk ────┘
|
||||
│
|
||||
▼
|
||||
目标写入:通用方言 → 参数化多行 INSERT
|
||||
mssql → 内联字面量 INSERT(自增列:事务内 IDENTITY_INSERT ON/OFF)
|
||||
│
|
||||
▼
|
||||
total += len(batch);onBatch 进度回调;len(batch)<N → 结束
|
||||
```
|
||||
|
||||
要点:源端按主键排序快照分页(快照语义,并发写入可能漂移);字节切片值转字符串(convertValue);mssql 因 2100 参数占位符上限走内联字面量 + 单批小事务(IDENTITY_INSERT ON→INSERT→OFF→COMMIT)。
|
||||
|
||||
## 关键原则与限制
|
||||
|
||||
::: warning 限制清单
|
||||
1. 无外键/视图/存储过程/触发器迁移(0 外键设计,只迁表/列/二级索引/注释/数据)
|
||||
2. 无主键表拒绝搬运(分页搬运依赖主键排序)
|
||||
3. mssql/oracle 源端索引内省未实现(作为源库时二级索引会丢)
|
||||
4. 逐表串行搬运(无并发),超大库耗时线性增长
|
||||
5. `-drop-target-first` 清空目标库**全部表**(含非本项目表),慎用
|
||||
6. 逐表行数校验不一致 → 整体失败退出码 1
|
||||
:::
|
||||
|
||||
## 与五方言定稿脚本的关系
|
||||
|
||||
```text
|
||||
document/{mysql,postgresql,sqlserver,oracle,sqlite}/easygoadmin.goframe.elevue.sql
|
||||
= "定稿 DDL"(mysql 为规范基准,人工导入维护)
|
||||
|
||||
迁移器 = "运行期动态等价物"(自动跟随源库结构)
|
||||
|
||||
两者独立演进:新表/新索引需两边同步(DDL 脚本手工维护)
|
||||
```
|
||||
|
||||
## 实测记录
|
||||
|
||||
仓库保留实测日志(可参考排障):
|
||||
|
||||
```text
|
||||
document/数据迁移PostgreSQL版本日志.txt
|
||||
document/数据迁移SqlServer版本日志.txt
|
||||
document/数据迁移Sqlite版本日志.txt
|
||||
document/数据迁移命令.txt(完整参数说明与示例)
|
||||
```
|
||||
|
||||
首期实测链路 mysql→pgsql;mssql/oracle 按定稿方言约定实现,待人工校对。
|
||||
|
||||
## 使用建议
|
||||
|
||||
```text
|
||||
1. 迁移前备份源库与目标库
|
||||
2. 首次迁移用 -tables 白名单先迁小表验证连通与方言转换
|
||||
3. 全量迁移加 -drop-target-first(确认目标库无他用!)
|
||||
4. 观察每表行数校验 PASS/FAIL;FAIL 表排查后 -tables 单独重迁
|
||||
5. 迁移后核对索引(uk_* 命名已统一)与注释(非 MySQL 目标语义可能降级)
|
||||
6. 应用侧切库:改 config.yaml database.default.type/link 重启
|
||||
```
|
||||
@@ -0,0 +1,106 @@
|
||||
# 7.4 数据导入导出
|
||||
|
||||
:::tip 概述
|
||||
Excel 导入导出基于 xuri/excelize/v2(utility/excel 封装):导入解析逐行校验写入并返回统计,导出走 ChunkQuery 分批查询防 OOM。启用模块:user / level / example。
|
||||
:::
|
||||
|
||||
## utility/excel API
|
||||
|
||||
```text
|
||||
ReadSheet(file FileOpener):首行表头,全单元格转字符串(TrimSpace),空行跳过;
|
||||
FileOpener 接口兼容 *ghttp.UploadFile 与 *multipart.FileHeader
|
||||
|
||||
WriteSheet(dir, baseName, headers, rows [][]any):写文件返回 (fileName, filePath)
|
||||
```
|
||||
|
||||
## 导入流程(Import,真实模式摘自 example)
|
||||
|
||||
```go
|
||||
// Import 导入案例演示
|
||||
// 表头为表单字段名,按列序写入
|
||||
func (s *sExample) Import(ctx context.Context, req *v1.ExampleImportReq) (res *v1.ExampleImportRes, err error) {
|
||||
_, rows, err := excel.ReadSheet(req.File)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res = &v1.ExampleImportRes{Total: len(rows)}
|
||||
for i, row := range rows {
|
||||
if cellAt(row, 0) == "" {
|
||||
res.Failed++
|
||||
res.Reasons = append(res.Reasons, fmt.Sprintf("第%d行:案例名称为空", i+2))
|
||||
continue
|
||||
}
|
||||
if _, e := dao.Example.Ctx(ctx).Data(map[string]any{
|
||||
"name": cellAt(row, 0),
|
||||
"avatar": cellAt(row, 1),
|
||||
"type": cellInt(row, 2, 0),
|
||||
"status": cellInt(row, 3, 0),
|
||||
"sort": cellInt(row, 4, 0),
|
||||
}).Insert(); e != nil {
|
||||
res.Failed++
|
||||
res.Reasons = append(res.Reasons, fmt.Sprintf("第%d行:写入失败 %v", i+2, e))
|
||||
continue
|
||||
}
|
||||
res.Success++
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
```
|
||||
|
||||
要点:逐行独立成败(一行失败不影响后续);返回 Total/Success/Failed/Reasons(逐行原因);user 导入还含唯一校验/租户上限/默认密码 bcrypt 预计算/角色与城市解析(ResolveCityCodes)。
|
||||
|
||||
## 导出流程(Export,真实模式摘自 example)
|
||||
|
||||
```go
|
||||
// Export 导出案例演示
|
||||
// 与列表同套筛选条件,枚举列输出字典文本
|
||||
func (s *sExample) Export(ctx context.Context, req *v1.ExampleExportReq) (res *v1.ExampleExportRes, err error) {
|
||||
rows, err := exampleSpec.applyFilters(ctx, exampleSpec.Model(ctx), req).Order("sort, id").All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headers := []string{"案例名称", "案例图片", "案例类型", "案例状态", "案例排序"}
|
||||
data := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
data = append(data, []any{r["name"].String(), r["avatar"].String(), DictGetText(ctx, "example_type", r["type"]), DictGetText(ctx, "example_status", r["status"]), r["sort"].String()})
|
||||
}
|
||||
fileName, _, err := excel.WriteSheet(upload.BaseDir(ctx), "案例演示数据", headers, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := upload.GetFileUrl(ctx, fileName)
|
||||
return &url, nil
|
||||
}
|
||||
```
|
||||
|
||||
要点:**导出统一走 `xxxSpec.applyFilters` 与列表同套筛选(导出=所见)**;枚举列 DictGetText 转文本;大结果集用 ChunkQuery 分批(user 导出 500/页)。
|
||||
|
||||
## 导入导出规范
|
||||
|
||||
| 项 | 规范 |
|
||||
|----|------|
|
||||
| 接口 | Import POST multipart(`p:"file"`);Export GET + 筛选参数 |
|
||||
| 杅限 | sys:{module}:import / export |
|
||||
| 日志 | logType import / export("模块名-导入数据/导出数据") |
|
||||
| 模板 | 表头=表单字段名(导出文件即导入模板) |
|
||||
| 文件位置 | 导出写临时文件返回 URL(/upload/temp/...,受 temp 清理回收) |
|
||||
| 防 OOM | 大结果集 ChunkQuery 分批(默认 500/页) |
|
||||
|
||||
## 前端组件
|
||||
|
||||
```text
|
||||
Excel 组件(components/Excel + importFile):
|
||||
导入:选文件 → POST /api/{module}/import → 展示统计与失败原因
|
||||
导出:调 /api/{module}/export → 拿 URL → downloadFile 下载
|
||||
详见 9.11 Excel 导入导出组件
|
||||
```
|
||||
|
||||
## 开发新模块的导入导出
|
||||
|
||||
```text
|
||||
1. 请求结构:ExampleImportReq(gmeta + File 字段)/ ExampleExportReq(gmeta + 筛选字段)
|
||||
2. 接口声明:service 接口加 Import/Export 方法
|
||||
3. logic 实现:按上方真实模式(ReadSheet 逐行 / applyFilters + WriteSheet)
|
||||
4. 权限节点:import/export(生成器 HasImport/HasExport 默认 true 自动建)
|
||||
5. 前端:导入按钮 + 导出按钮(模板参照 example 页面)
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# 7.5 前端文件组件
|
||||
|
||||
:::tip 概述
|
||||
前端文件能力集中在 components/Upload(上传组件族)与 utils/downloadFile(下载工具),配合两段式上传协议:选择文件即调 /api/upload/uploadFile 拿 temp URL,表单提交随业务接口转正。
|
||||
:::
|
||||
|
||||
## 组件清单(ui/src/components/Upload 等)
|
||||
|
||||
| 组件/工具 | 职责 |
|
||||
|-----------|------|
|
||||
| Upload | 上传组件族(单图/多图/普通文件形态) |
|
||||
| Cropper / vue-cropper | 图片裁剪(头像上传场景:裁剪后上传) |
|
||||
| importFile | 导入文件组件(Excel 导入入口,配合 Excel 组件) |
|
||||
| utils/downloadFile | 文件下载工具(导出 URL 下载) |
|
||||
| Editor(TinyMCE/Quill) | 富文本编辑器(内嵌图片上传协作,见 7.3) |
|
||||
|
||||
## 上传组件与后端协议的协作
|
||||
|
||||
```text
|
||||
1. 用户选择文件 → 前端 POST /api/upload/uploadFile(multipart,字段 file)
|
||||
可带 type=image 走图片规则(魔数嗅探)
|
||||
2. 后端 SaveTemp 落临时目录 → 返回 {originalName, fileExtension, fileType, fileSize, fileName, filePath, fileUrl}
|
||||
3. 组件回显 fileUrl(预览)、originalName(文件名);表单数据持有 fileUrl
|
||||
4. 表单提交(exampleAdd/articleAdd 等)→ 后端 spec.saveTransform
|
||||
→ SaveFromTemp 把 temp URL 转正 → 库存相对路径
|
||||
5. 详情回显 → 基座 GetFileUrl 补域名 → 组件直接展示
|
||||
```
|
||||
|
||||
关键认知:**前端只持有 URL(temp 或正式),永远不关心物理路径**;转正发生在业务表单保存时,不在上传时。
|
||||
|
||||
## 头像上传(Cropper 场景)
|
||||
|
||||
```text
|
||||
选择图片 → Cropper 裁剪 → 裁剪结果上传 /api/upload/uploadFile(type=image)
|
||||
→ 得到 temp URL → 头像组件预览
|
||||
→ 保存资料(index.updateProfile)→ 后端 SaveFileField(avatar, "user") 转正
|
||||
```
|
||||
|
||||
## 多图字段(article.images)
|
||||
|
||||
```text
|
||||
多图上传 → 每张走 uploadFile → 组件持有 URL 数组 → 表单逗号拼接
|
||||
→ 后端 MultiFileFields 声明 → SaveFromTempMulti 逐个转正
|
||||
→ 库存逗号分隔相对路径
|
||||
```
|
||||
|
||||
## 下载工具(utils/downloadFile)
|
||||
|
||||
```text
|
||||
导出接口返回文件 URL → downloadFile 触发浏览器下载
|
||||
(导出文件在 /upload/temp/...,受 tempExpireDays 清理——及时下载)
|
||||
```
|
||||
|
||||
## 开发对接要点
|
||||
|
||||
| 场景 | 要点 |
|
||||
|------|------|
|
||||
| 新页面接上传 | 用 Upload 组件 + 表单字段持 URL;后端 spec 声明 FileFields + FileSaveDir |
|
||||
| 图片类字段 | 上传带 type=image(后端魔数嗅探);后端注释含"图片"(生成器识别为图片列) |
|
||||
| 多图 | MultiFileFields + 逗号分隔约定 |
|
||||
| 富文本内图片 | 走 Editor 的上传通道(temp URL 入正文),保存时 Localize 统一转正 |
|
||||
| 大小/类型拦截 | 前端可预校验(体验优化);**真值以后端白名单与 maxSize 为准** |
|
||||
|
||||
## 排障
|
||||
|
||||
| 症状 | 原因 |
|
||||
|------|------|
|
||||
| 上传成功但保存后 404 | 转正失败或 domainUrl 配置错;查库中相对路径与 GetFileUrl |
|
||||
| 上传被拒 | 扩展名/大小/嗅探——对照 file.* 配置(7.2 上传配置详解) |
|
||||
| 预览正常发布后失效 | temp 已被 CleanTemp 清理——表单保存(转正)没发生或失败 |
|
||||
| 多图只保存最后一张 | 前端未逗号拼接或后端未声明 MultiFileFields |
|
||||
@@ -0,0 +1,81 @@
|
||||
# 7.3 富文本处理
|
||||
|
||||
:::tip 概述
|
||||
富文本的核心是 [IMG_URL] 持久化契约:存库时本站图片引用转为占位符(LocalizeContentFields),读取时还原为访问基址(ExpandContentFields,唯一还原出口)。content 字段的库内格式是系统私有契约。
|
||||
:::
|
||||
|
||||
## 转换流图
|
||||
|
||||
```text
|
||||
写侧 LocalizeContentFields(content, saveDir):
|
||||
正文 HTML
|
||||
│ 正则提取 img/video/source/embed[src] + a[href(带后缀)]
|
||||
│ 逐 URL 判断:
|
||||
│ 含 /temp/ → SaveFromTemp 转正 → "[IMG_URL]/user/.../x.png"
|
||||
│ 本站正式路径 → 剥离 domainUrl → "[IMG_URL]/user/.../x.png"
|
||||
│ 外站 http(s) → 跳过(不处理,规避 SSRF)
|
||||
▼
|
||||
库存 content(含 [IMG_URL] 占位符;此为持久化格式契约)
|
||||
|
||||
读侧 ExpandContentFields(content):
|
||||
"[IMG_URL]" ──ReplaceAll──▶ 访问基址(GetAccessBase)──▶ 前端富文本展示
|
||||
唯一还原出口;ModuleSpec.ContentFields 声明后两侧自动接入
|
||||
```
|
||||
|
||||
## 契约要点
|
||||
|
||||
::: warning [IMG_URL] 是持久化契约
|
||||
1. `[IMG_URL]` 是库内 content 字段格式的一部分——**content 不供第三方系统直接消费**
|
||||
2. 还原只能走 `ExpandContentFields`——业务代码禁止手写字符串替换
|
||||
3. 读取必须走 Detail/List(基座 ContentFields 自动 Expand)——直接 Struct 输出会漏还原
|
||||
4. 外站 URL 不本地化不替换(设计边界:防 SSRF 与外链下载)
|
||||
:::
|
||||
|
||||
## 声明式接入(ModuleSpec)
|
||||
|
||||
```go
|
||||
ContentFields: []string{"content"}, // 读写两侧自动接入
|
||||
FileSaveDir: "article", // 内嵌 temp 图片的转正目录
|
||||
```
|
||||
|
||||
接入后基座自动完成:
|
||||
|
||||
```text
|
||||
写侧(Add/Update → saveTransform):
|
||||
LocalizeContentFields 处理 content(temp 图片转正 + 占位符化)
|
||||
读侧(Page/Detail → readTransform):
|
||||
ExpandContentFields 还原占位符为访问基址
|
||||
```
|
||||
|
||||
article.content 是典型接入字段;未接入 ContentFields 的模块需手动调 base.go 两个函数(不推荐)。
|
||||
|
||||
## 匹配范围(写侧正则)
|
||||
|
||||
```text
|
||||
img[src] / video[src] / source[src] / embed[src]
|
||||
a[href](仅带文件后缀的链接——普通网页链接不处理)
|
||||
```
|
||||
|
||||
普通 `<a href="https://example.com">` 外链保持原样(是超链接不是资源引用)。
|
||||
|
||||
## 前端协作
|
||||
|
||||
```text
|
||||
编辑器(TinyMCE/Quill):
|
||||
上传图片走 /api/upload/uploadFile → 得到 temp URL → 插入编辑器
|
||||
编辑器内容即正文 HTML(含 temp URL)
|
||||
|
||||
保存:正文提交 → 后端 LocalizeContentFields 转正+占位符化 → 入库
|
||||
回显:Detail 返回已还原的完整 URL → 编辑器直接渲染
|
||||
再次编辑:取 Detail 内容 → 编辑 → 保存(循环;已正式的图片引用保持稳定)
|
||||
```
|
||||
|
||||
## 排障
|
||||
|
||||
| 症状 | 原因 |
|
||||
|------|------|
|
||||
| 富文本图片不显示 | 读取绕过了基座(直接 Struct);或 domainUrl 配置错(还原基址不对) |
|
||||
| 库里看到 [IMG_URL] | 正常——库内契约格式;接口输出应已还原 |
|
||||
| 外站图片 404 | 外站 URL 不本地化是设计边界;如需本地化要另行开发(注意 SSRF 防护) |
|
||||
| 图片重复转正 | 正式路径引用幂等(归一化原样返回),不会重复移动;temp 引用每次编辑会重新转正(保存前应已在阶段一转正) |
|
||||
| 第三方系统读 content 乱码 | content 是私有契约——第三方消费应走 Detail 接口(已还原)而非直读库 |
|
||||
@@ -0,0 +1,85 @@
|
||||
# 7.2 上传配置详解
|
||||
|
||||
:::tip 概述
|
||||
上传行为全部由 config.yaml 的 file.* 配置块控制:物理目录、访问域名、扩展名白名单、大小上限、临时保留天数。本节逐键说明与调优建议。
|
||||
:::
|
||||
|
||||
## 配置全貌
|
||||
|
||||
```yaml
|
||||
file:
|
||||
uploadDir: "upload" # 物理根目录(相对项目根或绝对路径)
|
||||
domainUrl: "" # 文件访问域名,为空时使用 /upload/ 相对路径
|
||||
tempDir: "temp" # 临时目录名,位于 uploadDir 下
|
||||
imageExt: "gif,jpg,jpeg,png,bmp,webp,svg" # 允许的图片扩展名
|
||||
fileExt: "gif,jpg,jpeg,png,bmp,webp,svg,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv,mp3,mp4,sql,js,css" # 允许的文件扩展名
|
||||
maxSize: 10 # 单文件大小上限(MB)
|
||||
tempExpireDays: 7 # 临时目录保留天数,超期自动清理,0 表示不清理
|
||||
```
|
||||
|
||||
## 逐键说明
|
||||
|
||||
| 键 | 作用 | 调优建议 |
|
||||
|----|------|---------|
|
||||
| uploadDir | 上传物理根目录 | 容器部署挂独立卷(跨卷 Rename 回退复制);权限最小化 |
|
||||
| domainUrl | 文件访问域名 | 空 = `/upload/` 相对路径(同域反代场景);CDN/独立文件域名时配置完整 origin;SaveFromTemp 按此前缀识别"本站 URL" |
|
||||
| tempDir | 临时目录名 | 保持默认;位于 uploadDir 下,CleanTemp 按它定位 |
|
||||
| imageExt | 图片白名单 | 按需增减;svg 在白名单时由 Response 中间件附件化兜底 |
|
||||
| fileExt | 普通文件白名单 | **对公网用户开放上传时建议收紧**——当前含 js/css/sql,公开场景应移除 |
|
||||
| maxSize | 单文件上限 MB | 表单上传场景 10 足够;大文件场景配合同步调 Nginx client_max_body_size |
|
||||
| tempExpireDays | temp 保留天数 | 0=不清理(不建议);默认 7 天覆盖正常"上传→提交表单"周期 |
|
||||
|
||||
## domainUrl 的三种部署形态
|
||||
|
||||
```text
|
||||
形态一:同域反代(推荐)
|
||||
domainUrl: ""(空)
|
||||
前端经 Nginx 同域访问 /upload/**,SaveFromTemp 以 /temp/ 前缀识别
|
||||
|
||||
形态二:独立文件域名/CDN
|
||||
domainUrl: "https://files.example.com"
|
||||
上传返回 https://files.example.com/temp/...;
|
||||
SaveFromTemp 匹配此前缀剥离域名后转正
|
||||
|
||||
形态三:后端直出
|
||||
domainUrl: "http://backend:8000"
|
||||
静态访问由 SetServerRoot("resource") 提供
|
||||
```
|
||||
|
||||
::: warning domainUrl 一致性
|
||||
domainUrl 决定"本站 URL"的判定——库里存相对路径、展示层拼域名。改 domainUrl 后旧数据无需迁移(相对路径不变),但**外链判定/转正识别**以新前缀为准。
|
||||
:::
|
||||
|
||||
## 安全链与配置的关系
|
||||
|
||||
```text
|
||||
大小限制 ← maxSize
|
||||
扩展名白名单 ← fileExt / imageExt
|
||||
图片魔数嗅探 ← type=image 时强制(配置不可关)
|
||||
随机文件名 ← 固定行为(32hex / HHmmss_4hex,防猜路径/覆盖)
|
||||
两段式 ← 固定行为(未过校验不进正式目录)
|
||||
svg 加固 ← Response 中间件固定行为(attachment+nosniff)
|
||||
temp 清理 ← tempExpireDays + 每日 03:30 gcron
|
||||
```
|
||||
|
||||
## 生产加固建议
|
||||
|
||||
```text
|
||||
1. fileExt 按业务收紧:纯图片业务 fileExt=imageExt 即可
|
||||
2. maxSize 与 Nginx client_max_body_size 对齐(防绕过)
|
||||
3. uploadDir 挂独立卷并限制执行权限(防上传 js 被解析执行——
|
||||
静态服务只应返回文件内容)
|
||||
4. domainUrl 走 CDN 时配置防盗链(CDN 侧)
|
||||
5. tempExpireDays 保持 ≤7(缩短未转正文件暴露窗口)
|
||||
```
|
||||
|
||||
## 验证配置生效
|
||||
|
||||
```sh
|
||||
# 上传超限 → 拒绝并提示大小
|
||||
# 上传白名单外扩展名 → 拒绝
|
||||
# 上传后检查:
|
||||
ls upload/temp/$(date +%Y%m%d)/ # 阶段一落盘
|
||||
ls upload/user/$(date +%Y%m%d)/ # 表单保存后转正
|
||||
# temp 清理验证:改小 tempExpireDays 观察 03:30 清理日志
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
# 7.1 文件上传全流程
|
||||
|
||||
:::tip 概述
|
||||
上传采用两段式:SaveTemp 先入临时目录(白名单+大小+图片嗅探校验),表单保存时 SaveFromTemp 移动到正式子目录。未走第二段的 temp 文件由每日 03:30 清理任务过期回收,不污染正式目录。
|
||||
:::
|
||||
|
||||
## 上传接口
|
||||
|
||||
```text
|
||||
POST /api/upload/uploadFile (sys:upload:file)multipart 字段名 file
|
||||
POST /api/upload/uploadFiles (sys:upload:files)多文件
|
||||
入参 type=image 时按图片规则校验,否则按普通文件
|
||||
```
|
||||
|
||||
multipart 字段约定:`*ghttp.UploadFile` + `p:"file"` 标签。
|
||||
|
||||
## 两段式流程图
|
||||
|
||||
```text
|
||||
阶段一(上传时,POST /api/upload/uploadFile):
|
||||
浏览器 ──multipart──▶ SaveTemp
|
||||
│ ① 大小 ≤ file.maxSize(MB)
|
||||
│ ② 扩展名 ∈ file.fileExt / file.imageExt
|
||||
│ ③ type=image:前512B 魔数嗅探须 image/*
|
||||
▼
|
||||
落盘 {uploadDir}/temp/{yyyymmdd}/{32位hex}.{ext}
|
||||
▼
|
||||
返回 {url:"{domainUrl}/temp/...", path:"temp/.../x.png"(入库形态)} → 前端预览
|
||||
|
||||
阶段二(表单保存时,spec.saveTransform / SaveFileField):
|
||||
表单携带 temp URL ──▶ SaveFromTemp(url, saveDir="user")
|
||||
│ 剥离 domainUrl → 相对路径
|
||||
│ 以 temp/ 开头?──否──▶ 已正式/外站:归一化或原样返回
|
||||
│ 是
|
||||
▼
|
||||
{uploadDir}/{saveDir}/{yyyymmdd}/{HHmmss}_{4字节hex}.{ext}(Rename,跨卷复制回退)
|
||||
▼
|
||||
库中写入相对路径 "user/20261017/153012_ab12.png"
|
||||
▼
|
||||
读侧 GetFileUrl:domainUrl + "/" + 相对路径 ──▶ 前端展示
|
||||
```
|
||||
|
||||
## SaveTemp 校验链(第一段)
|
||||
|
||||
```text
|
||||
1) 大小 ≤ file.maxSize(MB,默认 10)
|
||||
2) 扩展名白名单:file.fileExt(普通)/ file.imageExt(图片)
|
||||
3) 读取前 512 字节:type=image 时做魔数嗅探(http.DetectContentType 须 image/*),并据文件头嗅探 MIME
|
||||
落盘:{file.uploadDir}/{file.tempDir}/{yyyymmdd}/{32位随机hex}.{ext}
|
||||
返回 Result{Url(可预览完整地址), Path(相对 uploadDir,入库形态), Name, Size, Ext, Mime}
|
||||
```
|
||||
|
||||
## SaveFromTemp 处理规则(第二段)
|
||||
|
||||
| 入参形态 | 处理 |
|
||||
|---------|------|
|
||||
| 空值 | 返回 "" |
|
||||
| 本站 URL(匹配 file.domainUrl 前缀) | 剥离域名得相对路径 |
|
||||
| 相对路径以 temp/ 开头 | 移动到 {saveDir}/{yyyymmdd}/{HHmmss}_{4字节hex}.{ext}(os.Rename,跨卷回退"复制后删源");返回新相对路径(入库形态) |
|
||||
| 已是正式相对路径 | 归一化后原样返回(幂等) |
|
||||
| 外站 http(s) URL | 原样返回(不处理外链) |
|
||||
|
||||
SaveFromTempMulti:按逗号拆分逐个转正(如 article.images 多图字段)。
|
||||
|
||||
## 路径契约
|
||||
|
||||
```text
|
||||
数据库统一存"相对 uploadDir 的路径"(如 tenant/20260923/x.png)
|
||||
对外访问地址 = GetFileUrl = file.domainUrl + "/" + 相对路径
|
||||
(domainUrl 为空时用 /upload/ 相对路径)
|
||||
静态访问:router SetServerRoot("resource") 提供 /upload/**
|
||||
Response 中间件对 .svg 强制 attachment + nosniff(防存储型 XSS)
|
||||
```
|
||||
|
||||
## 声明式用法(ModuleSpec)
|
||||
|
||||
```go
|
||||
FileFields: []string{"avatar"} + FileSaveDir: "user"
|
||||
// 写侧 saveTransform 自动 temp→正式;读侧 readTransform 自动 GetFileUrl 补域名
|
||||
// MultiFileFields 处理逗号多文件;ContentFields 走富文本流程
|
||||
```
|
||||
|
||||
模块已接入:user(avatar)、article(cover/images/content)、tenant(logo)、example(avatar) 等。
|
||||
|
||||
## 临时目录清理(运营闭环)
|
||||
|
||||
```text
|
||||
cmd.go 注册 gcron "0 30 3 * * *"(每日 03:30)→ logic.CleanTemp:
|
||||
扫描 {uploadDir}/{tempDir} 下目录名为 yyyymmdd 且早于
|
||||
now-file.tempExpireDays(默认 7 天,0=不清理)的日期目录整目录删除
|
||||
返回删除数量并记日志
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 症状 | 排查 |
|
||||
|------|------|
|
||||
| 上传图片 404 | file.domainUrl 与静态服务根的相对关系;库中存相对路径,展示层必须经 GetFileUrl |
|
||||
| 上传被拒 | 扩展名不在白名单 / 超 maxSize / 图片魔数嗅探不过(改后缀伪造) |
|
||||
| temp 文件堆积 | CleanTemp 的 gcron 已注册?file.tempExpireDays > 0? |
|
||||
| 跨卷移动失败 | os.Rename 失败自动回退"复制后删源";检查磁盘空间与权限 |
|
||||
| 富文本图片不显示 | 读取侧走 Detail/List(基座自动 Expand),不要直接 Struct 输出 |
|
||||
Reference in New Issue
Block a user