整体平台上传功能大改造

This commit is contained in:
2026-09-09 10:49:57 +08:00
parent d2643a3697
commit de7225b61e
21 changed files with 3835 additions and 2622 deletions
+18 -1
View File
@@ -106,10 +106,27 @@ export function getFileById(id) {
*/ */
export function uploadFile(formData, options = {}) { export function uploadFile(formData, options = {}) {
// 0 表示「未分类」,不能用 truthy 判断 // 0 表示「未分类」,不能用 truthy 判断
if (options.cate !== undefined && options.cate !== null && options.cate !== "") { // 调用方若已手动 append 过 cate,这里不再重复追加(避免 multipart 出现两个 cate)
if (
options.cate !== undefined &&
options.cate !== null &&
options.cate !== "" &&
!formData.has("cate")
) {
formData.append("cate", String(options.cate)); formData.append("cate", String(options.cate));
} }
// scope: 'user' 表示上传到「我的个人文件」目录(backend/{tid}/{uid}/),
// 不传或 'tenant' 表示租户共享文件(backend/{tid}/)
if (options.scope && !formData.has("scope")) {
formData.append("scope", String(options.scope));
}
// tuid:显式指定归属用户(代他人上传场景),一般不需要传
if (options.tuid && !formData.has("tuid")) {
formData.append("tuid", String(options.tuid));
}
const config = { const config = {
url: "/backend/uploadfile", url: "/backend/uploadfile",
method: "post", method: "post",
@@ -151,7 +151,7 @@ const currentBanner = ref<Partial<Banner>>({
// 上传配置 // 上传配置
// @ts-ignore // @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles"); const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
const uploadHeaders = ref({ const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""), Authorization: "Bearer " + (localStorage.getItem("token") || ""),
}); });
@@ -117,7 +117,7 @@ const submitLoading = ref(false)
const formRef = ref(null) const formRef = ref(null)
// 上传配置 // 上传配置
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles"); const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
const uploadHeaders = ref({ const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""), Authorization: "Bearer " + (localStorage.getItem("token") || ""),
}); });
@@ -222,7 +222,7 @@ const currentMenu = ref<Partial<Menu>>({
// 上传配置 // 上传配置
// @ts-ignore // @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const uploadUrl = ref(API_BASE_URL + '/admin/uploadfiles'); const uploadUrl = ref(API_BASE_URL + '/backend/uploadfile');
const uploadHeaders = ref({ const uploadHeaders = ref({
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '') 'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
}); });
@@ -126,7 +126,7 @@ const submitLoading = ref(false);
const formRef = ref(null); const formRef = ref(null);
// 上传配置 // 上传配置
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles"); const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
const uploadHeaders = ref({ const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""), Authorization: "Bearer " + (localStorage.getItem("token") || ""),
}); });
@@ -127,7 +127,7 @@ const submitLoading = ref(false);
const formRef = ref(null); const formRef = ref(null);
// 上传配置 // 上传配置
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles"); const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
const uploadHeaders = ref({ const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""), Authorization: "Bearer " + (localStorage.getItem("token") || ""),
}); });
@@ -748,7 +748,8 @@ const uploadFileToSystem = async rawFile => {
const formData = new FormData() const formData = new FormData()
formData.append('file', rawFile) formData.append('file', rawFile)
formData.append('cate', 'reimbursement-invoice') formData.append('cate', 'reimbursement-invoice')
const uploadResponse = await uploadFile(formData) // 报销票据归属到当前用户个人目录:backend/{tid}/{uid}/年月日/
const uploadResponse = await uploadFile(formData, { scope: 'user' })
if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) { if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) {
throw new Error(uploadResponse?.msg || `「${rawFile.name}」上传失败`) throw new Error(uploadResponse?.msg || `「${rawFile.name}」上传失败`)
} }
@@ -300,7 +300,8 @@ function ensureImageName(file) {
async function uploadImage(file) { async function uploadImage(file) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const res = await uploadFile(formData); // 日程图片归属到当前用户个人目录:backend/{tid}/{uid}/年月日/
const res = await uploadFile(formData, { scope: "user" });
if (res?.code !== 200 && res?.code !== 201) { if (res?.code !== 200 && res?.code !== 201) {
throw new Error(res?.msg || `「${file.name}」上传失败`); throw new Error(res?.msg || `「${file.name}」上传失败`);
} }
@@ -24,7 +24,8 @@
<el-form-item label="资质图片" prop="file_url"> <el-form-item label="资质图片" prop="file_url">
<el-upload <el-upload
class="avatar-uploader" class="avatar-uploader"
action="/api/platform/common/upload" :action="uploadAction"
:headers="uploadHeaders"
:show-file-list="false" :show-file-list="false"
:on-success="handleUploadSuccess" :on-success="handleUploadSuccess"
:before-upload="beforeAvatarUpload" :before-upload="beforeAvatarUpload"
@@ -71,6 +72,12 @@
const tenantName = ref(''); const tenantName = ref('');
const formRef = ref(); const formRef = ref();
// 上传地址:走租户后台通用上传接口(原 /api/platform/common/upload 后端不存在,会 404)
const uploadAction = import.meta.env.VITE_API_BASE_URL + '/backend/uploadfile';
const uploadHeaders = {
Authorization: 'Bearer ' + (localStorage.getItem('token') || '')
};
const formData = reactive({ const formData = reactive({
tid: null, tid: null,
type: '', type: '',
+120
View File
@@ -0,0 +1,120 @@
// 存量文件迁移到分层目录(文件存储分层改造 S9)
//
// 用法(在 go/ 目录下执行):
//
// go run ./cmd/migrate_storage # 默认 dry-run,只打印计划,不改动任何数据
// go run ./cmd/migrate_storage --apply # 真正执行迁移
// go run ./cmd/migrate_storage --tid=234573 # 只迁移指定租户
// go run ./cmd/migrate_storage --limit=10 # 只处理前 N 条(调试用)
//
// 迁移原理:
// - 七牛云:BucketManager.Move 同 bucket 内服务端改名,不重新上传、不消耗流量、与文件大小无关
// - 本地 :os.Rename 同盘改名,不搬数据
//
// 幂等:目标 key 已存在时跳过(Skipped),可重复执行。
//
// 注意:老数据没有「个人/共享」归属信息(tuid 全为 NULL、uid 是上传者),
//
// 因此老数据统一按「租户共享」迁移,个人目录只对新上传的文件生效。
package main
import (
"flag"
"fmt"
"os"
"server/models"
"server/services"
)
func main() {
apply := flag.Bool("apply", false, "真正执行迁移;不传则只打印计划(dry-run)")
tid := flag.Uint64("tid", 0, "只迁移指定租户,0 表示全部")
limit := flag.Int("limit", 0, "只处理前 N 条,0 表示不限制")
flag.Parse()
// 注意:需在 go/ 目录下运行,beego 会自动加载 conf/app.conf
models.Init("")
if models.Orm == nil {
fmt.Println("数据库未初始化")
os.Exit(1)
}
models.EnsureSystemFileStorageColumns()
storageSvc, err := services.GetStorageService()
if err != nil {
fmt.Println("获取存储服务失败:", err)
os.Exit(1)
}
fmt.Printf("当前存储类型: %s\n", storageSvc.Type())
qs := models.Orm.QueryTable(new(models.SystemFile)).Filter("delete_time__isnull", true)
if *tid > 0 {
qs = qs.Filter("tid", *tid)
}
if *limit > 0 {
qs = qs.Limit(*limit)
}
var files []models.SystemFile
if _, err := qs.OrderBy("id").All(&files); err != nil {
fmt.Println("读取文件列表失败:", err)
os.Exit(1)
}
fmt.Printf("待处理文件数: %d(模式: %s)\n\n", len(files), modeName(*apply))
planned, skipped, failed := 0, 0, 0
for i := range files {
f := files[i]
oldKey := f.ObjectKey
if oldKey == "" {
oldKey = services.KeyFromSrc(f.Src, storageSvc)
}
if oldKey == "" {
fmt.Printf("[跳过] id=%d 无法解析原路径: %s\n", f.ID, f.Src)
skipped++
continue
}
newKey := services.TargetKey(&f)
if oldKey == newKey {
skipped++
continue
}
planned++
fmt.Printf("[%s] id=%d tid=%d\n 老: %s\n 新: %s\n", modeName(*apply), f.ID, f.Tid, oldKey, newKey)
if !*apply {
continue
}
if err := storageSvc.Move(oldKey, newKey); err != nil {
fmt.Printf(" !! 迁移失败: %v\n", err)
failed++
continue
}
newSrc := storageSvc.GetPublicURL(newKey)
if _, err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", f.ID).
Update(map[string]interface{}{
"src": newSrc,
"object_key": newKey,
"storage": storageSvc.Type(),
}); err != nil {
fmt.Printf(" !! 更新数据库失败(文件已移动,请重跑本脚本): %v\n", err)
failed++
}
}
fmt.Printf("\n==== 汇总 ====\n计划迁移: %d\n跳过: %d\n失败: %d\n", planned, skipped, failed)
if !*apply {
fmt.Println("当前为 dry-run,未改动任何数据。确认无误后加 --apply 执行。")
}
}
func modeName(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
+114 -51
View File
@@ -15,6 +15,7 @@ import (
"server/pkg/jwtutil" "server/pkg/jwtutil"
"server/services" "server/services"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web" beego "github.com/beego/beego/v2/server/web"
) )
@@ -78,6 +79,53 @@ func (c *BackendFileController) effectiveTid(claims *jwtutil.Claims) uint64 {
return 0 return 0
} }
// Prepare 所有接口执行前补齐文件存储分层所需字段,避免历史库缺列导致查询/写入报错
func (c *BackendFileController) Prepare() {
models.EnsureSystemFileStorageColumns()
}
// resolveTuid 解析归属用户 ID,返回 0 表示租户共享文件。
//
// 取值顺序:
// 1. 表单/请求头的 tuid(代他人上传场景,如 HR 代员工上传票据)
// 2. scope=user 时取当前登录的租户用户 UID(backend 端 JWT 的 UserID 即 yz_system_tenant_user.uid)
// —— 强制取自身 UID,避免前端伪造他人 uid 把文件塞进别人目录
// 3. 其余情况返回 0(租户共享)
func (c *BackendFileController) resolveTuid(claims *jwtutil.Claims) uint64 {
if v, err := c.GetUint64("tuid"); err == nil && v > 0 {
return v
}
if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-User-Id")); h != "" {
if v, e := strconv.ParseUint(h, 10, 64); e == nil && v > 0 {
return v
}
}
if strings.TrimSpace(c.GetString("scope")) == services.ScopeUser {
if claims != nil && claims.UserID > 0 {
return uint64(claims.UserID)
}
}
return 0
}
// currentTuid 当前登录者对应的租户用户 UID(backend 端 JWT 的 UserID 即 yz_system_tenant_user.uid)
func (c *BackendFileController) currentTuid(claims *jwtutil.Claims) uint64 {
if claims != nil && claims.UserID > 0 {
return uint64(claims.UserID)
}
return 0
}
// scopeFilter 按归属范围过滤:
// - scope=user → 只看当前登录用户的个人文件
// - 其它(默认)→ 只看租户共享文件
func scopeFilter(qs orm.QuerySeter, scope string, tuid uint64) orm.QuerySeter {
if scope == services.ScopeUser {
return qs.Filter("tuid", tuid)
}
return qs.Filter("tuid__isnull", true)
}
func (c *BackendFileController) jsonErr(httpStatus, bizCode int, msg string) { func (c *BackendFileController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus) c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
@@ -165,10 +213,13 @@ func (c *BackendFileController) GetAllFiles() {
} }
cate, _ := c.GetUint64("cate") cate, _ := c.GetUint64("cate")
keyword := strings.TrimSpace(c.GetString("keyword")) keyword := strings.TrimSpace(c.GetString("keyword"))
scope := strings.TrimSpace(c.GetString("scope"))
qs := models.Orm.QueryTable(new(models.SystemFile)). qs := models.Orm.QueryTable(new(models.SystemFile)).
Filter("source", services.SourceBackend).
Filter("tid", tid). Filter("tid", tid).
Filter("delete_time__isnull", true) Filter("delete_time__isnull", true)
qs = scopeFilter(qs, scope, c.currentTuid(claims))
if cate > 0 { if cate > 0 {
qs = qs.Filter("cate", cate) qs = qs.Filter("cate", cate)
} }
@@ -400,11 +451,14 @@ func (c *BackendFileController) GetCateFiles() {
pageSize = 24 pageSize = 24
} }
keyword := strings.TrimSpace(c.GetString("keyword")) keyword := strings.TrimSpace(c.GetString("keyword"))
scope := strings.TrimSpace(c.GetString("scope"))
qs := models.Orm.QueryTable(new(models.SystemFile)). qs := models.Orm.QueryTable(new(models.SystemFile)).
Filter("source", services.SourceBackend).
Filter("tid", tid). Filter("tid", tid).
Filter("cate", cateID). Filter("cate", cateID).
Filter("delete_time__isnull", true) Filter("delete_time__isnull", true)
qs = scopeFilter(qs, scope, c.currentTuid(claims))
if keyword != "" { if keyword != "" {
qs = qs.Filter("name__icontains", keyword) qs = qs.Filter("name__icontains", keyword)
} }
@@ -466,11 +520,14 @@ func (c *BackendFileController) UploadFile() {
c.jsonErr(401, 401, err.Error()) c.jsonErr(401, 401, err.Error())
return return
} }
tid := c.effectiveTid(claims)
if err := c.Ctx.Request.ParseMultipartForm(fileUploadMaxBytes); err != nil { if err := c.Ctx.Request.ParseMultipartForm(fileUploadMaxBytes); err != nil {
c.jsonErr(400, 400, "解析上传失败: "+err.Error()) c.jsonErr(400, 400, "解析上传失败: "+err.Error())
return return
} }
tid := c.effectiveTid(claims)
// 归属用户:传了 tuid 或 scope=user 即用户个人文件,否则为租户共享文件
tuid := c.resolveTuid(claims)
fh, header, err := c.GetFile("file") fh, header, err := c.GetFile("file")
if err != nil || fh == nil { if err != nil || fh == nil {
c.jsonErr(400, 400, "请选择要上传的文件") c.jsonErr(400, 400, "请选择要上传的文件")
@@ -483,7 +540,7 @@ func (c *BackendFileController) UploadFile() {
return return
} }
ext := fileExt(header.Filename) ext := services.FileExt(header.Filename)
if ext == "" { if ext == "" {
c.jsonErr(400, 400, "无法识别文件扩展名") c.jsonErr(400, 400, "无法识别文件扩展名")
return return
@@ -496,22 +553,31 @@ func (c *BackendFileController) UploadFile() {
return return
} }
// 上传文件 // 1. 先写入临时文件并计算 MD5(此时文件尚未进入正式目录)
result, err := storageService.Upload(fh, header) staged, err := storageService.Stage(fh, header)
if err != nil { if err != nil {
c.jsonErr(500, 500, "上传文件失败: "+err.Error()) c.jsonErr(500, 500, "读取上传文件失败: "+err.Error())
return return
} }
committed := false
defer func() {
// 去重命中或出错时清理临时文件,避免产生垃圾
if !committed && staged != nil {
_ = storageService.Discard(staged)
}
}()
// 检查文件是否已存在(通过MD5) ctx := services.UploadContext{Source: services.SourceBackend, Tid: tid, Tuid: tuid}
var exist models.SystemFile scope := ctx.Scope()
err = models.Orm.QueryTable(new(models.SystemFile)).
Filter("md5", result.MD5). // 2. 精确查重:同端 + 同归属范围 + 同租户 + 同归属用户 + 同 MD5 才算重复。
Filter("tid", tid). // 因此租户已有 a 文件、用户 c 上传同样文件不会冲突。
Filter("delete_time__isnull", true). exist, err := services.FindDuplicate(services.SourceBackend, scope, tid, tuid, staged.MD5)
One(&exist) if err != nil {
if err == nil { c.jsonErr(500, 500, "查重失败: "+err.Error())
// 文件已存在,返回已有记录 return
}
if exist != nil {
c.Data["json"] = map[string]interface{}{ c.Data["json"] = map[string]interface{}{
"code": 201, "code": 201,
"msg": "文件已存在", "msg": "文件已存在",
@@ -525,6 +591,14 @@ func (c *BackendFileController) UploadFile() {
return return
} }
// 3. 提交到正式目录:backend/{tid}[/{tuid}]/yyyy/MM/dd/
result, err := storageService.Commit(staged, ctx)
if err != nil {
c.jsonErr(500, 500, "上传文件失败: "+err.Error())
return
}
committed = true
// 获取分类 // 获取分类
cateStr := c.GetString("cate") cateStr := c.GetString("cate")
var cate uint64 var cate uint64
@@ -532,30 +606,24 @@ func (c *BackendFileController) UploadFile() {
cate, _ = strconv.ParseUint(cateStr, 10, 64) cate, _ = strconv.ParseUint(cateStr, 10, 64)
} }
adminID := uint64(claims.UserID) // 4. 保存文件记录到数据库
var tuidPtr *uint64 id, err := services.CreateFileRecord(services.FileUploadParams{
if ts := strings.TrimSpace(c.GetString("tuid")); ts != "" { Source: services.SourceBackend,
if v, e := strconv.ParseUint(ts, 10, 64); e == nil { Scope: scope,
tuidPtr = &v Tid: tid,
} Tuid: tuid,
} Uid: uint64(claims.UserID),
Name: header.Filename,
// 保存文件记录到数据库 Ext: ext,
row := &models.SystemFile{ Cate: cate,
Tid: tid, Size: uint64(result.Size),
Uid: &adminID, Src: result.URL,
Tuid: tuidPtr, ObjectKey: result.Key,
Name: header.Filename, Storage: storageService.Type(),
Type: detectFileType(ext), MD5: result.MD5,
Cate: cate, })
Size: uint64(result.Size),
Src: result.URL,
Uploader: adminID,
Md5: result.MD5,
}
id, err := models.Orm.Insert(row)
if err != nil { if err != nil {
// 数据库插入失败,尝试删除已上传的文件 // 数据库插入失败,删除已提交的文件
_ = storageService.Delete(result.Key) _ = storageService.Delete(result.Key)
c.jsonErr(500, 500, "上传失败: "+err.Error()) c.jsonErr(500, 500, "上传失败: "+err.Error())
return return
@@ -566,7 +634,7 @@ func (c *BackendFileController) UploadFile() {
"msg": "上传成功", "msg": "上传成功",
"data": map[string]interface{}{ "data": map[string]interface{}{
"url": result.URL, "url": result.URL,
"id": uint64(id), "id": id,
"name": header.Filename, "name": header.Filename,
}, },
} }
@@ -700,7 +768,9 @@ func (c *BackendFileController) DeleteFilePermanently() {
c.jsonErr(404, 404, "文件不存在") c.jsonErr(404, 404, "文件不存在")
return return
} }
removePhysicalBySrc(f.Src) if svc, e := services.GetStorageService(); e == nil {
_ = services.RemovePhysical(svc, f.ObjectKey, f.Src)
}
_, err = models.Orm.QueryTable(new(models.SystemFile)). _, err = models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", id). Filter("id", id).
Filter("tid", tid). Filter("tid", tid).
@@ -773,17 +843,8 @@ func (c *BackendFileController) BatchDeleteFiles() {
c.jsonErr(400, 400, "请选择要删除的文件") c.jsonErr(400, 400, "请选择要删除的文件")
return return
} }
// 软删除:只标记 delete_time,不删除物理文件(彻底删除接口才会删)
now := time.Now() now := time.Now()
for _, id := range body.IDs {
var f models.SystemFile
e := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", id).
Filter("tid", tid).
One(&f)
if e == nil && f.Src != "" {
removePhysicalBySrc(f.Src)
}
}
n, err := models.Orm.QueryTable(new(models.SystemFile)). n, err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", body.IDs). Filter("id__in", body.IDs).
Filter("tid", tid). Filter("tid", tid).
@@ -831,8 +892,10 @@ func (c *BackendFileController) BatchDeleteFilesPermanently() {
c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error())
return return
} }
for i := range rows { if svc, e := services.GetStorageService(); e == nil {
removePhysicalBySrc(rows[i].Src) for i := range rows {
_ = services.RemovePhysical(svc, rows[i].ObjectKey, rows[i].Src)
}
} }
n, err := models.Orm.QueryTable(new(models.SystemFile)). n, err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", body.IDs). Filter("id__in", body.IDs).
+62 -51
View File
@@ -78,6 +78,11 @@ func (c *PlatformFileController) effectiveTid(claims *jwtutil.Claims) uint64 {
return 0 return 0
} }
// Prepare 所有接口执行前补齐文件存储分层所需字段,避免历史库缺列导致查询/写入报错
func (c *PlatformFileController) Prepare() {
models.EnsureSystemFileStorageColumns()
}
func (c *PlatformFileController) jsonErr(httpStatus, bizCode int, msg string) { func (c *PlatformFileController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus) c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
@@ -167,6 +172,7 @@ func (c *PlatformFileController) GetAllFiles() {
keyword := strings.TrimSpace(c.GetString("keyword")) keyword := strings.TrimSpace(c.GetString("keyword"))
qs := models.Orm.QueryTable(new(models.SystemFile)). qs := models.Orm.QueryTable(new(models.SystemFile)).
Filter("source", services.SourcePlatform).
Filter("tid", tid). Filter("tid", tid).
Filter("delete_time__isnull", true) Filter("delete_time__isnull", true)
if cate > 0 { if cate > 0 {
@@ -402,6 +408,7 @@ func (c *PlatformFileController) GetCateFiles() {
keyword := strings.TrimSpace(c.GetString("keyword")) keyword := strings.TrimSpace(c.GetString("keyword"))
qs := models.Orm.QueryTable(new(models.SystemFile)). qs := models.Orm.QueryTable(new(models.SystemFile)).
Filter("source", services.SourcePlatform).
Filter("tid", tid). Filter("tid", tid).
Filter("cate", cateID). Filter("cate", cateID).
Filter("delete_time__isnull", true) Filter("delete_time__isnull", true)
@@ -466,11 +473,12 @@ func (c *PlatformFileController) UploadFile() {
c.jsonErr(401, 401, err.Error()) c.jsonErr(401, 401, err.Error())
return return
} }
tid := c.effectiveTid(claims)
if err := c.Ctx.Request.ParseMultipartForm(platformFileUploadMaxBytes); err != nil { if err := c.Ctx.Request.ParseMultipartForm(platformFileUploadMaxBytes); err != nil {
c.jsonErr(400, 400, "解析上传失败: "+err.Error()) c.jsonErr(400, 400, "解析上传失败: "+err.Error())
return return
} }
tid := c.effectiveTid(claims)
fh, header, err := c.GetFile("file") fh, header, err := c.GetFile("file")
if err != nil || fh == nil { if err != nil || fh == nil {
c.jsonErr(400, 400, "请选择要上传的文件") c.jsonErr(400, 400, "请选择要上传的文件")
@@ -483,7 +491,7 @@ func (c *PlatformFileController) UploadFile() {
return return
} }
ext := platformFileExt(header.Filename) ext := services.FileExt(header.Filename)
if ext == "" { if ext == "" {
c.jsonErr(400, 400, "无法识别文件扩展名") c.jsonErr(400, 400, "无法识别文件扩展名")
return return
@@ -496,22 +504,30 @@ func (c *PlatformFileController) UploadFile() {
return return
} }
// 上传文件 // 1. 先写入临时文件并计算 MD5(此时文件尚未进入正式目录)
result, err := storageService.Upload(fh, header) staged, err := storageService.Stage(fh, header)
if err != nil { if err != nil {
c.jsonErr(500, 500, "上传文件失败: "+err.Error()) c.jsonErr(500, 500, "读取上传文件失败: "+err.Error())
return return
} }
committed := false
defer func() {
if !committed && staged != nil {
_ = storageService.Discard(staged)
}
}()
// 检查文件是否已存在(通过MD5) // 平台端不做租户/用户分层,统一 platform/yyyy/MM/dd/
var exist models.SystemFile ctx := services.UploadContext{Source: services.SourcePlatform, Tid: tid}
err = models.Orm.QueryTable(new(models.SystemFile)). scope := ctx.Scope()
Filter("md5", result.MD5).
Filter("tid", tid). // 2. 精确查重(平台端按 tid + MD5)
Filter("delete_time__isnull", true). exist, err := services.FindDuplicate(services.SourcePlatform, scope, tid, 0, staged.MD5)
One(&exist) if err != nil {
if err == nil { c.jsonErr(500, 500, "查重失败: "+err.Error())
// 文件已存在,返回已有记录 return
}
if exist != nil {
c.Data["json"] = map[string]interface{}{ c.Data["json"] = map[string]interface{}{
"code": 201, "code": 201,
"msg": "文件已存在", "msg": "文件已存在",
@@ -525,6 +541,14 @@ func (c *PlatformFileController) UploadFile() {
return return
} }
// 3. 提交到正式目录
result, err := storageService.Commit(staged, ctx)
if err != nil {
c.jsonErr(500, 500, "上传文件失败: "+err.Error())
return
}
committed = true
// 获取分类 // 获取分类
cateStr := c.GetString("cate") cateStr := c.GetString("cate")
var cate uint64 var cate uint64
@@ -532,30 +556,22 @@ func (c *PlatformFileController) UploadFile() {
cate, _ = strconv.ParseUint(cateStr, 10, 64) cate, _ = strconv.ParseUint(cateStr, 10, 64)
} }
adminID := uint64(claims.UserID) // 4. 保存文件记录到数据库
var tuidPtr *uint64 id, err := services.CreateFileRecord(services.FileUploadParams{
if ts := strings.TrimSpace(c.GetString("tuid")); ts != "" { Source: services.SourcePlatform,
if v, e := strconv.ParseUint(ts, 10, 64); e == nil { Scope: scope,
tuidPtr = &v Tid: tid,
} Uid: uint64(claims.UserID),
} Name: header.Filename,
Ext: ext,
// 保存文件记录到数据库 Cate: cate,
row := &models.SystemFile{ Size: uint64(result.Size),
Tid: tid, Src: result.URL,
Uid: &adminID, ObjectKey: result.Key,
Tuid: tuidPtr, Storage: storageService.Type(),
Name: header.Filename, MD5: result.MD5,
Type: platformDetectFileType(ext), })
Cate: cate,
Size: uint64(result.Size),
Src: result.URL,
Uploader: adminID,
Md5: result.MD5,
}
id, err := models.Orm.Insert(row)
if err != nil { if err != nil {
// 数据库插入失败,尝试删除已上传的文件
_ = storageService.Delete(result.Key) _ = storageService.Delete(result.Key)
c.jsonErr(500, 500, "上传失败: "+err.Error()) c.jsonErr(500, 500, "上传失败: "+err.Error())
return return
@@ -566,7 +582,7 @@ func (c *PlatformFileController) UploadFile() {
"msg": "上传成功", "msg": "上传成功",
"data": map[string]interface{}{ "data": map[string]interface{}{
"url": result.URL, "url": result.URL,
"id": uint64(id), "id": id,
"name": header.Filename, "name": header.Filename,
}, },
} }
@@ -700,7 +716,9 @@ func (c *PlatformFileController) DeleteFilePermanently() {
c.jsonErr(404, 404, "文件不存在") c.jsonErr(404, 404, "文件不存在")
return return
} }
platformRemovePhysicalBySrc(f.Src) if svc, e := services.GetStorageService(); e == nil {
_ = services.RemovePhysical(svc, f.ObjectKey, f.Src)
}
_, err = models.Orm.QueryTable(new(models.SystemFile)). _, err = models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", id). Filter("id", id).
Filter("tid", tid). Filter("tid", tid).
@@ -773,17 +791,8 @@ func (c *PlatformFileController) BatchDeleteFiles() {
c.jsonErr(400, 400, "请选择要删除的文件") c.jsonErr(400, 400, "请选择要删除的文件")
return return
} }
// 软删除:只标记 delete_time,不删除物理文件(彻底删除接口才会删)
now := time.Now() now := time.Now()
for _, id := range body.IDs {
var f models.SystemFile
e := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", id).
Filter("tid", tid).
One(&f)
if e == nil && f.Src != "" {
platformRemovePhysicalBySrc(f.Src)
}
}
n, err := models.Orm.QueryTable(new(models.SystemFile)). n, err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", body.IDs). Filter("id__in", body.IDs).
Filter("tid", tid). Filter("tid", tid).
@@ -831,8 +840,10 @@ func (c *PlatformFileController) BatchDeleteFilesPermanently() {
c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error())
return return
} }
for i := range rows { if svc, e := services.GetStorageService(); e == nil {
platformRemovePhysicalBySrc(rows[i].Src) for i := range rows {
_ = services.RemovePhysical(svc, rows[i].ObjectKey, rows[i].Src)
}
} }
n, err := models.Orm.QueryTable(new(models.SystemFile)). n, err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", body.IDs). Filter("id__in", body.IDs).
+34 -35
View File
@@ -10,6 +10,7 @@ import (
"server/models" "server/models"
"server/pkg/jwtutil" "server/pkg/jwtutil"
"server/services"
beego "github.com/beego/beego/v2/server/web" beego "github.com/beego/beego/v2/server/web"
"github.com/qiniu/go-sdk/v7/auth/qbox" "github.com/qiniu/go-sdk/v7/auth/qbox"
@@ -91,9 +92,10 @@ func (c *QiniuUploadController) GetUploadToken() {
} }
// 生成文件 key(前端可以覆盖) // 生成文件 key(前端可以覆盖)
// 平台端固定落在 platform/yyyy/MM/dd/ 下,与服务端中转上传保持同一套目录规则
datePath := time.Now().Format("2006/01/02") datePath := time.Now().Format("2006/01/02")
timestamp := time.Now().UnixNano() timestamp := time.Now().UnixNano()
keyPrefix := fmt.Sprintf("%s/%d", datePath, timestamp) keyPrefix := fmt.Sprintf("%s/%s/%d", services.SourcePlatform, datePath, timestamp)
// 创建上传策略 // 创建上传策略
mac := qbox.NewMac(cfg.QiniuAccessKey, cfg.QiniuSecretKey) mac := qbox.NewMac(cfg.QiniuAccessKey, cfg.QiniuSecretKey)
@@ -106,13 +108,13 @@ func (c *QiniuUploadController) GetUploadToken() {
// 返回上传凭证和配置 // 返回上传凭证和配置
c.jsonOK(map[string]interface{}{ c.jsonOK(map[string]interface{}{
"token": upToken, "token": upToken,
"domain": cfg.QiniuDomain, "domain": cfg.QiniuDomain,
"bucket": cfg.QiniuBucket, "bucket": cfg.QiniuBucket,
"region": cfg.QiniuRegion, "region": cfg.QiniuRegion,
"keyPrefix": keyPrefix, "keyPrefix": keyPrefix,
"expires": time.Now().Add(time.Hour).Unix(), "expires": time.Now().Add(time.Hour).Unix(),
"uploadUrl": getQiniuUploadURL(cfg.QiniuRegion), "uploadUrl": getQiniuUploadURL(cfg.QiniuRegion),
}) })
} }
@@ -164,7 +166,8 @@ func (c *QiniuUploadController) SaveFileRecord() {
domain := strings.TrimRight(cfg.QiniuDomain, "/") domain := strings.TrimRight(cfg.QiniuDomain, "/")
fileURL := fmt.Sprintf("%s/%s", domain, req.Key) fileURL := fmt.Sprintf("%s/%s", domain, req.Key)
// 计算 MD5(使用 hash 作为 MD5,或者重新计算) // 注意:直传通道前端给的是七牛 etag(req.Hash),并非文件真实 MD5,
// 与服务端中转上传算出的 MD5 不同源,因此该通道的查重只在本通道内有效。
md5Sum := req.Hash md5Sum := req.Hash
if md5Sum == "" { if md5Sum == "" {
// 如果没有 hash,使用 key 生成一个唯一标识 // 如果没有 hash,使用 key 生成一个唯一标识
@@ -174,13 +177,12 @@ func (c *QiniuUploadController) SaveFileRecord() {
} }
// 检查文件是否已存在(通过 MD5) // 检查文件是否已存在(通过 MD5)
var exist models.SystemFile exist, err := services.FindDuplicate(services.SourcePlatform, services.ScopeTenant, tid, 0, md5Sum)
err = models.Orm.QueryTable(new(models.SystemFile)). if err != nil {
Filter("md5", md5Sum). c.jsonErr(500, 500, "查重失败: "+err.Error())
Filter("tid", tid). return
Filter("delete_time__isnull", true). }
One(&exist) if exist != nil {
if err == nil {
// 文件已存在,返回已有记录 // 文件已存在,返回已有记录
c.Data["json"] = map[string]interface{}{ c.Data["json"] = map[string]interface{}{
"code": 201, "code": 201,
@@ -195,25 +197,22 @@ func (c *QiniuUploadController) SaveFileRecord() {
return return
} }
// 检测文件类型 // 保存文件记录(平台端:source=platform,scope=tenant,object_key=七牛 key)
ext := getQiniuFileExt(req.Name) // 文件类型由 services.CreateFileRecord 依据扩展名统一推断
fileType := detectQiniuFileType(ext) id, err := services.CreateFileRecord(services.FileUploadParams{
Source: services.SourcePlatform,
// 保存文件记录 Scope: services.ScopeTenant,
adminID := uint64(claims.UserID) Tid: tid,
row := &models.SystemFile{ Uid: uint64(claims.UserID),
Tid: tid, Name: req.Name,
Uid: &adminID, Ext: getQiniuFileExt(req.Name),
Name: req.Name, Cate: req.Cate,
Type: fileType, Size: uint64(req.Size),
Cate: req.Cate, Src: fileURL,
Size: uint64(req.Size), ObjectKey: req.Key,
Src: fileURL, Storage: services.StorageTypeQiniu,
Uploader: adminID, MD5: md5Sum,
Md5: md5Sum, })
}
id, err := models.Orm.Insert(row)
if err != nil { if err != nil {
c.jsonErr(500, 500, "保存文件记录失败: "+err.Error()) c.jsonErr(500, 500, "保存文件记录失败: "+err.Error())
return return
@@ -0,0 +1,93 @@
-- 文件存储分层改造:yz_system_files 新增归属与存储字段
-- 对应计划文档:go/docs/文件存储分层改造计划.md S1
--
-- 幂等脚本:用存储过程逐项判断,已存在的列/索引自动跳过,可反复执行,不会报
-- 1060 Duplicate column / 1061 Duplicate key。
--
-- 背景:md5 在部分历史库中是 TEXT 类型,MySQL 不允许对 TEXT/BLOB 建整列索引,
-- 因此 idx_file_dedup 使用前缀长度 md5(32)(MD5 十六进制串固定 32 字符,等价全值)。
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
DELIMITER $$
CREATE PROCEDURE `yz_alter_system_files_storage`()
BEGIN
DECLARE v_db VARCHAR(64);
SET v_db = DATABASE();
-- 1. 字段:source
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'source'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `source` varchar(16) NOT NULL DEFAULT 'backend'
COMMENT '来源端: backend-租户后台 platform-平台端';
END IF;
-- 2. 字段:scope
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'scope'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT 'tenant'
COMMENT '归属: tenant-租户共享 user-用户个人';
END IF;
-- 3. 字段:storage
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'storage'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `storage` varchar(16) NOT NULL DEFAULT ''
COMMENT '存储类型: local/qiniu(冗余,便于迁移与排查)';
END IF;
-- 4. 字段:object_key
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'object_key'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `object_key` varchar(512) NOT NULL DEFAULT ''
COMMENT '存储相对路径(不含域名),用于迁移与精确删除';
END IF;
-- 5. 索引:查重(source + scope + tid + tuid + md5 前缀)
-- 不加 UNIQUE:软删(delete_time)与并发上传下唯一索引会直接报错,去重在代码层完成
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_dedup'
) THEN
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_dedup` (`source`, `scope`, `tid`, `tuid`, `md5`(32));
END IF;
-- 6. 索引:归属列表过滤
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_owner'
) THEN
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_owner` (`source`, `tid`, `scope`, `tuid`, `delete_time`);
END IF;
END$$
DELIMITER ;
CALL `yz_alter_system_files_storage`();
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
-- 7. 老数据初始化:迁移脚本执行前先打底,避免 source/scope 为空导致查询遗漏
UPDATE `yz_system_files` SET `source` = 'backend' WHERE `source` = '' OR `source` IS NULL;
UPDATE `yz_system_files` SET `scope` = 'tenant' WHERE `scope` = '' OR `scope` IS NULL;
-- 8. 校验(应看到 4 个字段 + 2 个索引)
-- SHOW COLUMNS FROM yz_system_files LIKE 'source';
-- SHOW COLUMNS FROM yz_system_files LIKE 'scope';
-- SHOW COLUMNS FROM yz_system_files LIKE 'storage';
-- SHOW COLUMNS FROM yz_system_files LIKE 'object_key';
-- SHOW INDEX FROM yz_system_files WHERE Key_name IN ('idx_file_dedup','idx_file_owner');
+403
View File
@@ -0,0 +1,403 @@
# 文件存储分层改造计划
> 创建日期:2026-09-09
> 当前状态:**进行中**(改造中断时,从「进度总表」里第一个未勾选的条目继续)
> 适用范围:`go/`(服务端)+ `backend/`(租户后台前端)+ `platform/`(平台端前端)
---
## 0. 进度总表
> 每完成一项,把 `- [ ]` 改成 `- [x]` 并更新日期。中断后从第一个 `- [ ]` 继续。
| # | 阶段 | 内容 | 状态 |
|---|---|---|---|
| S1 | 数据库 | `yz_system_files` 新增 `source`/`scope`/`storage`/`object_key` + 索引 + `EnsureSystemFileStorageColumns()` | ✅ 2026-09-09 |
| S2 | services | `storage_service.go`:新增 `UploadContext`/`BuildObjectKey()`/`Stage()`/`Commit()`/`Move()` | ✅ 2026-09-09 |
| S3 | services | 新增 `services/file_service.go`:MD5 精确查重、入库、路径解析、文件类型推断 | ✅ 2026-09-09 |
| S4 | controllers | `backend_file.go`:上传走新目录规则 + 先算 MD5 后落盘 + 列表/删除 scope 隔离 | ✅ 2026-09-09 |
| S5 | controllers | `platform_file.go`:固定 `platform/日期`,不做租户/用户分层 | ✅ 2026-09-09 |
| S6 | controllers | `qiniu_upload.go`:token 的 `keyPrefix` 带 `platform/` 前缀,入库写 `source` | ✅ 2026-09-09 |
| S7 | 前端 | backend 端上传点接入新参数(含修复 5 处 404 的 `/admin/uploadfiles`) | ✅ 2026-09-09 |
| S8 | 前端 | platform 端上传点接入新参数(含修复 `/platform/upload`、资质上传) | ✅ 2026-09-09 |
| S9 | 迁移 | `cmd/migrate_storage`(默认 dry-run,`--apply` 执行,七牛走 Move)— **脚本已就绪,尚未执行** | ✅ 2026-09-09(待执行) |
| S10 | 验收 | 上传/去重/隔离/迁移全链路验证 | ☐ |
---
## 1. 目标与非目标
### 目标
1. 两个端的文件物理隔离:`backend/` 与 `platform/` 分目录。
2. backend 端按租户隔离:`backend/{tid}/`。
3. 租户内区分**租户共享文件**与**用户个人文件**:个人文件落到 `backend/{tid}/{tuid}/`。
4. 常规按 `年/月/日` 分目录。
5. MD5 去重**精确到归属**:
- 租户共享文件 → 同 `tid` 内 MD5 相同才算重复;
- 用户个人文件 → 同 `tid` + 同 `tuid` 内 MD5 相同才算重复;
- 「租户已有 a 文件」与「用户 c 上传同样文件」**不冲突**,可并存。
6. 去重改为**先算 MD5 再落盘**,重复文件不产生物理垃圾。
### 非目标(本次不做)
- 不做文件访问鉴权(`/uploads` 仍为公开静态目录),仅做目录隔离。
- 不迁移 `themes/` 下的官网模板(`POST /platform/template/upload` 保持原样,不进文件表)。
- 不改组织架构 CSV 导入(`importOrganization` 是临时解析文件,不入库 `yz_system_files`)。
---
## 2. 现状分析
### 2.1 服务端链路
| 入口 | 代码位置 | 说明 |
|---|---|---|
| `POST /backend/uploadfile`、`/backend/uploadfiles` | `controllers/backend_file.go:463` | backend 端,服务端中转 |
| `POST /platform/uploadfile`、`/platform/uploadfiles` | `controllers/platform_file.go:463` | platform 端,服务端中转 |
| `GET /platform/qiniu/token` + `POST /platform/qiniu/save` | `controllers/qiniu_upload.go` | 七牛前端直传,**仅 platform 端有路由** |
| `POST /platform/template/upload` | `controllers/platform_template.go` | 模板 zip → `themes/`,不进文件表 |
- 存储实现:`services/storage_service.go`,`LocalStorage`(`BaseDir=uploads`、`BaseURL=/`)与 `QiniuStorage`。
- 当前路径规则:`2006/01/02/{UnixNano}{ext}`,**无任何端/租户/用户维度**。
- 静态映射:`beego.SetStaticPath("/uploads","uploads")`(`main.go:23`)。
- `backend_file.go` 与 `platform_file.go` 是**复制粘贴的两份**(约 900 行几乎全同),改规则必须同步两处。
### 2.2 数据库
`yz_system_files`(`models/system_file.go`)现有字段:`tid / uid / tuid / name / type / cate / size / src / uploader / md5 / create_time / update_time / delete_time`。
语义现状(重要):
- `uid` = 上传者 ID(controller 里直接写 `claims.UserID`)。
- `tuid` = 表单可选参数,**前端从未传过,库中全为 NULL**。
- 没有「来源端」「共享/个人」「存储 key」字段 → 无法做目录归属与迁移。
ID 语义(已确认):
- **backend 端登录**:`services/platform_auth.go:122` 用 `tenantUser.Uid` 签发 JWT → `claims.UserID` 就是**租户用户 uid(8 位,如 67091493)**,`claims.TenantId` = 租户 ID(如 234573)。
- **platform 端登录**:`services/platform_auth.go:79` 用 `AdminUser.ID` 签发 → `claims.UserID` 是平台管理员 ID,`claims.TenantId = 0`。
**结论:backend 端 JWT 里天然就带着「租户 ID + 用户 uid」,个人文件目录名可直接用 `claims.UserID`;前端传 `tuid` 只在"代别人上传"场景才需要。**
### 2.3 前端上传点全量清单
#### backend 端(`backend/src`)—— 21 处代码点,19 处真实发请求
| # | 文件(相对 `backend/src`) | 功能 | 接口 | 现状参数 |
|---|---|---|---|---|
| B1 | `views/system/fileManager/components/uploadFile.vue` | 系统文件库上传 | `/backend/uploadfile` | `cate`(重复 append 两次) |
| B2 | `views/components/UmoEditor.vue` | 富文本图片/视频 | `/backend/uploadfile` | 无 |
| B3 | `views/apps/cms/article/index/components/edit.vue` | 文章封面 | `/backend/uploadfile` | `cate=article` |
| B4 | `views/apps/cms/article/type/components/edit.vue` | 文章分类默认图 | `/backend/uploadfile` | 无 |
| B5 | `views/moduleshop/center/index.vue` | 模块中心缩略图 | `/backend/uploadfile` | `cate=module` |
| B6 | `views/basicSettings/siteSettings/components/normalSettings.vue` | 站点 Logo | `/backend/uploadfile` | `cate=site` |
| B7 | 同上 | 站点白色 Logo | `/backend/uploadfile` | `cate=site` |
| B8 | 同上 | 站点 ico 图标 | `/backend/uploadfile` | `cate=site` |
| B9 | `views/apps/oa/schedule/components/detail.vue` | 日程相关图片/粘贴截图 | `/backend/uploadfile` | 无 |
| B10 | `views/apps/oa/reimburse/components/detail.vue` | 报销发票/票据 | `/backend/uploadfile` | `cate=reimbursement-invoice` |
| B11 | `views/apps/oa/employeefile/components/recordEditDialog.vue` | 员工档案记录附件 | `/backend/uploadfile` | 无 |
| B12 | `views/apps/oa/employeefile/components/fileDetailDrawer.vue` | 员工证照-学历照片 | `/backend/uploadfile` | 无 |
| B13 | `views/apps/cms/banner/components/edit.vue` | Banner 图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B14 | `views/apps/cms/solution/index/components/edit.vue` | 方案图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B15 | `views/apps/cms/product/index/components/edit.vue` | 产品图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B16 | `views/apps/cms/frontMenu/components/edit.vue` | 前端菜单图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B17 | `views/apps/cms/friendlink/components/edit.vue` | 友情链接 Logo | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B18 | `views/basicSettings/tenants/components/qualification.vue` | 租户资质文件 | `/api/platform/common/upload` ⚠️ | 无,提交为 mock |
| B19 | `views/apps/organization/components/ImportExportDialog.vue` | 组织架构 CSV 导入 | `/backend/.../importOrganization` | CSV,不入库文件表 |
| B20 | `views/moduleshop/publish/index.vue` | 模块 zip | 无(TODO 死代码) | — |
| B21 | `views/moduleshop/components/createModules.vue` | 模块 zip | 无(TODO 死代码) | — |
#### platform 端(`platform/src`)—— 14 处代码点,9 处真实发请求
| # | 文件(相对 `platform/src`) | 功能 | 接口 | 通道 |
|---|---|---|---|---|
| P1 | `views/system/fileManager/components/uploadFile.vue` | 平台文件管理 | `/platform/uploadfile` | 服务端中转 |
| P2 | `views/components/UmoEditor.vue` | 富文本(笔记本) | `/platform/uploadfile` | 服务端中转 |
| P3 | `views/basicSettings/siteSettings/components/normalSettings.vue` | 站点 Logo | `/platform/uploadfile` | 服务端中转 |
| P4 | 同上 | 站点白色 Logo | `/platform/uploadfile` | 服务端中转 |
| P5 | 同上 | 站点 ico 图标 | `/platform/uploadfile` | 服务端中转 |
| P6 | `views/moduleshop/center/index.vue` | 模块中心缩略图 | `/platform/uploadfile` | 服务端中转 |
| P7 | `views/platform/softwareupgrade/components/edit.vue` | 软件升级包(4 平台) | `smartUpload()` | **七牛直传 / 本地自适应** |
| P8 | `views/template/index.vue` | 官网模板 zip | `/platform/template/upload` | 服务端中转(不入库) |
| P9 | `views/apps/babyhealth/users/components/userEdit.vue` | 用户头像 | **`{BASE}/platform/upload`** ⚠️ | 服务端中转 |
| P10 | `views/apps/babyhealth/users/components/userEdit.vue` | 头像(裁剪后) | `uploadAvatar()` | ❌ 死代码(函数未定义) |
| P11 | `views/basicSettings/tenants/components/qualification.vue` | 租户资质图片 | `/api/platform/common/upload` ⚠️ | 悬空(无代理/无 token) |
| P12 | `views/apps/babyhealth/babys/components/edit.vue` | 宝贝头像 | `uploadAvatar()` | ❌ 死代码 |
| P13 | `views/moduleshop/components/createModules.vue` | 创建模块包 | 无(调用被注释) | ❌ 不发请求 |
| P14 | `views/moduleshop/publish/index.vue` | 发布模块包 | 无(TODO) | ❌ 不发请求 |
### 2.4 已发现的问题(本次一并处理)
| 级别 | 问题 | 说明 |
|---|---|---|
| 🔴 | **`/admin/uploadfiles` 路由不存在** | `go/routers` 全量搜索无 `/admin` 前缀路由;B13~B17 五处 CMS 上传实际会 404(`VITE_API_BASE=https://api.yunzer.cn`)。需收敛到 `/backend/uploadfile`。 |
| 🔴 | **`/platform/upload` 路由不存在** | P9 头像上传会 404。需收敛到 `/platform/uploadfile`。 |
| 🟡 | **`/api/platform/common/upload` 路由不存在** | B18 / P11 悬空,且提交逻辑是 mock。 |
| 🔴 | **去重先落盘后判断** | 命中重复时物理文件已写入磁盘/七牛且未删除 → 产生孤儿垃圾。 |
| 🔴 | **去重维度只有 `tid`** | 不区分端、不区分用户,与"精确到用户"要求不符。 |
| 🟡 | **七牛直传的 `md5` 存的是 etag** | `qiniu_upload.go:168` 把 `hash`(etag)当 md5 存,与本地真 MD5 不同源,跨存储去重会失准。 |
| 🟡 | **物理删除路径脆弱** | `removePhysicalBySrc` 直接 `os.Remove(TrimPrefix(src,"/"))`,依赖进程 CWD。 |
| 🟡 | **删除语义不一致** | 单条 `DeleteFile` 只软删,`BatchDeleteFiles` 却真删物理文件。 |
| 🟢 | `cate` 被重复 append | `api/file.js` 的 `options.cate` 与调用处手动 append 各一次 → multipart 里两个 `cate`。 |
---
## 3. 目标目录规范
```
{存储根}/ 本地: uploads/ 七牛: bucket 根
├── backend/
│ └── {tid}/ 例: 234573
│ ├── 2026/09/09/{ts}_{rand}.{ext} ← scope=tenant(租户共享)
│ └── {tuid}/ 例: 67091493
│ └── 2026/09/09/{ts}_{rand}.{ext} ← scope=user(用户个人)
└── platform/
└── 2026/09/09/{ts}_{rand}.{ext} ← 平台端,不分层
```
- `{ts}_{rand}`:`UnixNano` + 6 位随机(避免同纳秒并发冲突)。
- 七牛用同样的 key 字符串(`/` 即逻辑目录)。
- `tid=0`(platform 端或缺失租户上下文)时 backend 端路径退化为 `backend/0/...`,并在日志告警。
### 访问 URL
- 本地:`/uploads/backend/234573/2026/09/09/xxx.png`
- 七牛:`{domain}/backend/234573/2026/09/09/xxx.png`
---
## 4. 数据库改造(S1)
### 4.1 新增字段
```sql
ALTER TABLE `yz_system_files`
ADD COLUMN `source` varchar(16) NOT NULL DEFAULT 'backend' COMMENT '来源端: backend-租户后台 platform-平台端',
ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT 'tenant' COMMENT '归属: tenant-租户共享 user-用户个人',
ADD COLUMN `storage` varchar(16) NOT NULL DEFAULT '' COMMENT '存储类型: local/qiniu(冗余,便于迁移与排查)',
ADD COLUMN `object_key` varchar(512) NOT NULL DEFAULT '' COMMENT '存储相对路径(不含域名),用于迁移与精确删除';
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_dedup` (`source`, `scope`, `tid`, `tuid`, `md5`),
ADD KEY `idx_file_owner` (`source`, `tid`, `scope`, `tuid`, `delete_time`);
```
> 不建议加 UNIQUE:`delete_time` 软删 + 并发上传下唯一索引会直接报错,改用普通索引 + 代码层查重。
### 4.2 字段语义(改造后明确)
| 字段 | 语义 |
|---|---|
| `tid` | 租户 ID |
| `uid` | **上传者** ID(保持不变) |
| `tuid` | **归属用户** ID(个人文件必填,共享文件为 NULL) |
| `source` | `backend` / `platform` |
| `scope` | `tenant` / `user` |
| `object_key` | 存储相对路径,如 `backend/234573/67091493/2026/09/09/xxx.png` |
### 4.3 落地方式
在 `models/system_file.go` 增加 `EnsureSystemFileStorageColumns()`(参照 `EnsureTenantUserGroupColumn` 的既有模式,`ALTER` 报错忽略),并在 `BackendFileController.Prepare()` / `PlatformFileController.Prepare()` 中调用。SQL 脚本同步落到 `go/docs/sql/`。
---
## 5. 服务端改造
### S2 `services/storage_service.go`
```go
// UploadContext 上传上下文,决定最终落盘路径
type UploadContext struct {
Source string // backend / platform
Tid uint64 // 租户 ID
Tuid uint64 // 归属用户 ID,0 = 租户共享
Ext string // 扩展名
}
// BuildObjectKey 生成存储相对路径(不含域名、不含 BaseDir)
// backend 共享: backend/234573/2026/09/09/xxx.png
// backend 个人: backend/234573/67091493/2026/09/09/xxx.png
// platform : platform/2026/09/09/xxx.png
func BuildObjectKey(ctx UploadContext) (key, datePath string)
```
新增方法(保留原 `Upload` 内部复用):
- `StageToTemp(file, header) (tmpPath string, md5 string, size int64, err error)` — 流式算 MD5 并写入**同磁盘**临时目录 `uploads/.tmp/`(保证后续 `os.Rename` 不跨盘)。
- `CommitTemp(tmpPath, objectKey) (*UploadResult, error)` — `MkdirAll` + `Rename`(跨盘失败则回退 `io.Copy`)。
- `UploadWithContext(file, header, ctx)` — 组合上面两步,供迁移脚本等简单场景使用。
> 七牛同样"先算 MD5":先 `StageToTemp` 得到 md5 + 临时文件 → 查重 → 命中则删临时文件返回已存在,未命中才 `Put` 到新 key。
### S3 新增 `services/file_service.go`
- `FindDuplicate(source, scope string, tid, tuid uint64, md5 string) (*models.SystemFile, error)`
- `CreateFileRecord(...) (uint64, error)` — 统一写入 `source/scope/object_key/storage`
- `ListFiles(source, scope string, tid, tuid uint64, ...) ` — 列表的 scope 隔离
- `RemovePhysical(storageType, objectKey, src string) error` — 用 `object_key` 精确删除,替代脆弱的 `removePhysicalBySrc`
### S4 `controllers/backend_file.go`
1. `Prepare()` 里调 `models.EnsureSystemFileStorageColumns()`。
2. `UploadFile` 流程改为:
```
鉴权 → effectiveTid → 解析 tuid(form > X-Tenant-User-Id 头 > claims.UserID)
→ StageToTemp(拿到 md5/size)
→ FindDuplicate(source=backend, scope, tid, tuid, md5)
命中 → 删临时文件 → 返回 code 201(文件已存在)
未命中 → CommitTemp(objectKey) → 入库 → 返回 code 200
```
3. `scope` 判定:`tuid > 0` → `user`,否则 `tenant`。
4. 列表接口 `GetAllFiles` / `GetCateFiles` / `GetUserCate` 增加 `scope` 过滤:
- 默认只返回 `scope=tenant`;
- `?scope=user` 时按当前 `tuid` 过滤,只返回本人文件。
5. 删除:统一用 `object_key` 删除物理文件;统一软删与批量删除的语义(批量删除不再误删物理文件,彻底删除才删)。
### S5 `controllers/platform_file.go`
- `source=platform`,路径固定 `platform/日期`,**忽略 `tuid` 与租户分层**。
- 其余(先算 MD5 再落盘、`object_key` 入库、删除修复)与 S4 保持一致。
### S6 `controllers/qiniu_upload.go`
- `GetUploadToken` 返回的 `keyPrefix` 改为 `platform/2026/09/09/{ts}`(原来是 `2026/09/09/{ts}`)。
- `SaveFileRecord` 入库时写 `source=platform`、`scope=tenant`、`object_key=req.Key`。
- 明确 `md5` 字段:直传场景无法拿到真 MD5,**保留 etag 但写入时打标**(在 `md5` 为空时用 etag,并在注释中说明;后续如需精确去重,此通道需改为服务端中转)。
### S7 / S8 前端改造
统一封装:
- `backend/src/api/file.js` 与 `platform/src/api/file.js` 的 `uploadFile(formData, options)` 增加 `options.tuid`,并**移除调用处重复的 `cate` append**(保留 options 里那次)。
- 个人文件场景传 `tuid`,公共场景不传。
---
## 6. 前端上传点归属判定表
> **`tenant` = 租户共享(`backend/{tid}/日期/`);`user` = 用户个人(`backend/{tid}/{tuid}/日期/`)**
### backend 端
| # | 功能 | 判定 | 依据 |
|---|---|---|---|
| B1 | 系统文件库上传 | `tenant` | ✅ 已确认:素材库不分「共享/我的」,全部走租户共享;个人文件只来自 B9/B10 |
| B2 | 富文本(文章/方案/产品正文) | `tenant` | 业务内容,全租户可见 |
| B3 | CMS 文章封面 | `tenant` | 业务数据 |
| B4 | CMS 文章分类默认图 | `tenant` | 业务数据 |
| B5 | 模块中心缩略图 | `tenant` | 模块市场资源 |
| B6/B7/B8 | 站点 Logo / 白色 Logo / ico | `tenant` | 租户级配置 |
| B9 | OA 日程图片 | `user` | ✅ 已确认:按日程创建人归属 |
| B10 | OA 报销发票 | `user` | ✅ 已确认:挂在员工个人报销单下 |
| B11 | 员工档案记录附件 | `tenant` | ✅ 已确认:HR 需跨员工查看 |
| B12 | 员工证照(学历照片) | `tenant` | ✅ 已确认:HR 需跨员工查看 |
| B13~B17 | CMS Banner/方案/产品/菜单/友链 | `tenant` | 业务数据 + **需修 404**(✅ 已确认要修) |
| B18 | 租户资质文件 | `tenant` | **需修 404**(✅ 已确认要修,接口需重新设计) |
| B19 | 组织架构 CSV 导入 | 不涉及 | 临时解析,不入库 |
### platform 端(全部 `platform/日期/`,不做租户/用户分层)
| # | 功能 | 处理 |
|---|---|---|
| P1~P6 | 文件管理 / 富文本 / 站点 Logo ×3 / 模块缩略图 | 保持调用 `/platform/uploadfile`,无需传 `tuid` |
| P7 | 软件升级包 | `smartUpload` → 七牛直传时 key 也要带 `platform/` 前缀(S6 已覆盖) |
| P8 | 模板 zip | 不动(走 `/platform/template/upload`,不入库) |
| P9 | 用户头像 | **需修**:`{BASE}/platform/upload` → `/platform/uploadfile` |
| P10 / P12 | `uploadAvatar()` 死代码 | 本次不启用(或删除) |
| P11 | 租户资质图片 | ⚠️ **待确认**,见 §8-Q3 |
| P13 / P14 | 模块包(未发请求) | 不动 |
---
## 7. 存量数据迁移方案(S9)
### 7.1 当前存储类型
✅ **已确认:`storage_type = qiniu`(七牛云)** → 迁移走 `BucketManager.Move`(服务端改名,不走流量、与文件大小无关)。
> 若后续切回本地存储,脚本自动改为 `os.Rename` 分支。
### 7.2 迁移原理
- **七牛云**:`BucketManager.Move(srcBucket, srcKey, destBucket, destKey)` — 同 bucket 内**服务端原子改名**,不走流量、秒级完成,只计 API 调用次数。与文件大小无关。
- **本地**:`os.MkdirAll` + `os.Rename`(同盘,不搬数据)。
> 注意:现有 `services/storage_migration.go` 的 `MigrateFile` 是"下载再上传"的旧实现,**本次要替换为 Move/Rename**。
### 7.3 迁移步骤
1. 遍历 `yz_system_files WHERE delete_time IS NULL`。
2. 从 `src` 解析出老 key:
- 本地:`/uploads/2026/09/09/xxx.png` → `2026/09/09/xxx.png`
- 七牛:`{domain}/2026/09/09/xxx.png` → `2026/09/09/xxx.png`
3. 按新规则生成目标 key:**老数据一律按 `scope=tenant`(租户共享)迁移**(原因见 7.4)。
4. Move / Rename。
5. `UPDATE yz_system_files SET src=新URL, object_key=新key, source=..., scope='tenant', storage=... WHERE id=?`。
### 7.4 老数据的两个硬限制(必须知悉)
1. **还原不出"个人/共享"归属**:老数据 `tuid` 全为 NULL,`uid` 是上传者(管理员)ID,没有任何字段能说明"这是谁的个人文件"。因此老数据**统一按租户共享迁移**;个人目录只对改造后新增的文件生效。
2. **`md5` 可能混了七牛 etag**:判断方法
```sql
SELECT COUNT(*) FROM yz_system_files WHERE LENGTH(md5) <> 32;
```
非 32 位的记录是 etag,不是真 MD5,这部分去重会失准(本次不修复,仅记录)。
### 7.5 硬编码 URL 风险
Move 后旧 URL 会 404。需排查是否有业务把上传 URL 写死在 `yz_system_files.src` 之外的地方(CMS 正文 `content`、官网模板配置 `yz_tenant_site_setting` 等)。**执行迁移前先做全库扫描**,这部分在 S9 里做。
### 7.6 脚本形态
`go/cmd/migrate_storage/main.go`:
- 默认 `--dry-run`:只打印 `老key → 新key` 计划与统计,不改动任何数据。
- `--apply`:真正执行。
- `--tid=234573`:可选,只迁移指定租户。
- 幂等:目标 key 已存在则跳过,可重复执行。
---
## 8. 待确认事项(**未答复前这些模块不改动**)
### 已确认
| 编号 | 结论 | 影响范围 |
|---|---|---|
| Q2 | **日程图片、报销发票 → `user`(个人);员工档案附件、员工证照 → `tenant`(共享)** | B9~B12 |
| Q3 | **三组坏链全部修**:①`/admin/uploadfiles`(B13~B17)→ `/backend/uploadfile`;②`/platform/upload`(P9)→ `/platform/uploadfile`;③`/api/platform/common/upload`(B18、P11 资质)→ 重新设计 | B13~B18、P9、P11 |
| Q4 | **存储类型 = 七牛云 qiniu** → 迁移走 `BucketManager.Move` | S9 |
| Q1 | **素材库不分 Tab,全部走租户共享。** 个人文件只来自 B9 日程图片、B10 报销发票。后端已备好 `scope=user` 能力,以后要加「我的文件」Tab 只需前端加 Tab + 传参。 | B1 |
### 待确认
无(Q1~Q4 全部已确认)。
### 后续单列任务(不在本次改造范围)
| 编号 | 事项 | 说明 |
|---|---|---|
| T1 | **租户资质业务落库** | B18/P11 的上传通道已修(改为走通用上传接口、带上 token),但 `submitForm` 仍是前端 mock,后端**没有资质表也没有保存接口**。需要新表(tid/type/file_url/expire_time/remark)+ 保存/详情接口,属新功能,另开任务。 |
| T2 | 七牛直传通道的 MD5 是 etag | 软件升级包走七牛直传,`md5` 存的是 etag 与服务端中转算出的真 MD5 不同源,该通道查重只在通道内有效。如需全局精确去重,需把直传改为服务端中转(大文件代价高)。 |
---
## 9. 风险与回滚
| 风险 | 应对 |
|---|---|
| 新目录规则上线后老 URL 失效 | 迁移前先全库扫描硬编码引用;迁移脚本先 dry-run |
| `effectiveTid()` 拿到 0 | backend 端 tid=0 时落 `backend/0/` 并打 WARN 日志,不阻断 |
| 并发上传同文件 | 临时文件名带随机后缀;查重与入库之间的极短窗口允许少量重复(后续可加分布式锁) |
| `os.Rename` 跨盘失败 | 回退 `io.Copy` |
| 改造中断 | 按 §0 进度总表从第一个未勾选项继续 |
| 回滚 | 服务端改动集中在 `storage_service.go` + 两个 file controller,回滚即恢复这 3 个文件的旧版本;数据库新增列可保留(不影响旧逻辑) |
---
## 10. 验收清单
- [ ] backend 上传图片 → 落盘到 `uploads/backend/{tid}/2026/09/09/`
- [ ] backend 传 `tuid` 上传 → 落盘到 `uploads/backend/{tid}/{tuid}/2026/09/09/`
- [ ] platform 上传 → 落盘到 `uploads/platform/2026/09/09/`
- [ ] 同一租户重复上传同一文件 → 返回 201,且**磁盘上没有新增文件**
- [ ] 租户已有 a 文件,用户 c 上传同样文件 → 正常入库,不冲突(两处物理文件并存)
- [ ] 用户 c 再传同一文件 → 返回 201
- [ ] 文件列表:`scope=user` 只看到自己的;默认只看到租户共享
- [ ] 删除文件 → 物理文件按 `object_key` 精确删除
- [ ] 七牛模式:`keyPrefix` 带 `platform/` 前缀
- [ ] 迁移 dry-run 输出正确,apply 后旧 URL 全部更新且可访问
+71 -15
View File
@@ -1,25 +1,81 @@
package models package models
import "time" import (
"sync"
"time"
)
// SystemFile 附件表 yz_system_files // SystemFile 附件表 yz_system_files
//
// 归属语义(2026-09-09 文件存储分层改造后明确):
// - tid : 租户 ID
// - uid : 上传者 ID
// - tuid : 归属用户 ID(scope=user 时必填;scope=tenant 时为 NULL)
// - source: 来源端 backend / platform
// - scope : tenant-租户共享 / user-用户个人
// - object_key: 存储相对路径,不含域名,如 backend/234573/67091493/2026/09/09/xxx.png
type SystemFile struct { type SystemFile struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"` ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"` Tid uint64 `orm:"column(tid)" json:"tid"`
Uid *uint64 `orm:"column(uid);null" json:"uid"` Uid *uint64 `orm:"column(uid);null" json:"uid"`
Tuid *uint64 `orm:"column(tuid);null" json:"tuid"` Tuid *uint64 `orm:"column(tuid);null" json:"tuid"`
Name string `orm:"column(name);size(255)" json:"name"` Name string `orm:"column(name);size(255)" json:"name"`
Type uint8 `orm:"column(type);default(2)" json:"type"` Type uint8 `orm:"column(type);default(2)" json:"type"`
Cate uint64 `orm:"column(cate);default(0)" json:"cate"` Cate uint64 `orm:"column(cate);default(0)" json:"cate"`
Size uint64 `orm:"column(size);default(0)" json:"size"` Size uint64 `orm:"column(size);default(0)" json:"size"`
Src string `orm:"column(src);size(512)" json:"src"` Src string `orm:"column(src);size(512)" json:"src"`
Uploader uint64 `orm:"column(uploader);default(0)" json:"uploader"` Uploader uint64 `orm:"column(uploader);default(0)" json:"uploader"`
Md5 string `orm:"column(md5);size(32)" json:"md5"` Md5 string `orm:"column(md5);size(32)" json:"md5"`
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` Source string `orm:"column(source);size(16);default(backend)" json:"source"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null;auto_now" json:"update_time"` Scope string `orm:"column(scope);size(16);default(tenant)" json:"scope"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` Storage string `orm:"column(storage);size(16);default()" json:"storage"`
ObjectKey string `orm:"column(object_key);size(512);default()" json:"object_key"`
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null;auto_now" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
} }
func (m *SystemFile) TableName() string { func (m *SystemFile) TableName() string {
return "yz_system_files" return "yz_system_files"
} }
// 归属与存储字段的取值常量
const (
FileSourceBackend = "backend" // 租户后台端
FileSourcePlatform = "platform" // 平台端
FileScopeTenant = "tenant" // 租户共享文件
FileScopeUser = "user" // 用户个人文件
)
var systemFileStorageColumnsOnce sync.Once
// EnsureSystemFileStorageColumns 为附件表补齐文件存储分层改造所需字段。
// 历史库可能早于该特性建表,字段/索引已存在时 MySQL 报 duplicate,统一忽略,不阻断主流程。
// 写入 yz_system_files 之前必须调用,否则 INSERT 会因 Unknown column 失败。
func EnsureSystemFileStorageColumns() {
if Orm == nil {
return
}
systemFileStorageColumnsOnce.Do(func() {
alters := []string{
"ALTER TABLE yz_system_files ADD COLUMN source varchar(16) NOT NULL DEFAULT 'backend' COMMENT '来源端: backend-租户后台 platform-平台端'",
"ALTER TABLE yz_system_files ADD COLUMN scope varchar(16) NOT NULL DEFAULT 'tenant' COMMENT '归属: tenant-租户共享 user-用户个人'",
"ALTER TABLE yz_system_files ADD COLUMN storage varchar(16) NOT NULL DEFAULT '' COMMENT '存储类型: local/qiniu'",
"ALTER TABLE yz_system_files ADD COLUMN object_key varchar(512) NOT NULL DEFAULT '' COMMENT '存储相对路径(不含域名)'",
}
for _, sql := range alters {
_, _ = Orm.Raw(sql).Exec()
}
// 索引:MD5 精确去重 + 归属列表过滤
// md5 在部分历史库中是 TEXT 类型,MySQL 不允许对 TEXT/BLOB 建整列索引,
// 故使用前缀长度 md5(32)(MD5 十六进制串固定 32 字符)。
indexes := []string{
"ALTER TABLE yz_system_files ADD KEY idx_file_dedup (source, scope, tid, tuid, md5(32))",
"ALTER TABLE yz_system_files ADD KEY idx_file_owner (source, tid, scope, tuid, delete_time)",
}
for _, sql := range indexes {
_, _ = Orm.Raw(sql).Exec()
}
})
}
+216
View File
@@ -0,0 +1,216 @@
package services
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
"server/models"
)
// FileUploadParams 创建文件记录所需参数
type FileUploadParams struct {
Source string // backend / platform
Scope string // tenant / user
Tid uint64
Tuid uint64 // 归属用户,0 表示租户共享
Uid uint64 // 上传者
Name string
Ext string
Cate uint64
Size uint64
Src string
ObjectKey string
Storage string
MD5 string
}
// FindDuplicate 按「来源端 + 归属范围 + 租户 + 归属用户 + MD5」精确查重。
//
// 规则:
// - 租户共享文件:同一 tid 内 MD5 相同才算重复(tuid 为 NULL)
// - 用户个人文件:同一 tid + 同一 tuid 内 MD5 相同才算重复
// - 因此「租户已有 a 文件」与「用户 c 上传同样文件」互不冲突,可并存
//
// 未命中时返回 (nil, nil),调用方按 err == nil && file == nil 处理即可。
func FindDuplicate(source, scope string, tid, tuid uint64, md5Str string) (*models.SystemFile, error) {
if md5Str == "" {
return nil, nil
}
models.EnsureSystemFileStorageColumns()
qs := models.Orm.QueryTable(new(models.SystemFile)).
Filter("source", source).
Filter("scope", scope).
Filter("tid", tid).
Filter("md5", md5Str).
Filter("delete_time__isnull", true)
if scope == ScopeUser {
qs = qs.Filter("tuid", tuid)
} else {
qs = qs.Filter("tuid__isnull", true)
}
var f models.SystemFile
if err := qs.OrderBy("-id").One(&f); err != nil {
return nil, nil
}
return &f, nil
}
// CreateFileRecord 写入文件记录(同步写入归属与存储字段)
func CreateFileRecord(p FileUploadParams) (uint64, error) {
models.EnsureSystemFileStorageColumns()
uid := p.Uid
row := &models.SystemFile{
Tid: p.Tid,
Uid: &uid,
Name: p.Name,
Type: DetectFileType(p.Ext),
Cate: p.Cate,
Size: p.Size,
Src: p.Src,
Uploader: p.Uid,
Md5: p.MD5,
Source: p.Source,
Scope: p.Scope,
Storage: p.Storage,
ObjectKey: p.ObjectKey,
}
if p.Tuid > 0 {
tuid := p.Tuid
row.Tuid = &tuid
}
if row.Source == "" {
row.Source = SourceBackend
}
if row.Scope == "" {
row.Scope = ScopeTenant
}
id, err := models.Orm.Insert(row)
if err != nil {
return 0, err
}
return uint64(id), nil
}
// RemovePhysical 删除物理文件。优先用 object_key,老数据则从 src 反推。
func RemovePhysical(svc StorageService, objectKey, src string) error {
if svc == nil {
return fmt.Errorf("存储服务未初始化")
}
key := strings.TrimSpace(objectKey)
if key == "" {
key = KeyFromSrc(src, svc)
}
if key == "" {
return fmt.Errorf("无法解析文件路径: %s", src)
}
return svc.Delete(key)
}
// KeyFromSrc 从访问 URL/相对路径中解析出存储 key(object_key)
func KeyFromSrc(src string, svc StorageService) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
switch s := svc.(type) {
case *LocalStorage:
base := filepath.ToSlash(s.BaseDir)
rel := src
if i := strings.Index(rel, base+"/"); i >= 0 {
rel = rel[i+len(base)+1:]
}
rel = strings.TrimPrefix(rel, "/")
// 去掉可能的查询串
if i := strings.IndexAny(rel, "?#"); i >= 0 {
rel = rel[:i]
}
return rel
case *QiniuStorage:
domain := strings.TrimRight(s.Domain, "/")
rel := src
if domain != "" && strings.HasPrefix(rel, domain) {
rel = strings.TrimPrefix(strings.TrimPrefix(rel, domain), "/")
} else if u, err := url.Parse(rel); err == nil && u.Host != "" {
rel = strings.TrimPrefix(u.Path, "/")
}
rel = strings.TrimPrefix(rel, "/")
if i := strings.IndexAny(rel, "?#"); i >= 0 {
rel = rel[:i]
}
return rel
}
return ""
}
// 文件类型与扩展名白名单(两端共用同一套规则)
var fileTypeByCategory = map[string]uint8{
"image": 1,
"document": 2,
"video": 3,
"audio": 4,
"appsupgrade": 2,
}
var allowedExtByCategory = map[string][]string{
"image": {"jpg", "jpeg", "png", "gif", "bmp", "webp"},
"document": {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt"},
"video": {"mp4", "webm", "mov"},
"audio": {"mp3", "wav", "ogg"},
"appsupgrade": {"zip", "exe", "dmg", "msi", "msix", "apk", "deb", "rpm", "7z", "tar", "gz", "pkg"},
}
// FileExt 取小写扩展名(不含点)
func FileExt(name string) string {
name = strings.TrimSpace(name)
if i := strings.LastIndex(name, "."); i >= 0 && i < len(name)-1 {
return strings.ToLower(name[i+1:])
}
return ""
}
// DetectFileType 根据扩展名推断文件类型:1图片 2文档 3视频 4音频,未匹配返回 2(文档/其他)
func DetectFileType(ext string) uint8 {
ext = strings.ToLower(strings.TrimPrefix(ext, "."))
for cat, exts := range allowedExtByCategory {
for _, e := range exts {
if e == ext {
if t, ok := fileTypeByCategory[cat]; ok {
return t
}
return 2
}
}
}
return 2
}
// SoftDeleteFiles 软删除(标记 delete_time)
func SoftDeleteFiles(ids []uint64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
models.EnsureSystemFileStorageColumns()
return models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", ids).
Filter("delete_time__isnull", true).
Update(map[string]interface{}{"delete_time": time.Now()})
}
// DeleteFilesPermanently 彻底删除数据库记录(物理文件由调用方先删)
func DeleteFilesPermanently(ids []uint64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
models.EnsureSystemFileStorageColumns()
return models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", ids).
Delete()
}
+92 -61
View File
@@ -5,7 +5,6 @@ import (
"mime/multipart" "mime/multipart"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"server/models" "server/models"
@@ -13,12 +12,13 @@ import (
// MigrationProgress 迁移进度 // MigrationProgress 迁移进度
type MigrationProgress struct { type MigrationProgress struct {
Total int Total int
Success int Success int
Failed int Failed int
Current string Skipped int
Errors []string Current string
mu sync.Mutex Errors []string
mu sync.Mutex
} }
// AddSuccess 增加成功计数 // AddSuccess 增加成功计数
@@ -28,6 +28,13 @@ func (p *MigrationProgress) AddSuccess() {
p.Success++ p.Success++
} }
// AddSkipped 增加跳过计数
func (p *MigrationProgress) AddSkipped() {
p.mu.Lock()
defer p.mu.Unlock()
p.Skipped++
}
// AddFailed 增加失败计数 // AddFailed 增加失败计数
func (p *MigrationProgress) AddFailed(err string) { func (p *MigrationProgress) AddFailed(err string) {
p.mu.Lock() p.mu.Lock()
@@ -68,75 +75,109 @@ func NewStorageMigration(from, to StorageService) *StorageMigration {
} }
} }
// MigrateFile 迁移单个文件 // TargetKey 计算某条文件记录改造后的新 key
func TargetKey(f *models.SystemFile) string {
source := f.Source
if source == "" {
source = SourceBackend
}
var tuid uint64
if f.Tuid != nil {
tuid = *f.Tuid
}
return BuildObjectKey(UploadContext{Source: source, Tid: f.Tid, Tuid: tuid}, FileExt(f.Name))
}
// MigrateFile 迁移单个文件到新的分层目录
//
// 同类型存储(七牛→七牛 / 本地→本地)走服务端改名,不重新上传、不消耗流量;
// 本地→七牛 走"读取 + 上传",其余跨存储方向暂不支持。
func (m *StorageMigration) MigrateFile(file *models.SystemFile) error { func (m *StorageMigration) MigrateFile(file *models.SystemFile) error {
m.progress.SetCurrent(file.Name) m.progress.SetCurrent(file.Name)
// 如果是本地存储,从本地读取文件 oldKey := file.ObjectKey
if localFrom, ok := m.fromService.(*LocalStorage); ok { if oldKey == "" {
// 从本地文件系统读取 oldKey = KeyFromSrc(file.Src, m.fromService)
localPath := strings.TrimPrefix(file.Src, "/") }
filePath := filepath.Join(localFrom.BaseDir, localPath) if oldKey == "" {
return fmt.Errorf("无法解析原存储路径: %s", file.Src)
}
f, err := os.Open(filePath) newKey := TargetKey(file)
if oldKey == newKey {
m.progress.AddSkipped()
return nil
}
if m.fromService.Type() == m.toService.Type() {
if err := m.toService.Move(oldKey, newKey); err != nil {
return fmt.Errorf("移动文件失败: %w", err)
}
} else {
// 跨存储:仅支持 本地 → 七牛
localFrom, ok := m.fromService.(*LocalStorage)
if !ok {
return fmt.Errorf("暂不支持从 %s 迁出到 %s", m.fromService.Type(), m.toService.Type())
}
localPath := filepath.Join(localFrom.BaseDir, filepath.FromSlash(oldKey))
f, err := os.Open(localPath)
if err != nil { if err != nil {
return fmt.Errorf("打开本地文件失败: %w", err) return fmt.Errorf("打开本地文件失败: %w", err)
} }
defer f.Close() defer f.Close()
// 获取文件信息
stat, err := f.Stat() stat, err := f.Stat()
if err != nil { if err != nil {
return fmt.Errorf("获取文件信息失败: %w", err) return fmt.Errorf("获取文件信息失败: %w", err)
} }
header := &multipart.FileHeader{Filename: file.Name, Size: stat.Size()}
// 创建 multipart.FileHeader staged, err := m.toService.Stage(f, header)
header := &multipart.FileHeader{
Filename: file.Name,
Size: stat.Size(),
}
// 上传到目标存储
result, err := m.toService.Upload(f, header)
if err != nil { if err != nil {
return fmt.Errorf("上传到目标存储失败: %w", err) return err
} }
source := file.Source
// 更新数据库记录 if source == "" {
_, err = models.Orm.QueryTable(new(models.SystemFile)). source = SourceBackend
Filter("id", file.ID). }
Update(map[string]interface{}{ var tuid uint64
"src": result.URL, if file.Tuid != nil {
}) tuid = *file.Tuid
if err != nil { }
// 上传成功但更新数据库失败,尝试删除已上传的文件 if _, err := m.toService.Commit(staged, UploadContext{Source: source, Tid: file.Tid, Tuid: tuid}); err != nil {
_ = m.toService.Delete(result.Key) _ = m.toService.Discard(staged)
return fmt.Errorf("更新数据库失败: %w", err) return err
} }
m.progress.AddSuccess()
return nil
} }
// 如果是七牛云存储,需要先下载再上传(这里简化处理) newSrc := m.toService.GetPublicURL(newKey)
return fmt.Errorf("暂不支持从七牛云迁移到本地") if _, err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", file.ID).
Update(map[string]interface{}{
"src": newSrc,
"object_key": newKey,
"storage": m.toService.Type(),
}); err != nil {
return fmt.Errorf("更新数据库失败: %w", err)
}
m.progress.AddSuccess()
return nil
} }
// MigrateAll 迁移所有文件 // MigrateAll 迁移所有文件
func (m *StorageMigration) MigrateAll(tid uint64) error { func (m *StorageMigration) MigrateAll(tid uint64) error {
// 获取所有文件
var files []models.SystemFile var files []models.SystemFile
_, err := models.Orm.QueryTable(new(models.SystemFile)). qs := models.Orm.QueryTable(new(models.SystemFile)).Filter("delete_time__isnull", true)
Filter("tid", tid). if tid > 0 {
Filter("delete_time__isnull", true). qs = qs.Filter("tid", tid)
All(&files) }
if err != nil { if _, err := qs.All(&files); err != nil {
return fmt.Errorf("获取文件列表失败: %w", err) return fmt.Errorf("获取文件列表失败: %w", err)
} }
m.progress.Total = len(files) m.progress.Total = len(files)
// 并发迁移(限制并发数)
concurrency := 5 concurrency := 5
sem := make(chan struct{}, concurrency) sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -145,8 +186,8 @@ func (m *StorageMigration) MigrateAll(tid uint64) error {
wg.Add(1) wg.Add(1)
go func(file *models.SystemFile) { go func(file *models.SystemFile) {
defer wg.Done() defer wg.Done()
sem <- struct{}{} // 获取信号量 sem <- struct{}{}
defer func() { <-sem }() // 释放信号量 defer func() { <-sem }()
if err := m.MigrateFile(file); err != nil { if err := m.MigrateFile(file); err != nil {
m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err)) m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err))
@@ -165,27 +206,17 @@ func (m *StorageMigration) GetProgress() *MigrationProgress {
// MigrateLocalToQiniu 从本地存储迁移到七牛云 // MigrateLocalToQiniu 从本地存储迁移到七牛云
func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) { func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) {
// 获取存储配置
cfg, err := models.GetStorageConfig() cfg, err := models.GetStorageConfig()
if err != nil { if err != nil {
return nil, fmt.Errorf("获取存储配置失败: %w", err) return nil, fmt.Errorf("获取存储配置失败: %w", err)
} }
if cfg.StorageType != StorageTypeQiniu {
if cfg.StorageType != "qiniu" {
return nil, fmt.Errorf("当前存储类型不是七牛云") return nil, fmt.Errorf("当前存储类型不是七牛云")
} }
// 创建存储服务 migration := NewStorageMigration(NewLocalStorage(), NewQiniuStorage(cfg))
localStorage := NewLocalStorage()
qiniuStorage := NewQiniuStorage(cfg)
// 创建迁移服务
migration := NewStorageMigration(localStorage, qiniuStorage)
// 执行迁移
if err := migration.MigrateAll(tid); err != nil { if err := migration.MigrateAll(tid); err != nil {
return migration.GetProgress(), err return migration.GetProgress(), err
} }
return migration.GetProgress(), nil return migration.GetProgress(), nil
} }
+285 -97
View File
@@ -3,11 +3,13 @@ package services
import ( import (
"context" "context"
"crypto/md5" "crypto/md5"
"crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -18,22 +20,116 @@ import (
"github.com/qiniu/go-sdk/v7/storage" "github.com/qiniu/go-sdk/v7/storage"
) )
// StorageService 存储服务接口 // 来源端
type StorageService interface { const (
Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) SourceBackend = "backend" // 租户后台端
GetPublicURL(key string) string SourcePlatform = "platform" // 平台端
Delete(key string) error )
// 归属范围
const (
ScopeTenant = "tenant" // 租户共享文件
ScopeUser = "user" // 用户个人文件
)
// 存储类型
const (
StorageTypeLocal = "local"
StorageTypeQiniu = "qiniu"
)
// UploadContext 上传上下文,决定文件最终的存储路径
type UploadContext struct {
Source string // backend / platform
Tid uint64 // 租户 ID(platform 端可为 0)
Tuid uint64 // 归属用户 ID,0 表示租户共享文件
}
// Scope 返回归属范围:带了归属用户即个人文件,否则为租户共享文件。
// 平台端不做用户分层,统一按租户共享处理。
func (c UploadContext) Scope() string {
if c.Source == SourcePlatform {
return ScopeTenant
}
if c.Tuid > 0 {
return ScopeUser
}
return ScopeTenant
}
// BuildObjectKey 生成存储相对路径(不含域名、不含本地 BaseDir)
//
// backend 共享: backend/234573/2026/09/09/xxx.png
// backend 个人: backend/234573/67091493/2026/09/09/xxx.png
// platform : platform/2026/09/09/xxx.png
func BuildObjectKey(ctx UploadContext, ext string) string {
datePath := time.Now().Format("2006/01/02")
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomHex(6), normalizeExt(ext))
if ctx.Source == SourcePlatform {
return path.Join(SourcePlatform, datePath, name)
}
if ctx.Tuid > 0 {
return path.Join(SourceBackend, fmt.Sprint(ctx.Tid), fmt.Sprint(ctx.Tuid), datePath, name)
}
return path.Join(SourceBackend, fmt.Sprint(ctx.Tid), datePath, name)
}
// normalizeExt 规范扩展名:小写、补前导点
func normalizeExt(ext string) string {
ext = strings.TrimSpace(ext)
if ext == "" {
return ""
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return strings.ToLower(ext)
}
// randomHex 生成 n 位十六进制随机串(使用 crypto/rand,无需种子)
func randomHex(n int) string {
b := make([]byte, (n+1)/2)
if _, err := rand.Read(b); err != nil {
return fmt.Sprint(time.Now().UnixNano() % 1000000)
}
return hex.EncodeToString(b)[:n]
}
// StagedFile 已落临时文件、算完 MD5 的待提交文件
type StagedFile struct {
TempPath string // 本地临时文件路径
MD5 string // 文件内容 MD5
Size int64 // 文件大小
MimeType string // 文件类型
Ext string // 扩展名(含点)
} }
// UploadResult 上传结果 // UploadResult 上传结果
type UploadResult struct { type UploadResult struct {
URL string // 完整访问URL URL string // 完整访问URL
Key string // 存储key/路径 Key string // 存储key/相对路径(object_key)
Size int64 // 文件大小 Size int64 // 文件大小
MD5 string // 文件MD5 MD5 string // 文件MD5
MimeType string // 文件类型 MimeType string // 文件类型
} }
// StorageService 存储服务接口
type StorageService interface {
// Stage 把上传流写入临时文件并计算 MD5,此时文件尚未进入正式存储目录
Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error)
// Commit 把临时文件提交到按 ctx 计算出的正式路径;返回访问 URL 与 object_key
Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error)
// Discard 丢弃临时文件(去重命中或出错时调用)
Discard(staged *StagedFile) error
GetPublicURL(key string) string
Delete(key string) error
// Move 把已存文件从 oldKey 改名到 newKey(迁移用:本地 Rename / 七牛 Move)
Move(oldKey, newKey string) error
// Type 返回存储类型 local / qiniu
Type() string
}
// LocalStorage 本地存储实现 // LocalStorage 本地存储实现
type LocalStorage struct { type LocalStorage struct {
BaseDir string // 基础目录,默认 "uploads" BaseDir string // 基础目录,默认 "uploads"
@@ -48,51 +144,83 @@ func NewLocalStorage() *LocalStorage {
} }
} }
// Upload 上传文件到本地 // Type 存储类型
func (s *LocalStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) { func (s *LocalStorage) Type() string { return StorageTypeLocal }
// 生成存储路径
ext := filepath.Ext(header.Filename)
datePath := time.Now().Format("2006/01/02")
fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
savePath := filepath.Join(datePath, fileName)
// 创建目录 // tempDir 临时目录:与正式目录同盘,保证 Commit 时 os.Rename 不跨设备
destDir := filepath.Join(s.BaseDir, filepath.FromSlash(datePath)) func (s *LocalStorage) tempDir() string {
if err := os.MkdirAll(destDir, 0755); err != nil { return filepath.Join(s.BaseDir, ".tmp")
}
// Stage 写入临时文件并计算 MD5
func (s *LocalStorage) Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error) {
dir := s.tempDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建临时目录失败: %w", err)
}
tmp, err := os.CreateTemp(dir, "stage_*")
if err != nil {
return nil, fmt.Errorf("创建临时文件失败: %w", err)
}
defer tmp.Close()
hash := md5.New()
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
if err != nil {
tmpPath := tmp.Name()
_ = os.Remove(tmpPath)
return nil, fmt.Errorf("读取上传文件失败: %w", err)
}
return &StagedFile{
TempPath: tmp.Name(),
MD5: hex.EncodeToString(hash.Sum(nil)),
Size: size,
MimeType: mimeTypeOf(header),
Ext: normalizeExt(filepath.Ext(header.Filename)),
}, nil
}
// Commit 提交到正式目录
func (s *LocalStorage) Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error) {
if staged == nil {
return nil, fmt.Errorf("待提交文件为空")
}
key := BuildObjectKey(ctx, staged.Ext)
destPath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return nil, fmt.Errorf("创建目录失败: %w", err) return nil, fmt.Errorf("创建目录失败: %w", err)
} }
// 保存文件 // 同盘优先 Rename;跨设备失败则回退复制
destPath := filepath.Join(s.BaseDir, filepath.FromSlash(savePath)) if err := os.Rename(staged.TempPath, destPath); err != nil {
dst, err := os.Create(destPath) if copyErr := copyFile(staged.TempPath, destPath); copyErr != nil {
if err != nil { return nil, fmt.Errorf("保存文件失败: %w", copyErr)
return nil, fmt.Errorf("创建文件失败: %w", err) }
_ = os.Remove(staged.TempPath)
} }
defer dst.Close()
// 计算MD5并复制文件
hash := md5.New()
size, err := io.Copy(io.MultiWriter(dst, hash), file)
if err != nil {
_ = os.Remove(destPath)
return nil, fmt.Errorf("保存文件失败: %w", err)
}
md5Sum := hex.EncodeToString(hash.Sum(nil))
webURL := s.BaseURL + strings.ReplaceAll(filepath.ToSlash(destPath), "\\", "/")
return &UploadResult{ return &UploadResult{
URL: webURL, URL: s.GetPublicURL(key),
Key: savePath, Key: key,
Size: size, Size: staged.Size,
MD5: md5Sum, MD5: staged.MD5,
MimeType: header.Header.Get("Content-Type"), MimeType: staged.MimeType,
}, nil }, nil
} }
// Discard 删除临时文件
func (s *LocalStorage) Discard(staged *StagedFile) error {
if staged == nil || staged.TempPath == "" {
return nil
}
return os.Remove(staged.TempPath)
}
// GetPublicURL 获取公开访问URL // GetPublicURL 获取公开访问URL
func (s *LocalStorage) GetPublicURL(key string) string { func (s *LocalStorage) GetPublicURL(key string) string {
return s.BaseURL + filepath.ToSlash(filepath.Join(s.BaseDir, key)) full := filepath.ToSlash(filepath.Join(s.BaseDir, filepath.FromSlash(key)))
return s.BaseURL + strings.ReplaceAll(full, "\\", "/")
} }
// Delete 删除本地文件 // Delete 删除本地文件
@@ -101,6 +229,23 @@ func (s *LocalStorage) Delete(key string) error {
return os.Remove(filePath) return os.Remove(filePath)
} }
// Move 本地改名(同盘,不搬数据)
func (s *LocalStorage) Move(oldKey, newKey string) error {
oldPath := filepath.Join(s.BaseDir, filepath.FromSlash(oldKey))
newPath := filepath.Join(s.BaseDir, filepath.FromSlash(newKey))
if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
return fmt.Errorf("创建目标目录失败: %w", err)
}
if err := os.Rename(oldPath, newPath); err != nil {
// 跨设备时回退为复制 + 删除
if copyErr := copyFile(oldPath, newPath); copyErr != nil {
return fmt.Errorf("移动文件失败: %w", copyErr)
}
_ = os.Remove(oldPath)
}
return nil
}
// QiniuStorage 七牛云存储实现 // QiniuStorage 七牛云存储实现
type QiniuStorage struct { type QiniuStorage struct {
AccessKey string AccessKey string
@@ -121,6 +266,9 @@ func NewQiniuStorage(cfg *models.StorageConfig) *QiniuStorage {
} }
} }
// Type 存储类型
func (s *QiniuStorage) Type() string { return StorageTypeQiniu }
// getZone 根据区域代码获取存储区域 // getZone 根据区域代码获取存储区域
func (s *QiniuStorage) getZone() *storage.Region { func (s *QiniuStorage) getZone() *storage.Region {
switch s.Region { switch s.Region {
@@ -141,72 +289,77 @@ func (s *QiniuStorage) getZone() *storage.Region {
} }
} }
// Upload 上传文件到七牛云 // Stage 写入临时文件并计算 MD5(七牛直传拿不到内容,这里统一先落临时文件算 MD5,
func (s *QiniuStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) { // 便于去重;未命中才会真正上传到七牛)
// 生成存储key func (s *QiniuStorage) Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error) {
ext := filepath.Ext(header.Filename) tmp, err := os.CreateTemp("", "yz_upload_*")
datePath := time.Now().Format("2006/01/02") if err != nil {
fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) return nil, fmt.Errorf("创建临时文件失败: %w", err)
key := filepath.ToSlash(filepath.Join(datePath, fileName))
// 创建上传凭证
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
putPolicy := storage.PutPolicy{
Scope: s.Bucket,
} }
defer tmp.Close()
hash := md5.New()
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
if err != nil {
_ = os.Remove(tmp.Name())
return nil, fmt.Errorf("读取上传文件失败: %w", err)
}
return &StagedFile{
TempPath: tmp.Name(),
MD5: hex.EncodeToString(hash.Sum(nil)),
Size: size,
MimeType: mimeTypeOf(header),
Ext: normalizeExt(filepath.Ext(header.Filename)),
}, nil
}
// Commit 上传到七牛云
func (s *QiniuStorage) Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error) {
if staged == nil {
return nil, fmt.Errorf("待提交文件为空")
}
key := BuildObjectKey(ctx, staged.Ext)
f, err := os.Open(staged.TempPath)
if err != nil {
return nil, fmt.Errorf("打开临时文件失败: %w", err)
}
defer f.Close()
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
putPolicy := storage.PutPolicy{Scope: s.Bucket}
upToken := putPolicy.UploadToken(mac) upToken := putPolicy.UploadToken(mac)
// 配置上传参数
cfg := storage.Config{ cfg := storage.Config{
Region: s.getZone(), Region: s.getZone(),
UseHTTPS: true, UseHTTPS: true,
UseCdnDomains: false, UseCdnDomains: false,
} }
// 创建表单上传器
formUploader := storage.NewFormUploader(&cfg) formUploader := storage.NewFormUploader(&cfg)
ret := storage.PutRet{} ret := storage.PutRet{}
putExtra := storage.PutExtra{}
// 计算文件大小和MD5 if err := formUploader.Put(context.Background(), &ret, upToken, key, f, staged.Size, &storage.PutExtra{}); err != nil {
tmpFile, err := os.CreateTemp("", "qiniu_upload_*")
if err != nil {
return nil, fmt.Errorf("创建临时文件失败: %w", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
hash := md5.New()
size, err := io.Copy(io.MultiWriter(tmpFile, hash), file)
if err != nil {
return nil, fmt.Errorf("读取文件失败: %w", err)
}
md5Sum := hex.EncodeToString(hash.Sum(nil))
// 重置文件指针
if _, err := tmpFile.Seek(0, 0); err != nil {
return nil, fmt.Errorf("重置文件指针失败: %w", err)
}
// 执行上传
err = formUploader.Put(context.Background(), &ret, upToken, key, tmpFile, size, &putExtra)
if err != nil {
return nil, fmt.Errorf("上传到七牛云失败: %w", err) return nil, fmt.Errorf("上传到七牛云失败: %w", err)
} }
// 构建完整URL
domain := strings.TrimRight(s.Domain, "/")
url := fmt.Sprintf("%s/%s", domain, ret.Key)
return &UploadResult{ return &UploadResult{
URL: url, URL: s.GetPublicURL(key),
Key: ret.Key, Key: key,
Size: size, Size: staged.Size,
MD5: md5Sum, MD5: staged.MD5,
MimeType: header.Header.Get("Content-Type"), MimeType: staged.MimeType,
}, nil }, nil
} }
// Discard 删除临时文件
func (s *QiniuStorage) Discard(staged *StagedFile) error {
if staged == nil || staged.TempPath == "" {
return nil
}
return os.Remove(staged.TempPath)
}
// GetPublicURL 获取七牛云公开访问URL // GetPublicURL 获取七牛云公开访问URL
func (s *QiniuStorage) GetPublicURL(key string) string { func (s *QiniuStorage) GetPublicURL(key string) string {
domain := strings.TrimRight(s.Domain, "/") domain := strings.TrimRight(s.Domain, "/")
@@ -215,16 +368,25 @@ func (s *QiniuStorage) GetPublicURL(key string) string {
// Delete 删除七牛云文件 // Delete 删除七牛云文件
func (s *QiniuStorage) Delete(key string) error { func (s *QiniuStorage) Delete(key string) error {
mac := qbox.NewMac(s.AccessKey, s.SecretKey) return s.runBucketManager(func(bm *storage.BucketManager) error {
cfg := storage.Config{ return bm.Delete(s.Bucket, key)
Region: s.getZone(), }, "删除七牛云文件失败")
UseHTTPS: true, }
}
bucketManager := storage.NewBucketManager(mac, &cfg) // Move 七牛服务端改名(同 bucket 内原子操作,不走流量)
err := bucketManager.Delete(s.Bucket, key) func (s *QiniuStorage) Move(oldKey, newKey string) error {
if err != nil { return s.runBucketManager(func(bm *storage.BucketManager) error {
return fmt.Errorf("删除七牛云文件失败: %w", err) // force=false:目标 key 已存在时直接报错,避免覆盖,保证迁移可重复执行
return bm.Move(s.Bucket, oldKey, s.Bucket, newKey, false)
}, "移动七牛云文件失败")
}
func (s *QiniuStorage) runBucketManager(fn func(*storage.BucketManager) error, errMsg string) error {
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
cfg := storage.Config{Region: s.getZone(), UseHTTPS: true}
bm := storage.NewBucketManager(mac, &cfg)
if err := fn(bm); err != nil {
return fmt.Errorf("%s: %w", errMsg, err)
} }
return nil return nil
} }
@@ -240,7 +402,7 @@ func GetStorageService() (StorageService, error) {
switch cfg.StorageType { switch cfg.StorageType {
case "qiniu": case "qiniu":
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" || if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" ||
cfg.QiniuBucket == "" || cfg.QiniuDomain == "" { cfg.QiniuBucket == "" || cfg.QiniuDomain == "" {
return nil, fmt.Errorf("七牛云配置不完整") return nil, fmt.Errorf("七牛云配置不完整")
} }
return NewQiniuStorage(cfg), nil return NewQiniuStorage(cfg), nil
@@ -250,3 +412,29 @@ func GetStorageService() (StorageService, error) {
return NewLocalStorage(), nil return NewLocalStorage(), nil
} }
} }
// mimeTypeOf 从 multipart header 取 Content-Type
func mimeTypeOf(header *multipart.FileHeader) string {
if header == nil {
return ""
}
return header.Header.Get("Content-Type")
}
// copyFile 文件复制(Rename 跨设备失败时的回退方案)
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
@@ -213,7 +213,7 @@ watch(
); );
// 上传配置 // 上传配置
const uploadUrl = import.meta.env.VITE_API_BASE_URL + "/platform/upload"; const uploadUrl = import.meta.env.VITE_API_BASE_URL + "/platform/uploadfile";
const uploadHeaders = { const uploadHeaders = {
Authorization: "Bearer " + localStorage.getItem("token"), Authorization: "Bearer " + localStorage.getItem("token"),
}; };
@@ -24,7 +24,8 @@
<el-form-item label="资质图片" prop="file_url"> <el-form-item label="资质图片" prop="file_url">
<el-upload <el-upload
class="avatar-uploader" class="avatar-uploader"
action="/api/platform/common/upload" :action="uploadAction"
:headers="uploadHeaders"
:show-file-list="false" :show-file-list="false"
:on-success="handleUploadSuccess" :on-success="handleUploadSuccess"
:before-upload="beforeAvatarUpload" :before-upload="beforeAvatarUpload"
@@ -71,6 +72,12 @@
const tenantName = ref(''); const tenantName = ref('');
const formRef = ref(); const formRef = ref();
// 上传地址:走平台端通用上传接口(原 /api/platform/common/upload 后端不存在,会 404)
const uploadAction = import.meta.env.VITE_API_BASE_URL + '/platform/uploadfile';
const uploadHeaders = {
Authorization: 'Bearer ' + (localStorage.getItem('token') || '')
};
const formData = reactive({ const formData = reactive({
tid: null, tid: null,
type: '', type: '',