diff --git a/backend/src/api/file.js b/backend/src/api/file.js index afa984f..a29af0d 100644 --- a/backend/src/api/file.js +++ b/backend/src/api/file.js @@ -106,10 +106,27 @@ export function getFileById(id) { */ export function uploadFile(formData, options = {}) { // 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)); } + // 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 = { url: "/backend/uploadfile", method: "post", diff --git a/backend/src/views/apps/cms/banner/components/edit.vue b/backend/src/views/apps/cms/banner/components/edit.vue index 1748a7f..2abd5c0 100644 --- a/backend/src/views/apps/cms/banner/components/edit.vue +++ b/backend/src/views/apps/cms/banner/components/edit.vue @@ -151,7 +151,7 @@ const currentBanner = ref>({ // 上传配置 // @ts-ignore 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({ Authorization: "Bearer " + (localStorage.getItem("token") || ""), }); diff --git a/backend/src/views/apps/cms/friendlink/components/edit.vue b/backend/src/views/apps/cms/friendlink/components/edit.vue index 8631ce8..8f4d7a4 100644 --- a/backend/src/views/apps/cms/friendlink/components/edit.vue +++ b/backend/src/views/apps/cms/friendlink/components/edit.vue @@ -117,7 +117,7 @@ const submitLoading = ref(false) const formRef = ref(null) // 上传配置 -const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles"); +const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile"); const uploadHeaders = ref({ Authorization: "Bearer " + (localStorage.getItem("token") || ""), }); diff --git a/backend/src/views/apps/cms/frontMenu/components/edit.vue b/backend/src/views/apps/cms/frontMenu/components/edit.vue index 432fa35..9834a81 100644 --- a/backend/src/views/apps/cms/frontMenu/components/edit.vue +++ b/backend/src/views/apps/cms/frontMenu/components/edit.vue @@ -222,7 +222,7 @@ const currentMenu = ref>({ // 上传配置 // @ts-ignore 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({ 'Authorization': 'Bearer ' + (localStorage.getItem('token') || '') }); diff --git a/backend/src/views/apps/cms/product/index/components/edit.vue b/backend/src/views/apps/cms/product/index/components/edit.vue index 7e33b81..4cd0d3f 100644 --- a/backend/src/views/apps/cms/product/index/components/edit.vue +++ b/backend/src/views/apps/cms/product/index/components/edit.vue @@ -126,7 +126,7 @@ const submitLoading = ref(false); const formRef = ref(null); // 上传配置 -const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles"); +const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile"); const uploadHeaders = ref({ Authorization: "Bearer " + (localStorage.getItem("token") || ""), }); diff --git a/backend/src/views/apps/cms/solution/index/components/edit.vue b/backend/src/views/apps/cms/solution/index/components/edit.vue index 4e427be..0171f99 100644 --- a/backend/src/views/apps/cms/solution/index/components/edit.vue +++ b/backend/src/views/apps/cms/solution/index/components/edit.vue @@ -127,7 +127,7 @@ const submitLoading = ref(false); const formRef = ref(null); // 上传配置 -const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles"); +const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile"); const uploadHeaders = ref({ Authorization: "Bearer " + (localStorage.getItem("token") || ""), }); diff --git a/backend/src/views/apps/oa/reimburse/components/detail.vue b/backend/src/views/apps/oa/reimburse/components/detail.vue index 3a69a87..264e7a4 100644 --- a/backend/src/views/apps/oa/reimburse/components/detail.vue +++ b/backend/src/views/apps/oa/reimburse/components/detail.vue @@ -748,7 +748,8 @@ const uploadFileToSystem = async rawFile => { const formData = new FormData() formData.append('file', rawFile) 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) { throw new Error(uploadResponse?.msg || `「${rawFile.name}」上传失败`) } diff --git a/backend/src/views/apps/oa/schedule/components/detail.vue b/backend/src/views/apps/oa/schedule/components/detail.vue index 1c52a27..62fdb7d 100644 --- a/backend/src/views/apps/oa/schedule/components/detail.vue +++ b/backend/src/views/apps/oa/schedule/components/detail.vue @@ -300,7 +300,8 @@ function ensureImageName(file) { async function uploadImage(file) { const formData = new FormData(); 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) { throw new Error(res?.msg || `「${file.name}」上传失败`); } diff --git a/backend/src/views/basicSettings/tenants/components/qualification.vue b/backend/src/views/basicSettings/tenants/components/qualification.vue index 65dc94d..20613e0 100644 --- a/backend/src/views/basicSettings/tenants/components/qualification.vue +++ b/backend/src/views/basicSettings/tenants/components/qualification.vue @@ -24,7 +24,8 @@ 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" +} diff --git a/go/controllers/backend_file.go b/go/controllers/backend_file.go index 2d54c34..816e58c 100644 --- a/go/controllers/backend_file.go +++ b/go/controllers/backend_file.go @@ -1,907 +1,970 @@ -package controllers - -import ( - "crypto/md5" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "strconv" - "strings" - "time" - - "server/models" - "server/pkg/jwtutil" - "server/services" - - beego "github.com/beego/beego/v2/server/web" -) - -// BackendFileController 平台端文件管理(yz_system_files / yz_system_files_category) -type BackendFileController struct { - beego.Controller -} - -const fileUploadMaxMB = 2048 // 2GB,适用于大型软件安装包 -const fileUploadMaxBytes = fileUploadMaxMB * 1024 * 1024 - -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"}, - // 安装包 / 软件升级(上传时 cate 选 appsupgrade 分类即可,扩展名在此放行) - "appsupgrade": {"zip", "exe", "dmg", "msi", "msix", "apk", "deb", "rpm", "7z", "tar", "gz", "pkg"}, -} - -func (c *BackendFileController) backendClaims() (*jwtutil.Claims, error) { - auth := c.Ctx.Request.Header.Get("Authorization") - if auth == "" { - return nil, fmt.Errorf("未登录") - } - parts := strings.SplitN(auth, " ", 2) - if len(parts) != 2 || parts[0] != "Bearer" { - return nil, fmt.Errorf("认证信息格式错误") - } - claims, err := jwtutil.ParseToken(parts[1]) - if err != nil { - return nil, fmt.Errorf("无效的token") - } - if claims.UserType != "backend" { - return nil, fmt.Errorf("无权访问") - } - return claims, nil -} - -func (c *BackendFileController) effectiveTid(claims *jwtutil.Claims) uint64 { - _ = c.ParseForm(1 << 20) - if tid, err := c.GetUint64("tid"); err == nil && tid > 0 { - return tid - } - if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-Id")); h != "" { - if v, e := strconv.ParseUint(h, 10, 64); e == nil { - return v - } - } - if claims != nil && claims.TenantId > 0 { - return uint64(claims.TenantId) - } - return 0 -} - -func (c *BackendFileController) jsonErr(httpStatus, bizCode int, msg string) { - c.Ctx.Output.SetStatus(httpStatus) - c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} - _ = c.ServeJSON() -} - -func (c *BackendFileController) jsonOK(data interface{}) { - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} - _ = c.ServeJSON() -} - -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 -} - -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 "" -} - -func fileToMap(f *models.SystemFile) map[string]interface{} { - ct := f.CreateTime.Format("2006-01-02 15:04:05") - m := map[string]interface{}{ - "id": f.ID, - "tid": f.Tid, - "name": f.Name, - "type": f.Type, - "cate": f.Cate, - "size": f.Size, - "src": f.Src, - "uploader": f.Uploader, - "md5": f.Md5, - "create_time": ct, - "createTime": ct, - "groupId": f.Cate, - "url": f.Src, - } - if f.Uid != nil { - m["uid"] = *f.Uid - } - if f.Tuid != nil { - m["tuid"] = *f.Tuid - } - return m -} - -func removePhysicalBySrc(webSrc string) { - webSrc = strings.TrimSpace(webSrc) - if webSrc == "" { - return - } - webSrc = strings.TrimPrefix(webSrc, "/") - _ = os.Remove(webSrc) -} - -// GetAllFiles GET /backend/allfiles -func (c *BackendFileController) GetAllFiles() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - page, _ := c.GetInt("page", 1) - pageSize, _ := c.GetInt("pageSize", 10) - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 10 - } - cate, _ := c.GetUint64("cate") - keyword := strings.TrimSpace(c.GetString("keyword")) - - qs := models.Orm.QueryTable(new(models.SystemFile)). - Filter("tid", tid). - Filter("delete_time__isnull", true) - if cate > 0 { - qs = qs.Filter("cate", cate) - } - if keyword != "" { - qs = qs.Filter("name__icontains", keyword) - } - total, err := qs.Count() - if err != nil { - c.jsonErr(500, 500, "获取文件列表失败: "+err.Error()) - return - } - var rows []models.SystemFile - _, err = qs.OrderBy("-create_time").Limit(pageSize, (page-1)*pageSize).All(&rows) - if err != nil { - c.jsonErr(500, 500, "获取文件列表失败: "+err.Error()) - return - } - list := make([]map[string]interface{}, 0, len(rows)) - for i := range rows { - list = append(list, fileToMap(&rows[i])) - } - c.jsonOK(map[string]interface{}{ - "list": list, - "total": total, - "page": page, - "pageSize": pageSize, - }) -} - -// GetUserCate GET /backend/usercate -func (c *BackendFileController) GetUserCate() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - - var cates []models.SystemFilesCategory - _, err = models.Orm.QueryTable(new(models.SystemFilesCategory)). - Filter("tid", tid). - Filter("delete_time__isnull", true). - OrderBy("id"). - All(&cates) - if err != nil { - c.jsonErr(500, 500, "获取用户分类失败: "+err.Error()) - return - } - out := make([]map[string]interface{}, 0, len(cates)) - for i := range cates { - cnt, _ := models.Orm.QueryTable(new(models.SystemFile)). - Filter("tid", tid). - Filter("cate", cates[i].ID). - Filter("delete_time__isnull", true). - Count() - out = append(out, map[string]interface{}{ - "id": cates[i].ID, - "name": cates[i].Name, - "total": cnt, - }) - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out} - _ = c.ServeJSON() -} - -type createCateBody struct { - Name string `json:"name"` - Tuid *uint64 `json:"tuid"` -} - -// CreateFileCate POST /backend/createfilecate -func (c *BackendFileController) CreateFileCate() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body createCateBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - name := strings.TrimSpace(body.Name) - if name == "" { - c.jsonErr(400, 400, "分组名称不能为空") - return - } - uid := uint64(claims.UserID) - row := &models.SystemFilesCategory{ - Tid: tid, - Name: name, - Uid: &uid, - Tuid: body.Tuid, - } - id, err := models.Orm.Insert(row) - if err != nil { - c.jsonErr(500, 500, "新建文件分组失败: "+err.Error()) - return - } - c.Data["json"] = map[string]interface{}{ - "code": 200, - "msg": "新建文件分组成功", - "data": map[string]interface{}{"id": uint64(id)}, - } - _ = c.ServeJSON() -} - -type renameCateBody struct { - Name string `json:"name"` -} - -// RenameFileCate POST /backend/renamefilecate/:id -func (c *BackendFileController) RenameFileCate() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的分组ID") - return - } - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body renameCateBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - name := strings.TrimSpace(body.Name) - if name == "" { - c.jsonErr(400, 400, "分组名称不能为空") - return - } - n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(map[string]interface{}{"name": name}) - if err != nil { - c.jsonErr(500, 500, "重命名文件分组失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "分组不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "重命名文件分组成功"} - _ = c.ServeJSON() -} - -// DeleteFileCate DELETE /backend/deletefilecate/:id -func (c *BackendFileController) DeleteFileCate() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的分组ID") - return - } - cnt, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("cate", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Count() - if err != nil { - c.jsonErr(500, 500, "删除文件分组失败: "+err.Error()) - return - } - if cnt > 0 { - c.jsonErr(400, 400, fmt.Sprintf("该分组下还有 %d 个文件,请先删除分组内文件!", cnt)) - return - } - now := time.Now() - n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(map[string]interface{}{"delete_time": now}) - if err != nil { - c.jsonErr(500, 500, "删除文件分组失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "分组不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除文件分组成功"} - _ = c.ServeJSON() -} - -// GetCateFiles GET /backend/catefiles/:id -func (c *BackendFileController) GetCateFiles() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - cateID, err := strconv.ParseUint(idStr, 10, 64) - if err != nil { - c.jsonErr(400, 400, "无效的分类ID") - return - } - page, _ := c.GetInt("page", 1) - pageSize, _ := c.GetInt("pageSize", 24) - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 24 - } - keyword := strings.TrimSpace(c.GetString("keyword")) - - qs := models.Orm.QueryTable(new(models.SystemFile)). - Filter("tid", tid). - Filter("cate", cateID). - Filter("delete_time__isnull", true) - if keyword != "" { - qs = qs.Filter("name__icontains", keyword) - } - total, err := qs.Count() - if err != nil { - c.jsonErr(500, 500, "获取分类文件失败: "+err.Error()) - return - } - var rows []models.SystemFile - _, err = qs.OrderBy("-create_time").Limit(pageSize, (page-1)*pageSize).All(&rows) - if err != nil { - c.jsonErr(500, 500, "获取分类文件失败: "+err.Error()) - return - } - list := make([]map[string]interface{}, 0, len(rows)) - for i := range rows { - list = append(list, fileToMap(&rows[i])) - } - c.jsonOK(map[string]interface{}{ - "list": list, - "total": total, - "page": page, - "pageSize": pageSize, - "categoryId": cateID, - }) -} - -// GetFileByID GET /backend/file/:id -func (c *BackendFileController) GetFileByID() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的文件ID") - return - } - var f models.SystemFile - err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - One(&f) - if err != nil { - c.jsonErr(404, 404, "文件不存在") - return - } - c.jsonOK(fileToMap(&f)) -} - -// UploadFile POST /backend/uploadfile -func (c *BackendFileController) UploadFile() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - if err := c.Ctx.Request.ParseMultipartForm(fileUploadMaxBytes); err != nil { - c.jsonErr(400, 400, "解析上传失败: "+err.Error()) - return - } - fh, header, err := c.GetFile("file") - if err != nil || fh == nil { - c.jsonErr(400, 400, "请选择要上传的文件") - return - } - defer fh.Close() - - if header != nil && header.Size > fileUploadMaxBytes { - c.jsonErr(400, 400, fmt.Sprintf("文件大小不能超过%dMB", fileUploadMaxMB)) - return - } - - ext := fileExt(header.Filename) - if ext == "" { - c.jsonErr(400, 400, "无法识别文件扩展名") - return - } - - // 获取存储服务 - storageService, err := services.GetStorageService() - if err != nil { - c.jsonErr(500, 500, "获取存储服务失败: "+err.Error()) - return - } - - // 上传文件 - result, err := storageService.Upload(fh, header) - if err != nil { - c.jsonErr(500, 500, "上传文件失败: "+err.Error()) - return - } - - // 检查文件是否已存在(通过MD5) - var exist models.SystemFile - err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("md5", result.MD5). - Filter("tid", tid). - Filter("delete_time__isnull", true). - One(&exist) - if err == nil { - // 文件已存在,返回已有记录 - c.Data["json"] = map[string]interface{}{ - "code": 201, - "msg": "文件已存在", - "data": map[string]interface{}{ - "url": exist.Src, - "id": exist.ID, - "name": exist.Name, - }, - } - _ = c.ServeJSON() - return - } - - // 获取分类 - cateStr := c.GetString("cate") - var cate uint64 - if cateStr != "" { - cate, _ = strconv.ParseUint(cateStr, 10, 64) - } - - adminID := uint64(claims.UserID) - var tuidPtr *uint64 - if ts := strings.TrimSpace(c.GetString("tuid")); ts != "" { - if v, e := strconv.ParseUint(ts, 10, 64); e == nil { - tuidPtr = &v - } - } - - // 保存文件记录到数据库 - row := &models.SystemFile{ - Tid: tid, - Uid: &adminID, - Tuid: tuidPtr, - Name: header.Filename, - Type: detectFileType(ext), - Cate: cate, - Size: uint64(result.Size), - Src: result.URL, - Uploader: adminID, - Md5: result.MD5, - } - id, err := models.Orm.Insert(row) - if err != nil { - // 数据库插入失败,尝试删除已上传的文件 - _ = storageService.Delete(result.Key) - c.jsonErr(500, 500, "上传失败: "+err.Error()) - return - } - - c.Data["json"] = map[string]interface{}{ - "code": 200, - "msg": "上传成功", - "data": map[string]interface{}{ - "url": result.URL, - "id": uint64(id), - "name": header.Filename, - }, - } - _ = c.ServeJSON() -} - -func md5HashFile(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer f.Close() - h := md5.New() - if _, err := io.Copy(h, f); err != nil { - return "", err - } - return hex.EncodeToString(h.Sum(nil)), nil -} - -type updateFileBody struct { - Name *string `json:"name"` - Cate *uint64 `json:"cate"` -} - -// UpdateFile POST /backend/updatefile/:id -func (c *BackendFileController) UpdateFile() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的文件ID") - return - } - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body updateFileBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - up := map[string]interface{}{} - if body.Name != nil { - up["name"] = strings.TrimSpace(*body.Name) - } - if body.Cate != nil { - up["cate"] = *body.Cate - } - if len(up) == 0 { - c.jsonErr(400, 400, "无更新数据") - return - } - now := time.Now() - up["update_time"] = now - n, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(up) - if err != nil { - c.jsonErr(500, 500, "更新失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} - _ = c.ServeJSON() -} - -// DeleteFile DELETE /backend/deletefile/:id -func (c *BackendFileController) DeleteFile() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的文件ID") - return - } - now := time.Now() - n, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(map[string]interface{}{"delete_time": now}) - if err != nil { - c.jsonErr(500, 500, "删除失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} - _ = c.ServeJSON() -} - -// DeleteFilePermanently DELETE /backend/deletefilepermanently/:id -func (c *BackendFileController) DeleteFilePermanently() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的文件ID") - return - } - var f models.SystemFile - err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - One(&f) - if err != nil { - c.jsonErr(404, 404, "文件不存在") - return - } - removePhysicalBySrc(f.Src) - _, err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - Delete() - if err != nil { - c.jsonErr(500, 500, "永久删除失败: "+err.Error()) - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "永久删除成功"} - _ = c.ServeJSON() -} - -// MoveFile GET /backend/movefile/:id -func (c *BackendFileController) MoveFile() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的文件ID") - return - } - cate, _ := c.GetUint64("cate") - now := time.Now() - n, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(map[string]interface{}{"cate": cate, "update_time": now}) - if err != nil { - c.jsonErr(500, 500, "移动失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "移动成功"} - _ = c.ServeJSON() -} - -type idsBody struct { - IDs []uint64 `json:"ids"` - Cate *uint64 `json:"cate"` -} - -// BatchDeleteFiles POST /backend/batchdeletefiles -func (c *BackendFileController) BatchDeleteFiles() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body idsBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - if len(body.IDs) == 0 { - c.jsonErr(400, 400, "请选择要删除的文件") - return - } - 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)). - Filter("id__in", body.IDs). - Filter("tid", tid). - Update(map[string]interface{}{"delete_time": now}) - if err != nil { - c.jsonErr(500, 500, "批量删除失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量删除成功"} - _ = c.ServeJSON() -} - -// BatchDeleteFilesPermanently POST /backend/batchDeleteFilesPermanently -func (c *BackendFileController) BatchDeleteFilesPermanently() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body idsBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - if len(body.IDs) == 0 { - c.jsonErr(400, 400, "请选择要彻底删除的文件") - return - } - var rows []models.SystemFile - _, err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("id__in", body.IDs). - Filter("tid", tid). - All(&rows) - if err != nil { - c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) - return - } - for i := range rows { - removePhysicalBySrc(rows[i].Src) - } - n, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("id__in", body.IDs). - Filter("tid", tid). - Delete() - if err != nil { - c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量彻底删除成功"} - _ = c.ServeJSON() -} - -// UploadAvatar POST /backend/uploadavatar(占位) -func (c *BackendFileController) UploadAvatar() { - c.Data["json"] = map[string]interface{}{"code": 501, "msg": "上传头像暂未实现"} - _ = c.ServeJSON() -} - -// UpdateAvatar POST /backend/uploadavatar/:id(占位) -func (c *BackendFileController) UpdateAvatar() { - c.Data["json"] = map[string]interface{}{"code": 501, "msg": "更新头像暂未实现"} - _ = c.ServeJSON() -} - -// BatchMoveFiles POST /backend/batchMoveFiles -func (c *BackendFileController) BatchMoveFiles() { - claims, err := c.backendClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body idsBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - if len(body.IDs) == 0 { - c.jsonErr(400, 400, "请选择要移动的文件") - return - } - if body.Cate == nil { - c.jsonErr(400, 400, "缺少目标分类") - return - } - now := time.Now() - n, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("id__in", body.IDs). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(map[string]interface{}{"cate": *body.Cate, "update_time": now}) - if err != nil { - c.jsonErr(500, 500, "批量移动失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量移动成功"} - _ = c.ServeJSON() -} +package controllers + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendFileController 平台端文件管理(yz_system_files / yz_system_files_category) +type BackendFileController struct { + beego.Controller +} + +const fileUploadMaxMB = 2048 // 2GB,适用于大型软件安装包 +const fileUploadMaxBytes = fileUploadMaxMB * 1024 * 1024 + +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"}, + // 安装包 / 软件升级(上传时 cate 选 appsupgrade 分类即可,扩展名在此放行) + "appsupgrade": {"zip", "exe", "dmg", "msi", "msix", "apk", "deb", "rpm", "7z", "tar", "gz", "pkg"}, +} + +func (c *BackendFileController) backendClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *BackendFileController) effectiveTid(claims *jwtutil.Claims) uint64 { + _ = c.ParseForm(1 << 20) + if tid, err := c.GetUint64("tid"); err == nil && tid > 0 { + return tid + } + if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-Id")); h != "" { + if v, e := strconv.ParseUint(h, 10, 64); e == nil { + return v + } + } + if claims != nil && claims.TenantId > 0 { + return uint64(claims.TenantId) + } + 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) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendFileController) jsonOK(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +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 +} + +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 "" +} + +func fileToMap(f *models.SystemFile) map[string]interface{} { + ct := f.CreateTime.Format("2006-01-02 15:04:05") + m := map[string]interface{}{ + "id": f.ID, + "tid": f.Tid, + "name": f.Name, + "type": f.Type, + "cate": f.Cate, + "size": f.Size, + "src": f.Src, + "uploader": f.Uploader, + "md5": f.Md5, + "create_time": ct, + "createTime": ct, + "groupId": f.Cate, + "url": f.Src, + } + if f.Uid != nil { + m["uid"] = *f.Uid + } + if f.Tuid != nil { + m["tuid"] = *f.Tuid + } + return m +} + +func removePhysicalBySrc(webSrc string) { + webSrc = strings.TrimSpace(webSrc) + if webSrc == "" { + return + } + webSrc = strings.TrimPrefix(webSrc, "/") + _ = os.Remove(webSrc) +} + +// GetAllFiles GET /backend/allfiles +func (c *BackendFileController) GetAllFiles() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + cate, _ := c.GetUint64("cate") + keyword := strings.TrimSpace(c.GetString("keyword")) + scope := strings.TrimSpace(c.GetString("scope")) + + qs := models.Orm.QueryTable(new(models.SystemFile)). + Filter("source", services.SourceBackend). + Filter("tid", tid). + Filter("delete_time__isnull", true) + qs = scopeFilter(qs, scope, c.currentTuid(claims)) + if cate > 0 { + qs = qs.Filter("cate", cate) + } + if keyword != "" { + qs = qs.Filter("name__icontains", keyword) + } + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "获取文件列表失败: "+err.Error()) + return + } + var rows []models.SystemFile + _, err = qs.OrderBy("-create_time").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取文件列表失败: "+err.Error()) + return + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, fileToMap(&rows[i])) + } + c.jsonOK(map[string]interface{}{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + }) +} + +// GetUserCate GET /backend/usercate +func (c *BackendFileController) GetUserCate() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + + var cates []models.SystemFilesCategory + _, err = models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + OrderBy("id"). + All(&cates) + if err != nil { + c.jsonErr(500, 500, "获取用户分类失败: "+err.Error()) + return + } + out := make([]map[string]interface{}, 0, len(cates)) + for i := range cates { + cnt, _ := models.Orm.QueryTable(new(models.SystemFile)). + Filter("tid", tid). + Filter("cate", cates[i].ID). + Filter("delete_time__isnull", true). + Count() + out = append(out, map[string]interface{}{ + "id": cates[i].ID, + "name": cates[i].Name, + "total": cnt, + }) + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out} + _ = c.ServeJSON() +} + +type createCateBody struct { + Name string `json:"name"` + Tuid *uint64 `json:"tuid"` +} + +// CreateFileCate POST /backend/createfilecate +func (c *BackendFileController) CreateFileCate() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body createCateBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + c.jsonErr(400, 400, "分组名称不能为空") + return + } + uid := uint64(claims.UserID) + row := &models.SystemFilesCategory{ + Tid: tid, + Name: name, + Uid: &uid, + Tuid: body.Tuid, + } + id, err := models.Orm.Insert(row) + if err != nil { + c.jsonErr(500, 500, "新建文件分组失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "新建文件分组成功", + "data": map[string]interface{}{"id": uint64(id)}, + } + _ = c.ServeJSON() +} + +type renameCateBody struct { + Name string `json:"name"` +} + +// RenameFileCate POST /backend/renamefilecate/:id +func (c *BackendFileController) RenameFileCate() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的分组ID") + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body renameCateBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + c.jsonErr(400, 400, "分组名称不能为空") + return + } + n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"name": name}) + if err != nil { + c.jsonErr(500, 500, "重命名文件分组失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "分组不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "重命名文件分组成功"} + _ = c.ServeJSON() +} + +// DeleteFileCate DELETE /backend/deletefilecate/:id +func (c *BackendFileController) DeleteFileCate() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的分组ID") + return + } + cnt, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("cate", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Count() + if err != nil { + c.jsonErr(500, 500, "删除文件分组失败: "+err.Error()) + return + } + if cnt > 0 { + c.jsonErr(400, 400, fmt.Sprintf("该分组下还有 %d 个文件,请先删除分组内文件!", cnt)) + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "删除文件分组失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "分组不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除文件分组成功"} + _ = c.ServeJSON() +} + +// GetCateFiles GET /backend/catefiles/:id +func (c *BackendFileController) GetCateFiles() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + cateID, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.jsonErr(400, 400, "无效的分类ID") + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 24) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 24 + } + keyword := strings.TrimSpace(c.GetString("keyword")) + scope := strings.TrimSpace(c.GetString("scope")) + + qs := models.Orm.QueryTable(new(models.SystemFile)). + Filter("source", services.SourceBackend). + Filter("tid", tid). + Filter("cate", cateID). + Filter("delete_time__isnull", true) + qs = scopeFilter(qs, scope, c.currentTuid(claims)) + if keyword != "" { + qs = qs.Filter("name__icontains", keyword) + } + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "获取分类文件失败: "+err.Error()) + return + } + var rows []models.SystemFile + _, err = qs.OrderBy("-create_time").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取分类文件失败: "+err.Error()) + return + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, fileToMap(&rows[i])) + } + c.jsonOK(map[string]interface{}{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + "categoryId": cateID, + }) +} + +// GetFileByID GET /backend/file/:id +func (c *BackendFileController) GetFileByID() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + var f models.SystemFile + err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + One(&f) + if err != nil { + c.jsonErr(404, 404, "文件不存在") + return + } + c.jsonOK(fileToMap(&f)) +} + +// UploadFile POST /backend/uploadfile +func (c *BackendFileController) UploadFile() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + if err := c.Ctx.Request.ParseMultipartForm(fileUploadMaxBytes); err != nil { + c.jsonErr(400, 400, "解析上传失败: "+err.Error()) + return + } + tid := c.effectiveTid(claims) + // 归属用户:传了 tuid 或 scope=user 即用户个人文件,否则为租户共享文件 + tuid := c.resolveTuid(claims) + + fh, header, err := c.GetFile("file") + if err != nil || fh == nil { + c.jsonErr(400, 400, "请选择要上传的文件") + return + } + defer fh.Close() + + if header != nil && header.Size > fileUploadMaxBytes { + c.jsonErr(400, 400, fmt.Sprintf("文件大小不能超过%dMB", fileUploadMaxMB)) + return + } + + ext := services.FileExt(header.Filename) + if ext == "" { + c.jsonErr(400, 400, "无法识别文件扩展名") + return + } + + // 获取存储服务 + storageService, err := services.GetStorageService() + if err != nil { + c.jsonErr(500, 500, "获取存储服务失败: "+err.Error()) + return + } + + // 1. 先写入临时文件并计算 MD5(此时文件尚未进入正式目录) + staged, err := storageService.Stage(fh, header) + if err != nil { + c.jsonErr(500, 500, "读取上传文件失败: "+err.Error()) + return + } + committed := false + defer func() { + // 去重命中或出错时清理临时文件,避免产生垃圾 + if !committed && staged != nil { + _ = storageService.Discard(staged) + } + }() + + ctx := services.UploadContext{Source: services.SourceBackend, Tid: tid, Tuid: tuid} + scope := ctx.Scope() + + // 2. 精确查重:同端 + 同归属范围 + 同租户 + 同归属用户 + 同 MD5 才算重复。 + // 因此租户已有 a 文件、用户 c 上传同样文件不会冲突。 + exist, err := services.FindDuplicate(services.SourceBackend, scope, tid, tuid, staged.MD5) + if err != nil { + c.jsonErr(500, 500, "查重失败: "+err.Error()) + return + } + if exist != nil { + c.Data["json"] = map[string]interface{}{ + "code": 201, + "msg": "文件已存在", + "data": map[string]interface{}{ + "url": exist.Src, + "id": exist.ID, + "name": exist.Name, + }, + } + _ = c.ServeJSON() + 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") + var cate uint64 + if cateStr != "" { + cate, _ = strconv.ParseUint(cateStr, 10, 64) + } + + // 4. 保存文件记录到数据库 + id, err := services.CreateFileRecord(services.FileUploadParams{ + Source: services.SourceBackend, + Scope: scope, + Tid: tid, + Tuid: tuid, + Uid: uint64(claims.UserID), + Name: header.Filename, + Ext: ext, + Cate: cate, + Size: uint64(result.Size), + Src: result.URL, + ObjectKey: result.Key, + Storage: storageService.Type(), + MD5: result.MD5, + }) + if err != nil { + // 数据库插入失败,删除已提交的文件 + _ = storageService.Delete(result.Key) + c.jsonErr(500, 500, "上传失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "上传成功", + "data": map[string]interface{}{ + "url": result.URL, + "id": id, + "name": header.Filename, + }, + } + _ = c.ServeJSON() +} + +func md5HashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := md5.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +type updateFileBody struct { + Name *string `json:"name"` + Cate *uint64 `json:"cate"` +} + +// UpdateFile POST /backend/updatefile/:id +func (c *BackendFileController) UpdateFile() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body updateFileBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + up := map[string]interface{}{} + if body.Name != nil { + up["name"] = strings.TrimSpace(*body.Name) + } + if body.Cate != nil { + up["cate"] = *body.Cate + } + if len(up) == 0 { + c.jsonErr(400, 400, "无更新数据") + return + } + now := time.Now() + up["update_time"] = now + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(up) + if err != nil { + c.jsonErr(500, 500, "更新失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// DeleteFile DELETE /backend/deletefile/:id +func (c *BackendFileController) DeleteFile() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// DeleteFilePermanently DELETE /backend/deletefilepermanently/:id +func (c *BackendFileController) DeleteFilePermanently() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + var f models.SystemFile + err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + One(&f) + if err != nil { + c.jsonErr(404, 404, "文件不存在") + return + } + if svc, e := services.GetStorageService(); e == nil { + _ = services.RemovePhysical(svc, f.ObjectKey, f.Src) + } + _, err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Delete() + if err != nil { + c.jsonErr(500, 500, "永久删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "永久删除成功"} + _ = c.ServeJSON() +} + +// MoveFile GET /backend/movefile/:id +func (c *BackendFileController) MoveFile() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + cate, _ := c.GetUint64("cate") + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"cate": cate, "update_time": now}) + if err != nil { + c.jsonErr(500, 500, "移动失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "移动成功"} + _ = c.ServeJSON() +} + +type idsBody struct { + IDs []uint64 `json:"ids"` + Cate *uint64 `json:"cate"` +} + +// BatchDeleteFiles POST /backend/batchdeletefiles +func (c *BackendFileController) BatchDeleteFiles() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body idsBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(body.IDs) == 0 { + c.jsonErr(400, 400, "请选择要删除的文件") + return + } + // 软删除:只标记 delete_time,不删除物理文件(彻底删除接口才会删) + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "批量删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量删除成功"} + _ = c.ServeJSON() +} + +// BatchDeleteFilesPermanently POST /backend/batchDeleteFilesPermanently +func (c *BackendFileController) BatchDeleteFilesPermanently() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body idsBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(body.IDs) == 0 { + c.jsonErr(400, 400, "请选择要彻底删除的文件") + return + } + var rows []models.SystemFile + _, err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + All(&rows) + if err != nil { + c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) + return + } + if svc, e := services.GetStorageService(); e == nil { + for i := range rows { + _ = services.RemovePhysical(svc, rows[i].ObjectKey, rows[i].Src) + } + } + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + Delete() + if err != nil { + c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量彻底删除成功"} + _ = c.ServeJSON() +} + +// UploadAvatar POST /backend/uploadavatar(占位) +func (c *BackendFileController) UploadAvatar() { + c.Data["json"] = map[string]interface{}{"code": 501, "msg": "上传头像暂未实现"} + _ = c.ServeJSON() +} + +// UpdateAvatar POST /backend/uploadavatar/:id(占位) +func (c *BackendFileController) UpdateAvatar() { + c.Data["json"] = map[string]interface{}{"code": 501, "msg": "更新头像暂未实现"} + _ = c.ServeJSON() +} + +// BatchMoveFiles POST /backend/batchMoveFiles +func (c *BackendFileController) BatchMoveFiles() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body idsBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(body.IDs) == 0 { + c.jsonErr(400, 400, "请选择要移动的文件") + return + } + if body.Cate == nil { + c.jsonErr(400, 400, "缺少目标分类") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"cate": *body.Cate, "update_time": now}) + if err != nil { + c.jsonErr(500, 500, "批量移动失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量移动成功"} + _ = c.ServeJSON() +} diff --git a/go/controllers/platform_file.go b/go/controllers/platform_file.go index dd094dc..d7a15f9 100644 --- a/go/controllers/platform_file.go +++ b/go/controllers/platform_file.go @@ -1,907 +1,918 @@ -package controllers - -import ( - "crypto/md5" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "strconv" - "strings" - "time" - - "server/models" - "server/pkg/jwtutil" - "server/services" - - beego "github.com/beego/beego/v2/server/web" -) - -// PlatformFileController 平台端文件管理(yz_system_files / yz_system_files_category) -type PlatformFileController struct { - beego.Controller -} - -const platformFileUploadMaxMB = 2048 // 2GB,适用于大型软件安装包 -const platformFileUploadMaxBytes = platformFileUploadMaxMB * 1024 * 1024 - -var platformFileTypeByCategory = map[string]uint8{ - "image": 1, - "document": 2, - "video": 3, - "audio": 4, - "appsupgrade": 2, -} - -var platformAllowedExtByCategory = 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"}, - // 安装包 / 软件升级(上传时 cate 选 appsupgrade 分类即可,扩展名在此放行) - "appsupgrade": {"zip", "exe", "dmg", "msi", "msix", "apk", "deb", "rpm", "7z", "tar", "gz", "pkg"}, -} - -func (c *PlatformFileController) platformClaims() (*jwtutil.Claims, error) { - auth := c.Ctx.Request.Header.Get("Authorization") - if auth == "" { - return nil, fmt.Errorf("未登录") - } - parts := strings.SplitN(auth, " ", 2) - if len(parts) != 2 || parts[0] != "Bearer" { - return nil, fmt.Errorf("认证信息格式错误") - } - claims, err := jwtutil.ParseToken(parts[1]) - if err != nil { - return nil, fmt.Errorf("无效的token") - } - if claims.UserType != "platform" { - return nil, fmt.Errorf("无权访问") - } - return claims, nil -} - -func (c *PlatformFileController) effectiveTid(claims *jwtutil.Claims) uint64 { - _ = c.ParseForm(1 << 20) - if tid, err := c.GetUint64("tid"); err == nil && tid > 0 { - return tid - } - if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-Id")); h != "" { - if v, e := strconv.ParseUint(h, 10, 64); e == nil { - return v - } - } - if claims != nil && claims.TenantId > 0 { - return uint64(claims.TenantId) - } - return 0 -} - -func (c *PlatformFileController) jsonErr(httpStatus, bizCode int, msg string) { - c.Ctx.Output.SetStatus(httpStatus) - c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} - _ = c.ServeJSON() -} - -func (c *PlatformFileController) jsonOK(data interface{}) { - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} - _ = c.ServeJSON() -} - -func platformDetectFileType(ext string) uint8 { - ext = strings.ToLower(strings.TrimPrefix(ext, ".")) - for cat, exts := range platformAllowedExtByCategory { - for _, e := range exts { - if e == ext { - if t, ok := platformFileTypeByCategory[cat]; ok { - return t - } - return 2 - } - } - } - return 2 -} - -func platformFileExt(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 "" -} - -func platformFileToMap(f *models.SystemFile) map[string]interface{} { - ct := f.CreateTime.Format("2006-01-02 15:04:05") - m := map[string]interface{}{ - "id": f.ID, - "tid": f.Tid, - "name": f.Name, - "type": f.Type, - "cate": f.Cate, - "size": f.Size, - "src": f.Src, - "uploader": f.Uploader, - "md5": f.Md5, - "create_time": ct, - "createTime": ct, - "groupId": f.Cate, - "url": f.Src, - } - if f.Uid != nil { - m["uid"] = *f.Uid - } - if f.Tuid != nil { - m["tuid"] = *f.Tuid - } - return m -} - -func platformRemovePhysicalBySrc(webSrc string) { - webSrc = strings.TrimSpace(webSrc) - if webSrc == "" { - return - } - webSrc = strings.TrimPrefix(webSrc, "/") - _ = os.Remove(webSrc) -} - -// GetAllFiles GET /platform/allfiles -func (c *PlatformFileController) GetAllFiles() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - page, _ := c.GetInt("page", 1) - pageSize, _ := c.GetInt("pageSize", 10) - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 10 - } - cate, _ := c.GetUint64("cate") - keyword := strings.TrimSpace(c.GetString("keyword")) - - qs := models.Orm.QueryTable(new(models.SystemFile)). - Filter("tid", tid). - Filter("delete_time__isnull", true) - if cate > 0 { - qs = qs.Filter("cate", cate) - } - if keyword != "" { - qs = qs.Filter("name__icontains", keyword) - } - total, err := qs.Count() - if err != nil { - c.jsonErr(500, 500, "获取文件列表失败: "+err.Error()) - return - } - var rows []models.SystemFile - _, err = qs.OrderBy("-create_time").Limit(pageSize, (page-1)*pageSize).All(&rows) - if err != nil { - c.jsonErr(500, 500, "获取文件列表失败: "+err.Error()) - return - } - list := make([]map[string]interface{}, 0, len(rows)) - for i := range rows { - list = append(list, platformFileToMap(&rows[i])) - } - c.jsonOK(map[string]interface{}{ - "list": list, - "total": total, - "page": page, - "pageSize": pageSize, - }) -} - -// GetUserCate GET /platform/usercate -func (c *PlatformFileController) GetUserCate() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - - var cates []models.SystemFilesCategory - _, err = models.Orm.QueryTable(new(models.SystemFilesCategory)). - Filter("tid", tid). - Filter("delete_time__isnull", true). - OrderBy("id"). - All(&cates) - if err != nil { - c.jsonErr(500, 500, "获取用户分类失败: "+err.Error()) - return - } - out := make([]map[string]interface{}, 0, len(cates)) - for i := range cates { - cnt, _ := models.Orm.QueryTable(new(models.SystemFile)). - Filter("tid", tid). - Filter("cate", cates[i].ID). - Filter("delete_time__isnull", true). - Count() - out = append(out, map[string]interface{}{ - "id": cates[i].ID, - "name": cates[i].Name, - "total": cnt, - }) - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out} - _ = c.ServeJSON() -} - -type platformCreateCateBody struct { - Name string `json:"name"` - Tuid *uint64 `json:"tuid"` -} - -// CreateFileCate POST /platform/createfilecate -func (c *PlatformFileController) CreateFileCate() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body platformCreateCateBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - name := strings.TrimSpace(body.Name) - if name == "" { - c.jsonErr(400, 400, "分组名称不能为空") - return - } - uid := uint64(claims.UserID) - row := &models.SystemFilesCategory{ - Tid: tid, - Name: name, - Uid: &uid, - Tuid: body.Tuid, - } - id, err := models.Orm.Insert(row) - if err != nil { - c.jsonErr(500, 500, "新建文件分组失败: "+err.Error()) - return - } - c.Data["json"] = map[string]interface{}{ - "code": 200, - "msg": "新建文件分组成功", - "data": map[string]interface{}{"id": uint64(id)}, - } - _ = c.ServeJSON() -} - -type platformRenameCateBody struct { - Name string `json:"name"` -} - -// RenameFileCate POST /platform/renamefilecate/:id -func (c *PlatformFileController) RenameFileCate() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的分组ID") - return - } - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body platformRenameCateBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - name := strings.TrimSpace(body.Name) - if name == "" { - c.jsonErr(400, 400, "分组名称不能为空") - return - } - n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(map[string]interface{}{"name": name}) - if err != nil { - c.jsonErr(500, 500, "重命名文件分组失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "分组不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "重命名文件分组成功"} - _ = c.ServeJSON() -} - -// DeleteFileCate DELETE /platform/deletefilecate/:id -func (c *PlatformFileController) DeleteFileCate() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的分组ID") - return - } - cnt, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("cate", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Count() - if err != nil { - c.jsonErr(500, 500, "删除文件分组失败: "+err.Error()) - return - } - if cnt > 0 { - c.jsonErr(400, 400, fmt.Sprintf("该分组下还有 %d 个文件,请先删除分组内文件!", cnt)) - return - } - now := time.Now() - n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(map[string]interface{}{"delete_time": now}) - if err != nil { - c.jsonErr(500, 500, "删除文件分组失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "分组不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除文件分组成功"} - _ = c.ServeJSON() -} - -// GetCateFiles GET /platform/catefiles/:id -func (c *PlatformFileController) GetCateFiles() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - cateID, err := strconv.ParseUint(idStr, 10, 64) - if err != nil { - c.jsonErr(400, 400, "无效的分类ID") - return - } - page, _ := c.GetInt("page", 1) - pageSize, _ := c.GetInt("pageSize", 24) - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 24 - } - keyword := strings.TrimSpace(c.GetString("keyword")) - - qs := models.Orm.QueryTable(new(models.SystemFile)). - Filter("tid", tid). - Filter("cate", cateID). - Filter("delete_time__isnull", true) - if keyword != "" { - qs = qs.Filter("name__icontains", keyword) - } - total, err := qs.Count() - if err != nil { - c.jsonErr(500, 500, "获取分类文件失败: "+err.Error()) - return - } - var rows []models.SystemFile - _, err = qs.OrderBy("-create_time").Limit(pageSize, (page-1)*pageSize).All(&rows) - if err != nil { - c.jsonErr(500, 500, "获取分类文件失败: "+err.Error()) - return - } - list := make([]map[string]interface{}, 0, len(rows)) - for i := range rows { - list = append(list, platformFileToMap(&rows[i])) - } - c.jsonOK(map[string]interface{}{ - "list": list, - "total": total, - "page": page, - "pageSize": pageSize, - "categoryId": cateID, - }) -} - -// GetFileByID GET /platform/file/:id -func (c *PlatformFileController) GetFileByID() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的文件ID") - return - } - var f models.SystemFile - err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - One(&f) - if err != nil { - c.jsonErr(404, 404, "文件不存在") - return - } - c.jsonOK(platformFileToMap(&f)) -} - -// UploadFile POST /platform/uploadfile -func (c *PlatformFileController) UploadFile() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - if err := c.Ctx.Request.ParseMultipartForm(platformFileUploadMaxBytes); err != nil { - c.jsonErr(400, 400, "解析上传失败: "+err.Error()) - return - } - fh, header, err := c.GetFile("file") - if err != nil || fh == nil { - c.jsonErr(400, 400, "请选择要上传的文件") - return - } - defer fh.Close() - - if header != nil && header.Size > platformFileUploadMaxBytes { - c.jsonErr(400, 400, fmt.Sprintf("文件大小不能超过%dMB", platformFileUploadMaxMB)) - return - } - - ext := platformFileExt(header.Filename) - if ext == "" { - c.jsonErr(400, 400, "无法识别文件扩展名") - return - } - - // 获取存储服务 - storageService, err := services.GetStorageService() - if err != nil { - c.jsonErr(500, 500, "获取存储服务失败: "+err.Error()) - return - } - - // 上传文件 - result, err := storageService.Upload(fh, header) - if err != nil { - c.jsonErr(500, 500, "上传文件失败: "+err.Error()) - return - } - - // 检查文件是否已存在(通过MD5) - var exist models.SystemFile - err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("md5", result.MD5). - Filter("tid", tid). - Filter("delete_time__isnull", true). - One(&exist) - if err == nil { - // 文件已存在,返回已有记录 - c.Data["json"] = map[string]interface{}{ - "code": 201, - "msg": "文件已存在", - "data": map[string]interface{}{ - "url": exist.Src, - "id": exist.ID, - "name": exist.Name, - }, - } - _ = c.ServeJSON() - return - } - - // 获取分类 - cateStr := c.GetString("cate") - var cate uint64 - if cateStr != "" { - cate, _ = strconv.ParseUint(cateStr, 10, 64) - } - - adminID := uint64(claims.UserID) - var tuidPtr *uint64 - if ts := strings.TrimSpace(c.GetString("tuid")); ts != "" { - if v, e := strconv.ParseUint(ts, 10, 64); e == nil { - tuidPtr = &v - } - } - - // 保存文件记录到数据库 - row := &models.SystemFile{ - Tid: tid, - Uid: &adminID, - Tuid: tuidPtr, - Name: header.Filename, - 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 { - // 数据库插入失败,尝试删除已上传的文件 - _ = storageService.Delete(result.Key) - c.jsonErr(500, 500, "上传失败: "+err.Error()) - return - } - - c.Data["json"] = map[string]interface{}{ - "code": 200, - "msg": "上传成功", - "data": map[string]interface{}{ - "url": result.URL, - "id": uint64(id), - "name": header.Filename, - }, - } - _ = c.ServeJSON() -} - -func platformMd5HashFile(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer f.Close() - h := md5.New() - if _, err := io.Copy(h, f); err != nil { - return "", err - } - return hex.EncodeToString(h.Sum(nil)), nil -} - -type platformUpdateFileBody struct { - Name *string `json:"name"` - Cate *uint64 `json:"cate"` -} - -// UpdateFile POST /platform/updatefile/:id -func (c *PlatformFileController) UpdateFile() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的文件ID") - return - } - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body platformUpdateFileBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - up := map[string]interface{}{} - if body.Name != nil { - up["name"] = strings.TrimSpace(*body.Name) - } - if body.Cate != nil { - up["cate"] = *body.Cate - } - if len(up) == 0 { - c.jsonErr(400, 400, "无更新数据") - return - } - now := time.Now() - up["update_time"] = now - n, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(up) - if err != nil { - c.jsonErr(500, 500, "更新失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} - _ = c.ServeJSON() -} - -// DeleteFile DELETE /platform/deletefile/:id -func (c *PlatformFileController) DeleteFile() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的文件ID") - return - } - now := time.Now() - n, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(map[string]interface{}{"delete_time": now}) - if err != nil { - c.jsonErr(500, 500, "删除失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} - _ = c.ServeJSON() -} - -// DeleteFilePermanently DELETE /platform/deletefilepermanently/:id -func (c *PlatformFileController) DeleteFilePermanently() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的文件ID") - return - } - var f models.SystemFile - err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - One(&f) - if err != nil { - c.jsonErr(404, 404, "文件不存在") - return - } - platformRemovePhysicalBySrc(f.Src) - _, err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - Delete() - if err != nil { - c.jsonErr(500, 500, "永久删除失败: "+err.Error()) - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "永久删除成功"} - _ = c.ServeJSON() -} - -// MoveFile GET /platform/movefile/:id -func (c *PlatformFileController) MoveFile() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - idStr := c.Ctx.Input.Param(":id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - c.jsonErr(400, 400, "无效的文件ID") - return - } - cate, _ := c.GetUint64("cate") - now := time.Now() - n, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", id). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(map[string]interface{}{"cate": cate, "update_time": now}) - if err != nil { - c.jsonErr(500, 500, "移动失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "移动成功"} - _ = c.ServeJSON() -} - -type platformIdsBody struct { - IDs []uint64 `json:"ids"` - Cate *uint64 `json:"cate"` -} - -// BatchDeleteFiles POST /platform/batchdeletefiles -func (c *PlatformFileController) BatchDeleteFiles() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body platformIdsBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - if len(body.IDs) == 0 { - c.jsonErr(400, 400, "请选择要删除的文件") - return - } - 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)). - Filter("id__in", body.IDs). - Filter("tid", tid). - Update(map[string]interface{}{"delete_time": now}) - if err != nil { - c.jsonErr(500, 500, "批量删除失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量删除成功"} - _ = c.ServeJSON() -} - -// BatchDeleteFilesPermanently POST /platform/batchDeleteFilesPermanently -func (c *PlatformFileController) BatchDeleteFilesPermanently() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body platformIdsBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - if len(body.IDs) == 0 { - c.jsonErr(400, 400, "请选择要彻底删除的文件") - return - } - var rows []models.SystemFile - _, err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("id__in", body.IDs). - Filter("tid", tid). - All(&rows) - if err != nil { - c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) - return - } - for i := range rows { - platformRemovePhysicalBySrc(rows[i].Src) - } - n, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("id__in", body.IDs). - Filter("tid", tid). - Delete() - if err != nil { - c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量彻底删除成功"} - _ = c.ServeJSON() -} - -// UploadAvatar POST /platform/uploadavatar(占位) -func (c *PlatformFileController) UploadAvatar() { - c.Data["json"] = map[string]interface{}{"code": 501, "msg": "上传头像暂未实现"} - _ = c.ServeJSON() -} - -// UpdateAvatar POST /platform/uploadavatar/:id(占位) -func (c *PlatformFileController) UpdateAvatar() { - c.Data["json"] = map[string]interface{}{"code": 501, "msg": "更新头像暂未实现"} - _ = c.ServeJSON() -} - -// BatchMoveFiles POST /platform/batchMoveFiles -func (c *PlatformFileController) BatchMoveFiles() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - var body platformIdsBody - if err := json.Unmarshal(raw, &body); err != nil { - c.jsonErr(400, 400, "参数错误") - return - } - if len(body.IDs) == 0 { - c.jsonErr(400, 400, "请选择要移动的文件") - return - } - if body.Cate == nil { - c.jsonErr(400, 400, "缺少目标分类") - return - } - now := time.Now() - n, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("id__in", body.IDs). - Filter("tid", tid). - Filter("delete_time__isnull", true). - Update(map[string]interface{}{"cate": *body.Cate, "update_time": now}) - if err != nil { - c.jsonErr(500, 500, "批量移动失败: "+err.Error()) - return - } - if n == 0 { - c.jsonErr(404, 404, "文件不存在") - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量移动成功"} - _ = c.ServeJSON() -} +package controllers + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformFileController 平台端文件管理(yz_system_files / yz_system_files_category) +type PlatformFileController struct { + beego.Controller +} + +const platformFileUploadMaxMB = 2048 // 2GB,适用于大型软件安装包 +const platformFileUploadMaxBytes = platformFileUploadMaxMB * 1024 * 1024 + +var platformFileTypeByCategory = map[string]uint8{ + "image": 1, + "document": 2, + "video": 3, + "audio": 4, + "appsupgrade": 2, +} + +var platformAllowedExtByCategory = 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"}, + // 安装包 / 软件升级(上传时 cate 选 appsupgrade 分类即可,扩展名在此放行) + "appsupgrade": {"zip", "exe", "dmg", "msi", "msix", "apk", "deb", "rpm", "7z", "tar", "gz", "pkg"}, +} + +func (c *PlatformFileController) platformClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *PlatformFileController) effectiveTid(claims *jwtutil.Claims) uint64 { + _ = c.ParseForm(1 << 20) + if tid, err := c.GetUint64("tid"); err == nil && tid > 0 { + return tid + } + if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-Id")); h != "" { + if v, e := strconv.ParseUint(h, 10, 64); e == nil { + return v + } + } + if claims != nil && claims.TenantId > 0 { + return uint64(claims.TenantId) + } + return 0 +} + +// Prepare 所有接口执行前补齐文件存储分层所需字段,避免历史库缺列导致查询/写入报错 +func (c *PlatformFileController) Prepare() { + models.EnsureSystemFileStorageColumns() +} + +func (c *PlatformFileController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *PlatformFileController) jsonOK(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +func platformDetectFileType(ext string) uint8 { + ext = strings.ToLower(strings.TrimPrefix(ext, ".")) + for cat, exts := range platformAllowedExtByCategory { + for _, e := range exts { + if e == ext { + if t, ok := platformFileTypeByCategory[cat]; ok { + return t + } + return 2 + } + } + } + return 2 +} + +func platformFileExt(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 "" +} + +func platformFileToMap(f *models.SystemFile) map[string]interface{} { + ct := f.CreateTime.Format("2006-01-02 15:04:05") + m := map[string]interface{}{ + "id": f.ID, + "tid": f.Tid, + "name": f.Name, + "type": f.Type, + "cate": f.Cate, + "size": f.Size, + "src": f.Src, + "uploader": f.Uploader, + "md5": f.Md5, + "create_time": ct, + "createTime": ct, + "groupId": f.Cate, + "url": f.Src, + } + if f.Uid != nil { + m["uid"] = *f.Uid + } + if f.Tuid != nil { + m["tuid"] = *f.Tuid + } + return m +} + +func platformRemovePhysicalBySrc(webSrc string) { + webSrc = strings.TrimSpace(webSrc) + if webSrc == "" { + return + } + webSrc = strings.TrimPrefix(webSrc, "/") + _ = os.Remove(webSrc) +} + +// GetAllFiles GET /platform/allfiles +func (c *PlatformFileController) GetAllFiles() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + cate, _ := c.GetUint64("cate") + keyword := strings.TrimSpace(c.GetString("keyword")) + + qs := models.Orm.QueryTable(new(models.SystemFile)). + Filter("source", services.SourcePlatform). + Filter("tid", tid). + Filter("delete_time__isnull", true) + if cate > 0 { + qs = qs.Filter("cate", cate) + } + if keyword != "" { + qs = qs.Filter("name__icontains", keyword) + } + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "获取文件列表失败: "+err.Error()) + return + } + var rows []models.SystemFile + _, err = qs.OrderBy("-create_time").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取文件列表失败: "+err.Error()) + return + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, platformFileToMap(&rows[i])) + } + c.jsonOK(map[string]interface{}{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + }) +} + +// GetUserCate GET /platform/usercate +func (c *PlatformFileController) GetUserCate() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + + var cates []models.SystemFilesCategory + _, err = models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + OrderBy("id"). + All(&cates) + if err != nil { + c.jsonErr(500, 500, "获取用户分类失败: "+err.Error()) + return + } + out := make([]map[string]interface{}, 0, len(cates)) + for i := range cates { + cnt, _ := models.Orm.QueryTable(new(models.SystemFile)). + Filter("tid", tid). + Filter("cate", cates[i].ID). + Filter("delete_time__isnull", true). + Count() + out = append(out, map[string]interface{}{ + "id": cates[i].ID, + "name": cates[i].Name, + "total": cnt, + }) + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out} + _ = c.ServeJSON() +} + +type platformCreateCateBody struct { + Name string `json:"name"` + Tuid *uint64 `json:"tuid"` +} + +// CreateFileCate POST /platform/createfilecate +func (c *PlatformFileController) CreateFileCate() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body platformCreateCateBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + c.jsonErr(400, 400, "分组名称不能为空") + return + } + uid := uint64(claims.UserID) + row := &models.SystemFilesCategory{ + Tid: tid, + Name: name, + Uid: &uid, + Tuid: body.Tuid, + } + id, err := models.Orm.Insert(row) + if err != nil { + c.jsonErr(500, 500, "新建文件分组失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "新建文件分组成功", + "data": map[string]interface{}{"id": uint64(id)}, + } + _ = c.ServeJSON() +} + +type platformRenameCateBody struct { + Name string `json:"name"` +} + +// RenameFileCate POST /platform/renamefilecate/:id +func (c *PlatformFileController) RenameFileCate() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的分组ID") + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body platformRenameCateBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + c.jsonErr(400, 400, "分组名称不能为空") + return + } + n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"name": name}) + if err != nil { + c.jsonErr(500, 500, "重命名文件分组失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "分组不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "重命名文件分组成功"} + _ = c.ServeJSON() +} + +// DeleteFileCate DELETE /platform/deletefilecate/:id +func (c *PlatformFileController) DeleteFileCate() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的分组ID") + return + } + cnt, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("cate", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Count() + if err != nil { + c.jsonErr(500, 500, "删除文件分组失败: "+err.Error()) + return + } + if cnt > 0 { + c.jsonErr(400, 400, fmt.Sprintf("该分组下还有 %d 个文件,请先删除分组内文件!", cnt)) + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "删除文件分组失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "分组不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除文件分组成功"} + _ = c.ServeJSON() +} + +// GetCateFiles GET /platform/catefiles/:id +func (c *PlatformFileController) GetCateFiles() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + cateID, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.jsonErr(400, 400, "无效的分类ID") + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 24) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 24 + } + keyword := strings.TrimSpace(c.GetString("keyword")) + + qs := models.Orm.QueryTable(new(models.SystemFile)). + Filter("source", services.SourcePlatform). + Filter("tid", tid). + Filter("cate", cateID). + Filter("delete_time__isnull", true) + if keyword != "" { + qs = qs.Filter("name__icontains", keyword) + } + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "获取分类文件失败: "+err.Error()) + return + } + var rows []models.SystemFile + _, err = qs.OrderBy("-create_time").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取分类文件失败: "+err.Error()) + return + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, platformFileToMap(&rows[i])) + } + c.jsonOK(map[string]interface{}{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + "categoryId": cateID, + }) +} + +// GetFileByID GET /platform/file/:id +func (c *PlatformFileController) GetFileByID() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + var f models.SystemFile + err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + One(&f) + if err != nil { + c.jsonErr(404, 404, "文件不存在") + return + } + c.jsonOK(platformFileToMap(&f)) +} + +// UploadFile POST /platform/uploadfile +func (c *PlatformFileController) UploadFile() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + if err := c.Ctx.Request.ParseMultipartForm(platformFileUploadMaxBytes); err != nil { + c.jsonErr(400, 400, "解析上传失败: "+err.Error()) + return + } + tid := c.effectiveTid(claims) + + fh, header, err := c.GetFile("file") + if err != nil || fh == nil { + c.jsonErr(400, 400, "请选择要上传的文件") + return + } + defer fh.Close() + + if header != nil && header.Size > platformFileUploadMaxBytes { + c.jsonErr(400, 400, fmt.Sprintf("文件大小不能超过%dMB", platformFileUploadMaxMB)) + return + } + + ext := services.FileExt(header.Filename) + if ext == "" { + c.jsonErr(400, 400, "无法识别文件扩展名") + return + } + + // 获取存储服务 + storageService, err := services.GetStorageService() + if err != nil { + c.jsonErr(500, 500, "获取存储服务失败: "+err.Error()) + return + } + + // 1. 先写入临时文件并计算 MD5(此时文件尚未进入正式目录) + staged, err := storageService.Stage(fh, header) + if err != nil { + c.jsonErr(500, 500, "读取上传文件失败: "+err.Error()) + return + } + committed := false + defer func() { + if !committed && staged != nil { + _ = storageService.Discard(staged) + } + }() + + // 平台端不做租户/用户分层,统一 platform/yyyy/MM/dd/ + ctx := services.UploadContext{Source: services.SourcePlatform, Tid: tid} + scope := ctx.Scope() + + // 2. 精确查重(平台端按 tid + MD5) + exist, err := services.FindDuplicate(services.SourcePlatform, scope, tid, 0, staged.MD5) + if err != nil { + c.jsonErr(500, 500, "查重失败: "+err.Error()) + return + } + if exist != nil { + c.Data["json"] = map[string]interface{}{ + "code": 201, + "msg": "文件已存在", + "data": map[string]interface{}{ + "url": exist.Src, + "id": exist.ID, + "name": exist.Name, + }, + } + _ = c.ServeJSON() + return + } + + // 3. 提交到正式目录 + result, err := storageService.Commit(staged, ctx) + if err != nil { + c.jsonErr(500, 500, "上传文件失败: "+err.Error()) + return + } + committed = true + + // 获取分类 + cateStr := c.GetString("cate") + var cate uint64 + if cateStr != "" { + cate, _ = strconv.ParseUint(cateStr, 10, 64) + } + + // 4. 保存文件记录到数据库 + id, err := services.CreateFileRecord(services.FileUploadParams{ + Source: services.SourcePlatform, + Scope: scope, + Tid: tid, + Uid: uint64(claims.UserID), + Name: header.Filename, + Ext: ext, + Cate: cate, + Size: uint64(result.Size), + Src: result.URL, + ObjectKey: result.Key, + Storage: storageService.Type(), + MD5: result.MD5, + }) + if err != nil { + _ = storageService.Delete(result.Key) + c.jsonErr(500, 500, "上传失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "上传成功", + "data": map[string]interface{}{ + "url": result.URL, + "id": id, + "name": header.Filename, + }, + } + _ = c.ServeJSON() +} + +func platformMd5HashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := md5.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +type platformUpdateFileBody struct { + Name *string `json:"name"` + Cate *uint64 `json:"cate"` +} + +// UpdateFile POST /platform/updatefile/:id +func (c *PlatformFileController) UpdateFile() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body platformUpdateFileBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + up := map[string]interface{}{} + if body.Name != nil { + up["name"] = strings.TrimSpace(*body.Name) + } + if body.Cate != nil { + up["cate"] = *body.Cate + } + if len(up) == 0 { + c.jsonErr(400, 400, "无更新数据") + return + } + now := time.Now() + up["update_time"] = now + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(up) + if err != nil { + c.jsonErr(500, 500, "更新失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// DeleteFile DELETE /platform/deletefile/:id +func (c *PlatformFileController) DeleteFile() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// DeleteFilePermanently DELETE /platform/deletefilepermanently/:id +func (c *PlatformFileController) DeleteFilePermanently() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + var f models.SystemFile + err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + One(&f) + if err != nil { + c.jsonErr(404, 404, "文件不存在") + return + } + if svc, e := services.GetStorageService(); e == nil { + _ = services.RemovePhysical(svc, f.ObjectKey, f.Src) + } + _, err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Delete() + if err != nil { + c.jsonErr(500, 500, "永久删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "永久删除成功"} + _ = c.ServeJSON() +} + +// MoveFile GET /platform/movefile/:id +func (c *PlatformFileController) MoveFile() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + cate, _ := c.GetUint64("cate") + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"cate": cate, "update_time": now}) + if err != nil { + c.jsonErr(500, 500, "移动失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "移动成功"} + _ = c.ServeJSON() +} + +type platformIdsBody struct { + IDs []uint64 `json:"ids"` + Cate *uint64 `json:"cate"` +} + +// BatchDeleteFiles POST /platform/batchdeletefiles +func (c *PlatformFileController) BatchDeleteFiles() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body platformIdsBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(body.IDs) == 0 { + c.jsonErr(400, 400, "请选择要删除的文件") + return + } + // 软删除:只标记 delete_time,不删除物理文件(彻底删除接口才会删) + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "批量删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量删除成功"} + _ = c.ServeJSON() +} + +// BatchDeleteFilesPermanently POST /platform/batchDeleteFilesPermanently +func (c *PlatformFileController) BatchDeleteFilesPermanently() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body platformIdsBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(body.IDs) == 0 { + c.jsonErr(400, 400, "请选择要彻底删除的文件") + return + } + var rows []models.SystemFile + _, err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + All(&rows) + if err != nil { + c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) + return + } + if svc, e := services.GetStorageService(); e == nil { + for i := range rows { + _ = services.RemovePhysical(svc, rows[i].ObjectKey, rows[i].Src) + } + } + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + Delete() + if err != nil { + c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量彻底删除成功"} + _ = c.ServeJSON() +} + +// UploadAvatar POST /platform/uploadavatar(占位) +func (c *PlatformFileController) UploadAvatar() { + c.Data["json"] = map[string]interface{}{"code": 501, "msg": "上传头像暂未实现"} + _ = c.ServeJSON() +} + +// UpdateAvatar POST /platform/uploadavatar/:id(占位) +func (c *PlatformFileController) UpdateAvatar() { + c.Data["json"] = map[string]interface{}{"code": 501, "msg": "更新头像暂未实现"} + _ = c.ServeJSON() +} + +// BatchMoveFiles POST /platform/batchMoveFiles +func (c *PlatformFileController) BatchMoveFiles() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body platformIdsBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(body.IDs) == 0 { + c.jsonErr(400, 400, "请选择要移动的文件") + return + } + if body.Cate == nil { + c.jsonErr(400, 400, "缺少目标分类") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"cate": *body.Cate, "update_time": now}) + if err != nil { + c.jsonErr(500, 500, "批量移动失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量移动成功"} + _ = c.ServeJSON() +} diff --git a/go/controllers/qiniu_upload.go b/go/controllers/qiniu_upload.go index b82113d..e643a7b 100644 --- a/go/controllers/qiniu_upload.go +++ b/go/controllers/qiniu_upload.go @@ -1,327 +1,326 @@ -package controllers - -import ( - "crypto/md5" - "encoding/hex" - "encoding/json" - "fmt" - "strings" - "time" - - "server/models" - "server/pkg/jwtutil" - - beego "github.com/beego/beego/v2/server/web" - "github.com/qiniu/go-sdk/v7/auth/qbox" - "github.com/qiniu/go-sdk/v7/storage" -) - -// QiniuUploadController 七牛云上传控制器 -type QiniuUploadController struct { - beego.Controller -} - -// platformClaims 获取平台端 JWT claims -func (c *QiniuUploadController) platformClaims() (*jwtutil.Claims, error) { - auth := c.Ctx.Request.Header.Get("Authorization") - if auth == "" { - return nil, fmt.Errorf("未登录") - } - parts := strings.Split(auth, " ") - if len(parts) != 2 || parts[0] != "Bearer" { - return nil, fmt.Errorf("token 格式错误") - } - claims, err := jwtutil.ParseToken(parts[1]) - if err != nil { - return nil, fmt.Errorf("token 无效") - } - return claims, nil -} - -// effectiveTid 获取有效的租户 ID -func (c *QiniuUploadController) effectiveTid(claims *jwtutil.Claims) uint64 { - if claims.TenantId > 0 { - return uint64(claims.TenantId) - } - return 0 -} - -// jsonErr 返回错误响应 -func (c *QiniuUploadController) jsonErr(httpStatus, bizCode int, msg string) { - c.Ctx.Output.SetStatus(httpStatus) - c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} - _ = c.ServeJSON() -} - -// jsonOK 返回成功响应 -func (c *QiniuUploadController) jsonOK(data interface{}) { - c.Data["json"] = map[string]interface{}{"code": 200, "data": data} - _ = c.ServeJSON() -} - -// ParseJSON 解析 JSON 请求体 -func (c *QiniuUploadController) ParseJSON(v interface{}) error { - body := c.Ctx.Input.RequestBody - if len(body) == 0 { - return fmt.Errorf("请求体为空") - } - return json.Unmarshal(body, v) -} - -// GetUploadToken 获取上传凭证 -// GET /platform/qiniu/token -func (c *QiniuUploadController) GetUploadToken() { - _, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - - // 获取存储配置 - cfg, err := models.GetStorageConfig() - if err != nil || cfg.StorageType != "qiniu" { - c.jsonErr(400, 400, "当前未配置七牛云存储") - return - } - - // 检查配置完整性 - if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" || cfg.QiniuBucket == "" { - c.jsonErr(500, 500, "七牛云配置不完整") - return - } - - // 生成文件 key(前端可以覆盖) - datePath := time.Now().Format("2006/01/02") - timestamp := time.Now().UnixNano() - keyPrefix := fmt.Sprintf("%s/%d", datePath, timestamp) - - // 创建上传策略 - mac := qbox.NewMac(cfg.QiniuAccessKey, cfg.QiniuSecretKey) - putPolicy := storage.PutPolicy{ - Scope: cfg.QiniuBucket, - ReturnBody: `{"key":"$(key)","hash":"$(etag)","size":$(fsize),"mimeType":"$(mimeType)"}`, - Expires: 3600, // 1小时有效期 - } - upToken := putPolicy.UploadToken(mac) - - // 返回上传凭证和配置 - c.jsonOK(map[string]interface{}{ - "token": upToken, - "domain": cfg.QiniuDomain, - "bucket": cfg.QiniuBucket, - "region": cfg.QiniuRegion, - "keyPrefix": keyPrefix, - "expires": time.Now().Add(time.Hour).Unix(), - "uploadUrl": getQiniuUploadURL(cfg.QiniuRegion), - }) -} - -// SaveFileRecord 保存文件记录 -// POST /platform/qiniu/save -func (c *QiniuUploadController) SaveFileRecord() { - claims, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - tid := c.effectiveTid(claims) - - // 调试:打印请求体 - body := c.Ctx.Input.RequestBody - fmt.Println("SaveFileRecord 请求体长度:", len(body)) - fmt.Println("SaveFileRecord 请求体内容:", string(body)) - - // 解析请求参数 - type SaveRequest struct { - Key string `json:"key"` // 七牛云文件 key - Hash string `json:"hash"` // 文件 hash (etag) - Size int64 `json:"size"` // 文件大小 - Name string `json:"name"` // 原始文件名 - MimeType string `json:"mimeType"` // 文件类型 - Cate uint64 `json:"cate"` // 分类 ID - } - - var req SaveRequest - if err := c.ParseJSON(&req); err != nil { - c.jsonErr(400, 400, "参数解析失败: "+err.Error()) - return - } - - // 验证必填字段 - if req.Key == "" || req.Name == "" { - c.jsonErr(400, 400, "缺少必填参数") - return - } - - // 获取存储配置 - cfg, err := models.GetStorageConfig() - if err != nil || cfg.StorageType != "qiniu" { - c.jsonErr(400, 400, "当前未配置七牛云存储") - return - } - - // 构建完整 URL - domain := strings.TrimRight(cfg.QiniuDomain, "/") - fileURL := fmt.Sprintf("%s/%s", domain, req.Key) - - // 计算 MD5(使用 hash 作为 MD5,或者重新计算) - md5Sum := req.Hash - if md5Sum == "" { - // 如果没有 hash,使用 key 生成一个唯一标识 - h := md5.New() - h.Write([]byte(req.Key)) - md5Sum = hex.EncodeToString(h.Sum(nil)) - } - - // 检查文件是否已存在(通过 MD5) - var exist models.SystemFile - err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("md5", md5Sum). - Filter("tid", tid). - Filter("delete_time__isnull", true). - One(&exist) - if err == nil { - // 文件已存在,返回已有记录 - c.Data["json"] = map[string]interface{}{ - "code": 201, - "msg": "文件已存在", - "data": map[string]interface{}{ - "url": exist.Src, - "id": exist.ID, - "name": exist.Name, - }, - } - _ = c.ServeJSON() - return - } - - // 检测文件类型 - ext := getQiniuFileExt(req.Name) - fileType := detectQiniuFileType(ext) - - // 保存文件记录 - adminID := uint64(claims.UserID) - row := &models.SystemFile{ - Tid: tid, - Uid: &adminID, - Name: req.Name, - Type: fileType, - Cate: req.Cate, - Size: uint64(req.Size), - Src: fileURL, - Uploader: adminID, - Md5: md5Sum, - } - - id, err := models.Orm.Insert(row) - if err != nil { - c.jsonErr(500, 500, "保存文件记录失败: "+err.Error()) - return - } - - c.jsonOK(map[string]interface{}{ - "url": fileURL, - "id": uint64(id), - "name": req.Name, - "key": req.Key, - }) -} - -// GetStorageConfig 获取存储配置(前端用于判断上传方式) -// GET /platform/storage/config -func (c *QiniuUploadController) GetStorageConfig() { - _, err := c.platformClaims() - if err != nil { - c.jsonErr(401, 401, err.Error()) - return - } - - cfg, err := models.GetStorageConfig() - if err != nil { - c.jsonOK(map[string]interface{}{ - "storageType": "local", - }) - return - } - - // 只返回必要的配置信息,不返回密钥 - c.jsonOK(map[string]interface{}{ - "storageType": cfg.StorageType, - "qiniuDomain": cfg.QiniuDomain, - "qiniuRegion": cfg.QiniuRegion, - }) -} - -// getQiniuUploadURL 根据区域获取上传地址 -func getQiniuUploadURL(region string) string { - switch region { - case "z0": - return "https://up-z0.qiniup.com" - case "z1": - return "https://up-z1.qiniup.com" - case "z2": - return "https://up-z2.qiniup.com" - case "na0": - return "https://up-na0.qiniup.com" - case "as0": - return "https://up-as0.qiniup.com" - case "cn-east-2": - return "https://up-cn-east-2.qiniup.com" - default: - return "https://up-z0.qiniup.com" // 默认华东 - } -} - -// getQiniuFileExt 获取文件扩展名 -func getQiniuFileExt(filename string) string { - parts := strings.Split(filename, ".") - if len(parts) > 1 { - return strings.ToLower(parts[len(parts)-1]) - } - return "" -} - -// detectQiniuFileType 检测文件类型 -func detectQiniuFileType(ext string) uint8 { - imageExts := map[string]bool{ - "jpg": true, "jpeg": true, "png": true, "gif": true, "bmp": true, - "webp": true, "svg": true, "ico": true, - } - videoExts := map[string]bool{ - "mp4": true, "avi": true, "mov": true, "wmv": true, "flv": true, - "mkv": true, "webm": true, "m4v": true, - } - audioExts := map[string]bool{ - "mp3": true, "wav": true, "flac": true, "aac": true, "ogg": true, - "m4a": true, "wma": true, - } - docExts := map[string]bool{ - "doc": true, "docx": true, "xls": true, "xlsx": true, "ppt": true, - "pptx": true, "pdf": true, "txt": true, "md": true, - } - archiveExts := map[string]bool{ - "zip": true, "rar": true, "7z": true, "tar": true, "gz": true, - "bz2": true, "xz": true, - } - executableExts := map[string]bool{ - "exe": true, "msi": true, "dmg": true, "pkg": true, "deb": true, - "rpm": true, "apk": true, "msix": true, - } - - if imageExts[ext] { - return 1 // 图片 - } - if videoExts[ext] { - return 2 // 视频 - } - if audioExts[ext] { - return 3 // 音频 - } - if docExts[ext] { - return 4 // 文档 - } - if archiveExts[ext] || executableExts[ext] { - return 5 // 压缩包/安装包 - } - return 0 // 其他 -} +package controllers + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services" + + beego "github.com/beego/beego/v2/server/web" + "github.com/qiniu/go-sdk/v7/auth/qbox" + "github.com/qiniu/go-sdk/v7/storage" +) + +// QiniuUploadController 七牛云上传控制器 +type QiniuUploadController struct { + beego.Controller +} + +// platformClaims 获取平台端 JWT claims +func (c *QiniuUploadController) platformClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.Split(auth, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("token 格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("token 无效") + } + return claims, nil +} + +// effectiveTid 获取有效的租户 ID +func (c *QiniuUploadController) effectiveTid(claims *jwtutil.Claims) uint64 { + if claims.TenantId > 0 { + return uint64(claims.TenantId) + } + return 0 +} + +// jsonErr 返回错误响应 +func (c *QiniuUploadController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +// jsonOK 返回成功响应 +func (c *QiniuUploadController) jsonOK(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "data": data} + _ = c.ServeJSON() +} + +// ParseJSON 解析 JSON 请求体 +func (c *QiniuUploadController) ParseJSON(v interface{}) error { + body := c.Ctx.Input.RequestBody + if len(body) == 0 { + return fmt.Errorf("请求体为空") + } + return json.Unmarshal(body, v) +} + +// GetUploadToken 获取上传凭证 +// GET /platform/qiniu/token +func (c *QiniuUploadController) GetUploadToken() { + _, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + // 获取存储配置 + cfg, err := models.GetStorageConfig() + if err != nil || cfg.StorageType != "qiniu" { + c.jsonErr(400, 400, "当前未配置七牛云存储") + return + } + + // 检查配置完整性 + if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" || cfg.QiniuBucket == "" { + c.jsonErr(500, 500, "七牛云配置不完整") + return + } + + // 生成文件 key(前端可以覆盖) + // 平台端固定落在 platform/yyyy/MM/dd/ 下,与服务端中转上传保持同一套目录规则 + datePath := time.Now().Format("2006/01/02") + timestamp := time.Now().UnixNano() + keyPrefix := fmt.Sprintf("%s/%s/%d", services.SourcePlatform, datePath, timestamp) + + // 创建上传策略 + mac := qbox.NewMac(cfg.QiniuAccessKey, cfg.QiniuSecretKey) + putPolicy := storage.PutPolicy{ + Scope: cfg.QiniuBucket, + ReturnBody: `{"key":"$(key)","hash":"$(etag)","size":$(fsize),"mimeType":"$(mimeType)"}`, + Expires: 3600, // 1小时有效期 + } + upToken := putPolicy.UploadToken(mac) + + // 返回上传凭证和配置 + c.jsonOK(map[string]interface{}{ + "token": upToken, + "domain": cfg.QiniuDomain, + "bucket": cfg.QiniuBucket, + "region": cfg.QiniuRegion, + "keyPrefix": keyPrefix, + "expires": time.Now().Add(time.Hour).Unix(), + "uploadUrl": getQiniuUploadURL(cfg.QiniuRegion), + }) +} + +// SaveFileRecord 保存文件记录 +// POST /platform/qiniu/save +func (c *QiniuUploadController) SaveFileRecord() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + + // 调试:打印请求体 + body := c.Ctx.Input.RequestBody + fmt.Println("SaveFileRecord 请求体长度:", len(body)) + fmt.Println("SaveFileRecord 请求体内容:", string(body)) + + // 解析请求参数 + type SaveRequest struct { + Key string `json:"key"` // 七牛云文件 key + Hash string `json:"hash"` // 文件 hash (etag) + Size int64 `json:"size"` // 文件大小 + Name string `json:"name"` // 原始文件名 + MimeType string `json:"mimeType"` // 文件类型 + Cate uint64 `json:"cate"` // 分类 ID + } + + var req SaveRequest + if err := c.ParseJSON(&req); err != nil { + c.jsonErr(400, 400, "参数解析失败: "+err.Error()) + return + } + + // 验证必填字段 + if req.Key == "" || req.Name == "" { + c.jsonErr(400, 400, "缺少必填参数") + return + } + + // 获取存储配置 + cfg, err := models.GetStorageConfig() + if err != nil || cfg.StorageType != "qiniu" { + c.jsonErr(400, 400, "当前未配置七牛云存储") + return + } + + // 构建完整 URL + domain := strings.TrimRight(cfg.QiniuDomain, "/") + fileURL := fmt.Sprintf("%s/%s", domain, req.Key) + + // 注意:直传通道前端给的是七牛 etag(req.Hash),并非文件真实 MD5, + // 与服务端中转上传算出的 MD5 不同源,因此该通道的查重只在本通道内有效。 + md5Sum := req.Hash + if md5Sum == "" { + // 如果没有 hash,使用 key 生成一个唯一标识 + h := md5.New() + h.Write([]byte(req.Key)) + md5Sum = hex.EncodeToString(h.Sum(nil)) + } + + // 检查文件是否已存在(通过 MD5) + exist, err := services.FindDuplicate(services.SourcePlatform, services.ScopeTenant, tid, 0, md5Sum) + if err != nil { + c.jsonErr(500, 500, "查重失败: "+err.Error()) + return + } + if exist != nil { + // 文件已存在,返回已有记录 + c.Data["json"] = map[string]interface{}{ + "code": 201, + "msg": "文件已存在", + "data": map[string]interface{}{ + "url": exist.Src, + "id": exist.ID, + "name": exist.Name, + }, + } + _ = c.ServeJSON() + return + } + + // 保存文件记录(平台端:source=platform,scope=tenant,object_key=七牛 key) + // 文件类型由 services.CreateFileRecord 依据扩展名统一推断 + id, err := services.CreateFileRecord(services.FileUploadParams{ + Source: services.SourcePlatform, + Scope: services.ScopeTenant, + Tid: tid, + Uid: uint64(claims.UserID), + Name: req.Name, + Ext: getQiniuFileExt(req.Name), + Cate: req.Cate, + Size: uint64(req.Size), + Src: fileURL, + ObjectKey: req.Key, + Storage: services.StorageTypeQiniu, + MD5: md5Sum, + }) + if err != nil { + c.jsonErr(500, 500, "保存文件记录失败: "+err.Error()) + return + } + + c.jsonOK(map[string]interface{}{ + "url": fileURL, + "id": uint64(id), + "name": req.Name, + "key": req.Key, + }) +} + +// GetStorageConfig 获取存储配置(前端用于判断上传方式) +// GET /platform/storage/config +func (c *QiniuUploadController) GetStorageConfig() { + _, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + cfg, err := models.GetStorageConfig() + if err != nil { + c.jsonOK(map[string]interface{}{ + "storageType": "local", + }) + return + } + + // 只返回必要的配置信息,不返回密钥 + c.jsonOK(map[string]interface{}{ + "storageType": cfg.StorageType, + "qiniuDomain": cfg.QiniuDomain, + "qiniuRegion": cfg.QiniuRegion, + }) +} + +// getQiniuUploadURL 根据区域获取上传地址 +func getQiniuUploadURL(region string) string { + switch region { + case "z0": + return "https://up-z0.qiniup.com" + case "z1": + return "https://up-z1.qiniup.com" + case "z2": + return "https://up-z2.qiniup.com" + case "na0": + return "https://up-na0.qiniup.com" + case "as0": + return "https://up-as0.qiniup.com" + case "cn-east-2": + return "https://up-cn-east-2.qiniup.com" + default: + return "https://up-z0.qiniup.com" // 默认华东 + } +} + +// getQiniuFileExt 获取文件扩展名 +func getQiniuFileExt(filename string) string { + parts := strings.Split(filename, ".") + if len(parts) > 1 { + return strings.ToLower(parts[len(parts)-1]) + } + return "" +} + +// detectQiniuFileType 检测文件类型 +func detectQiniuFileType(ext string) uint8 { + imageExts := map[string]bool{ + "jpg": true, "jpeg": true, "png": true, "gif": true, "bmp": true, + "webp": true, "svg": true, "ico": true, + } + videoExts := map[string]bool{ + "mp4": true, "avi": true, "mov": true, "wmv": true, "flv": true, + "mkv": true, "webm": true, "m4v": true, + } + audioExts := map[string]bool{ + "mp3": true, "wav": true, "flac": true, "aac": true, "ogg": true, + "m4a": true, "wma": true, + } + docExts := map[string]bool{ + "doc": true, "docx": true, "xls": true, "xlsx": true, "ppt": true, + "pptx": true, "pdf": true, "txt": true, "md": true, + } + archiveExts := map[string]bool{ + "zip": true, "rar": true, "7z": true, "tar": true, "gz": true, + "bz2": true, "xz": true, + } + executableExts := map[string]bool{ + "exe": true, "msi": true, "dmg": true, "pkg": true, "deb": true, + "rpm": true, "apk": true, "msix": true, + } + + if imageExts[ext] { + return 1 // 图片 + } + if videoExts[ext] { + return 2 // 视频 + } + if audioExts[ext] { + return 3 // 音频 + } + if docExts[ext] { + return 4 // 文档 + } + if archiveExts[ext] || executableExts[ext] { + return 5 // 压缩包/安装包 + } + return 0 // 其他 +} diff --git a/go/docs/sql/alter_system_files_storage_columns.sql b/go/docs/sql/alter_system_files_storage_columns.sql new file mode 100644 index 0000000..ba783a2 --- /dev/null +++ b/go/docs/sql/alter_system_files_storage_columns.sql @@ -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'); diff --git a/go/docs/文件存储分层改造计划.md b/go/docs/文件存储分层改造计划.md new file mode 100644 index 0000000..ed3f262 --- /dev/null +++ b/go/docs/文件存储分层改造计划.md @@ -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 全部更新且可访问 diff --git a/go/models/system_file.go b/go/models/system_file.go index e7d0c40..0b0e248 100644 --- a/go/models/system_file.go +++ b/go/models/system_file.go @@ -1,25 +1,81 @@ -package models - -import "time" - -// SystemFile 附件表 yz_system_files -type SystemFile struct { - ID uint64 `orm:"column(id);pk;auto" json:"id"` - Tid uint64 `orm:"column(tid)" json:"tid"` - Uid *uint64 `orm:"column(uid);null" json:"uid"` - Tuid *uint64 `orm:"column(tuid);null" json:"tuid"` - Name string `orm:"column(name);size(255)" json:"name"` - Type uint8 `orm:"column(type);default(2)" json:"type"` - Cate uint64 `orm:"column(cate);default(0)" json:"cate"` - Size uint64 `orm:"column(size);default(0)" json:"size"` - Src string `orm:"column(src);size(512)" json:"src"` - Uploader uint64 `orm:"column(uploader);default(0)" json:"uploader"` - Md5 string `orm:"column(md5);size(32)" json:"md5"` - 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 { - return "yz_system_files" -} +package models + +import ( + "sync" + "time" +) + +// 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 { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + Uid *uint64 `orm:"column(uid);null" json:"uid"` + Tuid *uint64 `orm:"column(tuid);null" json:"tuid"` + Name string `orm:"column(name);size(255)" json:"name"` + Type uint8 `orm:"column(type);default(2)" json:"type"` + Cate uint64 `orm:"column(cate);default(0)" json:"cate"` + Size uint64 `orm:"column(size);default(0)" json:"size"` + Src string `orm:"column(src);size(512)" json:"src"` + Uploader uint64 `orm:"column(uploader);default(0)" json:"uploader"` + Md5 string `orm:"column(md5);size(32)" json:"md5"` + Source string `orm:"column(source);size(16);default(backend)" json:"source"` + Scope string `orm:"column(scope);size(16);default(tenant)" json:"scope"` + 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 { + 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() + } + }) +} diff --git a/go/services/file_service.go b/go/services/file_service.go new file mode 100644 index 0000000..6758971 --- /dev/null +++ b/go/services/file_service.go @@ -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() +} diff --git a/go/services/storage_migration.go b/go/services/storage_migration.go index f0155fe..f5c1bb9 100644 --- a/go/services/storage_migration.go +++ b/go/services/storage_migration.go @@ -1,191 +1,222 @@ -package services - -import ( - "fmt" - "mime/multipart" - "os" - "path/filepath" - "strings" - "sync" - - "server/models" -) - -// MigrationProgress 迁移进度 -type MigrationProgress struct { - Total int - Success int - Failed int - Current string - Errors []string - mu sync.Mutex -} - -// AddSuccess 增加成功计数 -func (p *MigrationProgress) AddSuccess() { - p.mu.Lock() - defer p.mu.Unlock() - p.Success++ -} - -// AddFailed 增加失败计数 -func (p *MigrationProgress) AddFailed(err string) { - p.mu.Lock() - defer p.mu.Unlock() - p.Failed++ - p.Errors = append(p.Errors, err) -} - -// SetCurrent 设置当前处理的文件 -func (p *MigrationProgress) SetCurrent(filename string) { - p.mu.Lock() - defer p.mu.Unlock() - p.Current = filename -} - -// GetProgress 获取进度信息 -func (p *MigrationProgress) GetProgress() (int, int, int, string) { - p.mu.Lock() - defer p.mu.Unlock() - return p.Total, p.Success, p.Failed, p.Current -} - -// StorageMigration 存储迁移服务 -type StorageMigration struct { - fromService StorageService - toService StorageService - progress *MigrationProgress -} - -// NewStorageMigration 创建存储迁移服务 -func NewStorageMigration(from, to StorageService) *StorageMigration { - return &StorageMigration{ - fromService: from, - toService: to, - progress: &MigrationProgress{ - Errors: make([]string, 0), - }, - } -} - -// MigrateFile 迁移单个文件 -func (m *StorageMigration) MigrateFile(file *models.SystemFile) error { - m.progress.SetCurrent(file.Name) - - // 如果是本地存储,从本地读取文件 - if localFrom, ok := m.fromService.(*LocalStorage); ok { - // 从本地文件系统读取 - localPath := strings.TrimPrefix(file.Src, "/") - filePath := filepath.Join(localFrom.BaseDir, localPath) - - f, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("打开本地文件失败: %w", err) - } - defer f.Close() - - // 获取文件信息 - stat, err := f.Stat() - if err != nil { - return fmt.Errorf("获取文件信息失败: %w", err) - } - - // 创建 multipart.FileHeader - header := &multipart.FileHeader{ - Filename: file.Name, - Size: stat.Size(), - } - - // 上传到目标存储 - result, err := m.toService.Upload(f, header) - if err != nil { - return fmt.Errorf("上传到目标存储失败: %w", err) - } - - // 更新数据库记录 - _, err = models.Orm.QueryTable(new(models.SystemFile)). - Filter("id", file.ID). - Update(map[string]interface{}{ - "src": result.URL, - }) - if err != nil { - // 上传成功但更新数据库失败,尝试删除已上传的文件 - _ = m.toService.Delete(result.Key) - return fmt.Errorf("更新数据库失败: %w", err) - } - - m.progress.AddSuccess() - return nil - } - - // 如果是七牛云存储,需要先下载再上传(这里简化处理) - return fmt.Errorf("暂不支持从七牛云迁移到本地") -} - -// MigrateAll 迁移所有文件 -func (m *StorageMigration) MigrateAll(tid uint64) error { - // 获取所有文件 - var files []models.SystemFile - _, err := models.Orm.QueryTable(new(models.SystemFile)). - Filter("tid", tid). - Filter("delete_time__isnull", true). - All(&files) - if err != nil { - return fmt.Errorf("获取文件列表失败: %w", err) - } - - m.progress.Total = len(files) - - // 并发迁移(限制并发数) - concurrency := 5 - sem := make(chan struct{}, concurrency) - var wg sync.WaitGroup - - for i := range files { - wg.Add(1) - go func(file *models.SystemFile) { - defer wg.Done() - sem <- struct{}{} // 获取信号量 - defer func() { <-sem }() // 释放信号量 - - if err := m.MigrateFile(file); err != nil { - m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err)) - } - }(&files[i]) - } - - wg.Wait() - return nil -} - -// GetProgress 获取迁移进度 -func (m *StorageMigration) GetProgress() *MigrationProgress { - return m.progress -} - -// MigrateLocalToQiniu 从本地存储迁移到七牛云 -func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) { - // 获取存储配置 - cfg, err := models.GetStorageConfig() - if err != nil { - return nil, fmt.Errorf("获取存储配置失败: %w", err) - } - - if cfg.StorageType != "qiniu" { - return nil, fmt.Errorf("当前存储类型不是七牛云") - } - - // 创建存储服务 - localStorage := NewLocalStorage() - qiniuStorage := NewQiniuStorage(cfg) - - // 创建迁移服务 - migration := NewStorageMigration(localStorage, qiniuStorage) - - // 执行迁移 - if err := migration.MigrateAll(tid); err != nil { - return migration.GetProgress(), err - } - - return migration.GetProgress(), nil -} +package services + +import ( + "fmt" + "mime/multipart" + "os" + "path/filepath" + "sync" + + "server/models" +) + +// MigrationProgress 迁移进度 +type MigrationProgress struct { + Total int + Success int + Failed int + Skipped int + Current string + Errors []string + mu sync.Mutex +} + +// AddSuccess 增加成功计数 +func (p *MigrationProgress) AddSuccess() { + p.mu.Lock() + defer p.mu.Unlock() + p.Success++ +} + +// AddSkipped 增加跳过计数 +func (p *MigrationProgress) AddSkipped() { + p.mu.Lock() + defer p.mu.Unlock() + p.Skipped++ +} + +// AddFailed 增加失败计数 +func (p *MigrationProgress) AddFailed(err string) { + p.mu.Lock() + defer p.mu.Unlock() + p.Failed++ + p.Errors = append(p.Errors, err) +} + +// SetCurrent 设置当前处理的文件 +func (p *MigrationProgress) SetCurrent(filename string) { + p.mu.Lock() + defer p.mu.Unlock() + p.Current = filename +} + +// GetProgress 获取进度信息 +func (p *MigrationProgress) GetProgress() (int, int, int, string) { + p.mu.Lock() + defer p.mu.Unlock() + return p.Total, p.Success, p.Failed, p.Current +} + +// StorageMigration 存储迁移服务 +type StorageMigration struct { + fromService StorageService + toService StorageService + progress *MigrationProgress +} + +// NewStorageMigration 创建存储迁移服务 +func NewStorageMigration(from, to StorageService) *StorageMigration { + return &StorageMigration{ + fromService: from, + toService: to, + progress: &MigrationProgress{ + Errors: make([]string, 0), + }, + } +} + +// 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 { + m.progress.SetCurrent(file.Name) + + oldKey := file.ObjectKey + if oldKey == "" { + oldKey = KeyFromSrc(file.Src, m.fromService) + } + if oldKey == "" { + return fmt.Errorf("无法解析原存储路径: %s", file.Src) + } + + 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 { + return fmt.Errorf("打开本地文件失败: %w", err) + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return fmt.Errorf("获取文件信息失败: %w", err) + } + header := &multipart.FileHeader{Filename: file.Name, Size: stat.Size()} + + staged, err := m.toService.Stage(f, header) + if err != nil { + return err + } + source := file.Source + if source == "" { + source = SourceBackend + } + var tuid uint64 + if file.Tuid != nil { + tuid = *file.Tuid + } + if _, err := m.toService.Commit(staged, UploadContext{Source: source, Tid: file.Tid, Tuid: tuid}); err != nil { + _ = m.toService.Discard(staged) + return err + } + } + + newSrc := m.toService.GetPublicURL(newKey) + 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 迁移所有文件 +func (m *StorageMigration) MigrateAll(tid uint64) error { + var files []models.SystemFile + qs := models.Orm.QueryTable(new(models.SystemFile)).Filter("delete_time__isnull", true) + if tid > 0 { + qs = qs.Filter("tid", tid) + } + if _, err := qs.All(&files); err != nil { + return fmt.Errorf("获取文件列表失败: %w", err) + } + + m.progress.Total = len(files) + + concurrency := 5 + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + + for i := range files { + wg.Add(1) + go func(file *models.SystemFile) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + if err := m.MigrateFile(file); err != nil { + m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err)) + } + }(&files[i]) + } + + wg.Wait() + return nil +} + +// GetProgress 获取迁移进度 +func (m *StorageMigration) GetProgress() *MigrationProgress { + return m.progress +} + +// MigrateLocalToQiniu 从本地存储迁移到七牛云 +func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) { + cfg, err := models.GetStorageConfig() + if err != nil { + return nil, fmt.Errorf("获取存储配置失败: %w", err) + } + if cfg.StorageType != StorageTypeQiniu { + return nil, fmt.Errorf("当前存储类型不是七牛云") + } + + migration := NewStorageMigration(NewLocalStorage(), NewQiniuStorage(cfg)) + if err := migration.MigrateAll(tid); err != nil { + return migration.GetProgress(), err + } + return migration.GetProgress(), nil +} diff --git a/go/services/storage_service.go b/go/services/storage_service.go index 7e439dd..a60b843 100644 --- a/go/services/storage_service.go +++ b/go/services/storage_service.go @@ -1,252 +1,440 @@ -package services - -import ( - "context" - "crypto/md5" - "encoding/hex" - "fmt" - "io" - "mime/multipart" - "os" - "path/filepath" - "strings" - "time" - - "server/models" - - "github.com/qiniu/go-sdk/v7/auth/qbox" - "github.com/qiniu/go-sdk/v7/storage" -) - -// StorageService 存储服务接口 -type StorageService interface { - Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) - GetPublicURL(key string) string - Delete(key string) error -} - -// UploadResult 上传结果 -type UploadResult struct { - URL string // 完整访问URL - Key string // 存储key/路径 - Size int64 // 文件大小 - MD5 string // 文件MD5 - MimeType string // 文件类型 -} - -// LocalStorage 本地存储实现 -type LocalStorage struct { - BaseDir string // 基础目录,默认 "uploads" - BaseURL string // 基础URL,默认 "/" -} - -// NewLocalStorage 创建本地存储服务 -func NewLocalStorage() *LocalStorage { - return &LocalStorage{ - BaseDir: "uploads", - BaseURL: "/", - } -} - -// Upload 上传文件到本地 -func (s *LocalStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) { - // 生成存储路径 - 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) - - // 创建目录 - destDir := filepath.Join(s.BaseDir, filepath.FromSlash(datePath)) - if err := os.MkdirAll(destDir, 0755); err != nil { - return nil, fmt.Errorf("创建目录失败: %w", err) - } - - // 保存文件 - destPath := filepath.Join(s.BaseDir, filepath.FromSlash(savePath)) - dst, err := os.Create(destPath) - if err != nil { - return nil, fmt.Errorf("创建文件失败: %w", err) - } - 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{ - URL: webURL, - Key: savePath, - Size: size, - MD5: md5Sum, - MimeType: header.Header.Get("Content-Type"), - }, nil -} - -// GetPublicURL 获取公开访问URL -func (s *LocalStorage) GetPublicURL(key string) string { - return s.BaseURL + filepath.ToSlash(filepath.Join(s.BaseDir, key)) -} - -// Delete 删除本地文件 -func (s *LocalStorage) Delete(key string) error { - filePath := filepath.Join(s.BaseDir, filepath.FromSlash(key)) - return os.Remove(filePath) -} - -// QiniuStorage 七牛云存储实现 -type QiniuStorage struct { - AccessKey string - SecretKey string - Bucket string - Domain string - Region string -} - -// NewQiniuStorage 创建七牛云存储服务 -func NewQiniuStorage(cfg *models.StorageConfig) *QiniuStorage { - return &QiniuStorage{ - AccessKey: cfg.QiniuAccessKey, - SecretKey: cfg.QiniuSecretKey, - Bucket: cfg.QiniuBucket, - Domain: cfg.QiniuDomain, - Region: cfg.QiniuRegion, - } -} - -// getZone 根据区域代码获取存储区域 -func (s *QiniuStorage) getZone() *storage.Region { - switch s.Region { - case "z0": - return &storage.ZoneHuadong - case "z1": - return &storage.ZoneHuabei - case "z2": - return &storage.ZoneHuanan - case "na0": - return &storage.ZoneBeimei - case "as0": - return &storage.ZoneXinjiapo - case "cn-east-2": - return &storage.ZoneHuadongZheJiang2 - default: - return &storage.ZoneHuadong // 默认华东 - } -} - -// Upload 上传文件到七牛云 -func (s *QiniuStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) { - // 生成存储key - ext := filepath.Ext(header.Filename) - datePath := time.Now().Format("2006/01/02") - fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) - key := filepath.ToSlash(filepath.Join(datePath, fileName)) - - // 创建上传凭证 - mac := qbox.NewMac(s.AccessKey, s.SecretKey) - putPolicy := storage.PutPolicy{ - Scope: s.Bucket, - } - upToken := putPolicy.UploadToken(mac) - - // 配置上传参数 - cfg := storage.Config{ - Region: s.getZone(), - UseHTTPS: true, - UseCdnDomains: false, - } - - // 创建表单上传器 - formUploader := storage.NewFormUploader(&cfg) - ret := storage.PutRet{} - putExtra := storage.PutExtra{} - - // 计算文件大小和MD5 - 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) - } - - // 构建完整URL - domain := strings.TrimRight(s.Domain, "/") - url := fmt.Sprintf("%s/%s", domain, ret.Key) - - return &UploadResult{ - URL: url, - Key: ret.Key, - Size: size, - MD5: md5Sum, - MimeType: header.Header.Get("Content-Type"), - }, nil -} - -// GetPublicURL 获取七牛云公开访问URL -func (s *QiniuStorage) GetPublicURL(key string) string { - domain := strings.TrimRight(s.Domain, "/") - return fmt.Sprintf("%s/%s", domain, key) -} - -// Delete 删除七牛云文件 -func (s *QiniuStorage) Delete(key string) error { - mac := qbox.NewMac(s.AccessKey, s.SecretKey) - cfg := storage.Config{ - Region: s.getZone(), - UseHTTPS: true, - } - - bucketManager := storage.NewBucketManager(mac, &cfg) - err := bucketManager.Delete(s.Bucket, key) - if err != nil { - return fmt.Errorf("删除七牛云文件失败: %w", err) - } - return nil -} - -// GetStorageService 根据配置获取存储服务 -func GetStorageService() (StorageService, error) { - cfg, err := models.GetStorageConfig() - if err != nil { - // 默认使用本地存储 - return NewLocalStorage(), nil - } - - switch cfg.StorageType { - case "qiniu": - if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" || - cfg.QiniuBucket == "" || cfg.QiniuDomain == "" { - return nil, fmt.Errorf("七牛云配置不完整") - } - return NewQiniuStorage(cfg), nil - case "local": - return NewLocalStorage(), nil - default: - return NewLocalStorage(), nil - } -} +package services + +import ( + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "mime/multipart" + "os" + "path" + "path/filepath" + "strings" + "time" + + "server/models" + + "github.com/qiniu/go-sdk/v7/auth/qbox" + "github.com/qiniu/go-sdk/v7/storage" +) + +// 来源端 +const ( + SourceBackend = "backend" // 租户后台端 + SourcePlatform = "platform" // 平台端 +) + +// 归属范围 +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 上传结果 +type UploadResult struct { + URL string // 完整访问URL + Key string // 存储key/相对路径(object_key) + Size int64 // 文件大小 + MD5 string // 文件MD5 + 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 本地存储实现 +type LocalStorage struct { + BaseDir string // 基础目录,默认 "uploads" + BaseURL string // 基础URL,默认 "/" +} + +// NewLocalStorage 创建本地存储服务 +func NewLocalStorage() *LocalStorage { + return &LocalStorage{ + BaseDir: "uploads", + BaseURL: "/", + } +} + +// Type 存储类型 +func (s *LocalStorage) Type() string { return StorageTypeLocal } + +// tempDir 临时目录:与正式目录同盘,保证 Commit 时 os.Rename 不跨设备 +func (s *LocalStorage) tempDir() string { + 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) + } + + // 同盘优先 Rename;跨设备失败则回退复制 + if err := os.Rename(staged.TempPath, destPath); err != nil { + if copyErr := copyFile(staged.TempPath, destPath); copyErr != nil { + return nil, fmt.Errorf("保存文件失败: %w", copyErr) + } + _ = os.Remove(staged.TempPath) + } + + return &UploadResult{ + URL: s.GetPublicURL(key), + Key: key, + Size: staged.Size, + MD5: staged.MD5, + MimeType: staged.MimeType, + }, nil +} + +// Discard 删除临时文件 +func (s *LocalStorage) Discard(staged *StagedFile) error { + if staged == nil || staged.TempPath == "" { + return nil + } + return os.Remove(staged.TempPath) +} + +// GetPublicURL 获取公开访问URL +func (s *LocalStorage) GetPublicURL(key string) string { + full := filepath.ToSlash(filepath.Join(s.BaseDir, filepath.FromSlash(key))) + return s.BaseURL + strings.ReplaceAll(full, "\\", "/") +} + +// Delete 删除本地文件 +func (s *LocalStorage) Delete(key string) error { + filePath := filepath.Join(s.BaseDir, filepath.FromSlash(key)) + 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 七牛云存储实现 +type QiniuStorage struct { + AccessKey string + SecretKey string + Bucket string + Domain string + Region string +} + +// NewQiniuStorage 创建七牛云存储服务 +func NewQiniuStorage(cfg *models.StorageConfig) *QiniuStorage { + return &QiniuStorage{ + AccessKey: cfg.QiniuAccessKey, + SecretKey: cfg.QiniuSecretKey, + Bucket: cfg.QiniuBucket, + Domain: cfg.QiniuDomain, + Region: cfg.QiniuRegion, + } +} + +// Type 存储类型 +func (s *QiniuStorage) Type() string { return StorageTypeQiniu } + +// getZone 根据区域代码获取存储区域 +func (s *QiniuStorage) getZone() *storage.Region { + switch s.Region { + case "z0": + return &storage.ZoneHuadong + case "z1": + return &storage.ZoneHuabei + case "z2": + return &storage.ZoneHuanan + case "na0": + return &storage.ZoneBeimei + case "as0": + return &storage.ZoneXinjiapo + case "cn-east-2": + return &storage.ZoneHuadongZheJiang2 + default: + return &storage.ZoneHuadong // 默认华东 + } +} + +// Stage 写入临时文件并计算 MD5(七牛直传拿不到内容,这里统一先落临时文件算 MD5, +// 便于去重;未命中才会真正上传到七牛) +func (s *QiniuStorage) Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error) { + tmp, err := os.CreateTemp("", "yz_upload_*") + 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 { + _ = 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) + + cfg := storage.Config{ + Region: s.getZone(), + UseHTTPS: true, + UseCdnDomains: false, + } + formUploader := storage.NewFormUploader(&cfg) + ret := storage.PutRet{} + + if err := formUploader.Put(context.Background(), &ret, upToken, key, f, staged.Size, &storage.PutExtra{}); err != nil { + return nil, fmt.Errorf("上传到七牛云失败: %w", err) + } + + return &UploadResult{ + URL: s.GetPublicURL(key), + Key: key, + Size: staged.Size, + MD5: staged.MD5, + MimeType: staged.MimeType, + }, nil +} + +// Discard 删除临时文件 +func (s *QiniuStorage) Discard(staged *StagedFile) error { + if staged == nil || staged.TempPath == "" { + return nil + } + return os.Remove(staged.TempPath) +} + +// GetPublicURL 获取七牛云公开访问URL +func (s *QiniuStorage) GetPublicURL(key string) string { + domain := strings.TrimRight(s.Domain, "/") + return fmt.Sprintf("%s/%s", domain, key) +} + +// Delete 删除七牛云文件 +func (s *QiniuStorage) Delete(key string) error { + return s.runBucketManager(func(bm *storage.BucketManager) error { + return bm.Delete(s.Bucket, key) + }, "删除七牛云文件失败") +} + +// Move 七牛服务端改名(同 bucket 内原子操作,不走流量) +func (s *QiniuStorage) Move(oldKey, newKey string) error { + return s.runBucketManager(func(bm *storage.BucketManager) error { + // 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 +} + +// GetStorageService 根据配置获取存储服务 +func GetStorageService() (StorageService, error) { + cfg, err := models.GetStorageConfig() + if err != nil { + // 默认使用本地存储 + return NewLocalStorage(), nil + } + + switch cfg.StorageType { + case "qiniu": + if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" || + cfg.QiniuBucket == "" || cfg.QiniuDomain == "" { + return nil, fmt.Errorf("七牛云配置不完整") + } + return NewQiniuStorage(cfg), nil + case "local": + return NewLocalStorage(), nil + default: + 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() +} diff --git a/platform/src/views/apps/babyhealth/users/components/userEdit.vue b/platform/src/views/apps/babyhealth/users/components/userEdit.vue index 3a28634..24df04c 100644 --- a/platform/src/views/apps/babyhealth/users/components/userEdit.vue +++ b/platform/src/views/apps/babyhealth/users/components/userEdit.vue @@ -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 = { Authorization: "Bearer " + localStorage.getItem("token"), }; diff --git a/platform/src/views/basicSettings/tenants/components/qualification.vue b/platform/src/views/basicSettings/tenants/components/qualification.vue index 65dc94d..8305c8d 100644 --- a/platform/src/views/basicSettings/tenants/components/qualification.vue +++ b/platform/src/views/basicSettings/tenants/components/qualification.vue @@ -24,7 +24,8 @@