Files
EasyGoAdmin_GoFrame_EleVue/document/wiki/05_共享能力层与文件富文本.txt
T
2026-10-20 11:37:25 +08:00

199 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
================================================================================
EasyGoAdmin 后端开发者文档 · 05 共享能力层与文件/富文本处理
================================================================================
代码位置:internal/logic/base.go(共享能力层)、utility/upload、
internal/logic/upload.go(上传接口)、manifest/config 的 file.* 配置
--------------------------------------------------------------------------------
1. base.go 共享能力层总览(横切能力,组合式复用)
--------------------------------------------------------------------------------
架构纪律(文件头约束):
1) 按能力分区:文件字段 / 富文本 / 枚举文本 / 唯一校验 / 事务 / 分块查询 / 树
2) 依赖方向单向:只允许依赖 dao / utility / consts,禁止引用模块文件
3) 导出函数上限 15 个,超出按能力拆 internal/logic/base/ 子包
4) CheckUnique 仅为 UX 快速报错(TOCTOU),并发唯一性靠 uk_* 索引
5) [IMG_URL] 占位符是持久化契约,还原只能走 ExpandContentFields
能力清单:
SaveFileFields(ctx, data, fields, saveDir) 单文件字段批量转正(原地更新 map)
SaveMultiFileFields(ctx, data, fields, saveDir) 逗号多文件字段批量转正
SaveFileField(ctx, fileUrl, saveDir) 单值便捷版
SaveMultiFileField(ctx, fileUrls, saveDir) 逗号串便捷版
LocalizeContentFields(ctx, content, saveDir) 富文本存库本地化(占位符化)
ExpandContentFields(ctx, content) 富文本读取还原(唯一还原出口)
DictGetText(ctx, code, value) 字典值 → 显示名(进程内缓存 300s)
SerializeMapOnRead(ctx, data, fieldDict) map 行补 {field}Text
LogTypeText(v int) 操作日志类型 → 中文(静态表)
CheckUnique(ctx, m, column, value, excludeId, label) 唯一性快速校验
WithTrans(ctx, fn) 事务包裹(错误自动回滚)
ChunkQuery(ctx, build, size, fn) 分块遍历(导出防 OOM)
BuildTree[T](rows, idOf, pidOf, childrenOf, less) 泛型树构建
CleanTemp(ctx, expireDays) 清理上传临时日期目录
fillUserCount(column, list) 按外键统计引用数(内部,level/position 等复用)
注:ModuleSpec 基座(module_spec.go)在其上封装了 readTransform/saveTransform/
checkUniqueRules/FillTextFields,模块优先用基座声明,逃生舱才直接调 base 函数。
--------------------------------------------------------------------------------
2. 文件上传两段式(utility/upload + logic/upload.go)
--------------------------------------------------------------------------------
2.1 上传接口
POST /api/upload/uploadFile (perm sys:upload:file)multipart 字段名 file
POST /api/upload/uploadFiles (perm sys:upload:files)多文件
入参 type=image 时按图片规则校验,否则按普通文件。
2.2 SaveTemp(第一段:落临时目录)
校验顺序:
1) 大小 ≤ file.maxSize(MB,默认 10)
2) 扩展名白名单:file.fileExt(普通)/ file.imageExt(图片)
3) 读取前 512 字节:type=image 时做魔数嗅探(http.DetectContentType 须 image/*),
并据文件头嗅探 MIME(识别不出回退 application/octet-stream)
落盘:{file.uploadDir}/{file.tempDir}/{yyyymmdd}/{32位随机hex}.{ext}
返回 Result{Url(可直接预览的完整地址), Path(相对 uploadDir,入库形态),
Name, Size, Ext, Mime}。
2.3 SaveFromTemp(第二段:表单保存时转正)
入参为上传返回的 URL 或相对路径;处理规则:
* 空值 → ""
* 本站 URL(匹配 file.domainUrl 前缀)→ 剥离域名得相对路径
* 相对路径以 temp/ 开头 → 移动到 {saveDir}/{yyyymmdd}/{HHmmss}_{4字节hex}.{ext}
(os.Rename,跨卷回退“复制后删源”);返回新相对路径(入库形态)
* 已是正式相对路径 → 归一化后原样返回(幂等)
* 外站 http(s) URL → 原样返回(不处理外链)
SaveFromTempMulti:按逗号拆分逐个转正(如 article.images 多图字段)。
2.4 路径契约
数据库统一存“相对 uploadDir 的路径”(如 tenant/20260923/x.png);
对外访问地址由 GetFileUrl = file.domainUrl + "/" + 相对路径 生成
(domainUrl 为空时用 /upload/ 相对路径)。
静态访问:router SetServerRoot("resource") 提供 /upload/**;
Response 中间件对 .svg 强制 attachment + nosniff(防存储型 XSS)。
2.5 ModuleSpec 中的声明式用法
FileFields: []string{"avatar"} + FileSaveDir: "user"
写侧 saveTransform 自动 temp→正式;读侧 readTransform 自动 GetFileUrl 补域名。
MultiFileFields 处理逗号多文件;ContentFields 走富文本流程。
2.6 临时目录清理(运营闭环)
cmd.go 注册 gcron "0 30 3 * * *"(每日 03:30)→ logic.CleanTemp:
扫描 {uploadDir}/{tempDir} 下目录名为 yyyymmdd 且早于
now-file.tempExpireDays(默认 7 天,0=不清理)的日期目录整目录删除,
返回删除数量并记日志。
2.7 上传两段式流程图
阶段一(上传时,POST /api/upload/uploadFile):
浏览器 ──multipart──▶ SaveTemp
│ ① 大小 ≤ file.maxSize(MB)
│ ② 扩展名 ∈ file.fileExt / file.imageExt
│ ③ type=image:前512B 魔数嗅探须 image/*
▼
落盘 {uploadDir}/temp/{yyyymmdd}/{32位hex}.{ext}
▼
返回 {originalName, fileExtension, fileType, fileSize, fileName,
filePath:"temp/.../x.png"(入库形态), fileUrl:"{domainUrl}/temp/..."} → 前端预览
阶段二(表单保存时,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 + "/" + 相对路径 ──▶ 前端展示
生命周期:temp 文件由 CleanTemp(每日 03:30)按 tempExpireDays 清理;
未走阶段二的 temp 文件自然过期,不污染正式目录。
--------------------------------------------------------------------------------
3. 富文本 [IMG_URL] 持久化契约
--------------------------------------------------------------------------------
3.1 存库(LocalizeContentFields)
正文(如 article.content)入库前处理:
* 匹配范围:img/video/source/embed 的 src + a 的 href(带文件后缀)
* 本站 /temp/ 引用 → SaveFromTemp 转正后替换为 "[IMG_URL]/{相对路径}"
* 本站正式 /upload/ 引用 → 剥离访问基址(GetAccessBase)写 "[IMG_URL]{相对路径}"
* 外站 URL → 不处理(规避 SSRF/外链下载)
[IMG_URL] 是库内 content 字段格式的一部分:content 不供第三方系统直接消费。
3.2 读取(ExpandContentFields)
strings.ReplaceAll(content, "[IMG_URL]", GetAccessBase(ctx)),
是唯一还原出口,业务代码禁止手写字符串替换。
ModuleSpec 的 ContentFields 声明后读写两侧自动接入。
3.3 富文本转换流图
写侧 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──▶ domainUrl(访问基址)──▶ 前端富文本展示
唯一还原出口;ModuleSpec.ContentFields 声明后两侧自动接入。
--------------------------------------------------------------------------------
4. 事务(WithTrans)
--------------------------------------------------------------------------------
WithTrans(ctx, fn):g.DB().Begin → fn(gdb.WithTX(ctx, tx)) → 出错 Rollback,
否则 Commit。dao.Ctx(ctx) 在事务上下文内自动走事务连接。
纪律:事务内禁止 HTTP/文件 IO;bcrypt 等 CPU 密集操作在事务外预计算
(典型:租户开户 Account、用户 Add)。
--------------------------------------------------------------------------------
5. 分块查询(ChunkQuery)
--------------------------------------------------------------------------------
ChunkQuery(ctx, build, size, fn):build(page, size) 每页重建查询(gdb Model
不可复用执行),默认页大小 500;语义为快照——并发写入可能造成翻页漂移。
典型用途:用户导出(logic/user.go Export)分批读取 → 写 Excel,防大结果集 OOM。
--------------------------------------------------------------------------------
6. 树构建(BuildTree / ModuleSpec 树形)
--------------------------------------------------------------------------------
base.BuildTree[T]:泛型平铺转树,父节点不在集合内则挂根,可传排序函数。
ModuleSpec 路线:IsTree:true + ParentOf +(ChildSetter 或 DTO 的 Children 字段),
List 末尾自动建树,叶子 Children 预置空数组。DTO 必须含 Id 字段。
使用模块:menu / dept / category。
--------------------------------------------------------------------------------
7. Excel 导入导出(utility/excel)
--------------------------------------------------------------------------------
基于 xuri/excelize/v2,支持 xls/xlsx。
ReadSheet(file FileOpener):首行表头,全单元格转字符串(TrimSpace),
空行跳过;FileOpener 接口兼容 *ghttp.UploadFile 与 *multipart.FileHeader。
WriteSheet(dir, baseName, headers, rows [][]any):写文件返回 (fileName, filePath)。
--------------------------------------------------------------------------------
8. 其他 utility 能力
--------------------------------------------------------------------------------
utility/cache 统一缓存门面:Init 探测 Redis(失败/运行期出错自动降级 gcache
进程内存,UsingMemory() 可查);Set/Get/Del/Exists/SetNX。
注意:内存降级下多实例不共享(限流计数、任务锁退化为单机语义)。
utility/crypto GenerateSalt(8 字节随机 base64url 截 10 位)、
HashPassword/VerifyPassword(bcrypt(password+salt))、IsBcrypt。
utility/ua ParseOS / ParseBrowser(操作日志、登录日志、在线用户用)。
utility/ip2region xdb 懒加载(sync.Once)+ 读锁并发;City(ip) 优先市回退省,
内网/回环返回空;Location(ip) 供日志(失败显示 "-")。
路径配置 ip2region.path(默认 manifest/data/ip2region.xdb)。
utility/captcha 见 03 章。
utility/response 见 01 章。
--------------------------------------------------------------------------------
9. 常见问题排查
--------------------------------------------------------------------------------
上传图片 404:检查 file.domainUrl 与静态服务根(SetServerRoot("resource"))的
相对关系;库中存的是相对 uploadDir 路径,展示层必须经 GetFileUrl。
富文本图片不显示:确认读取侧走 Detail/List(基座自动 Expand),不要直接
Struct 输出;外链未本地化属预期(不处理外站 URL)。
temp 文件堆积:确认 CleanTemp 的 gcron 已注册(cmd.go),file.tempExpireDays>0。
归属地显示 "-":检查 ip2region.xdb 是否存在、api.proxyIps 是否包含反代地址
(否则日志 IP 是反代 IP)。