整体平台上传功能大改造
This commit is contained in:
+18
-1
@@ -106,10 +106,27 @@ export function getFileById(id) {
|
|||||||
*/
|
*/
|
||||||
export function uploadFile(formData, options = {}) {
|
export function uploadFile(formData, options = {}) {
|
||||||
// 0 表示「未分类」,不能用 truthy 判断
|
// 0 表示「未分类」,不能用 truthy 判断
|
||||||
if (options.cate !== undefined && options.cate !== null && options.cate !== "") {
|
// 调用方若已手动 append 过 cate,这里不再重复追加(避免 multipart 出现两个 cate)
|
||||||
|
if (
|
||||||
|
options.cate !== undefined &&
|
||||||
|
options.cate !== null &&
|
||||||
|
options.cate !== "" &&
|
||||||
|
!formData.has("cate")
|
||||||
|
) {
|
||||||
formData.append("cate", String(options.cate));
|
formData.append("cate", String(options.cate));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scope: 'user' 表示上传到「我的个人文件」目录(backend/{tid}/{uid}/),
|
||||||
|
// 不传或 'tenant' 表示租户共享文件(backend/{tid}/)
|
||||||
|
if (options.scope && !formData.has("scope")) {
|
||||||
|
formData.append("scope", String(options.scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuid:显式指定归属用户(代他人上传场景),一般不需要传
|
||||||
|
if (options.tuid && !formData.has("tuid")) {
|
||||||
|
formData.append("tuid", String(options.tuid));
|
||||||
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: "/backend/uploadfile",
|
url: "/backend/uploadfile",
|
||||||
method: "post",
|
method: "post",
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ const currentBanner = ref<Partial<Banner>>({
|
|||||||
// 上传配置
|
// 上传配置
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
|
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
|
||||||
const uploadHeaders = ref({
|
const uploadHeaders = ref({
|
||||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ const submitLoading = ref(false)
|
|||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
|
|
||||||
// 上传配置
|
// 上传配置
|
||||||
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
|
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
|
||||||
const uploadHeaders = ref({
|
const uploadHeaders = ref({
|
||||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ const currentMenu = ref<Partial<Menu>>({
|
|||||||
// 上传配置
|
// 上传配置
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
const uploadUrl = ref(API_BASE_URL + '/admin/uploadfiles');
|
const uploadUrl = ref(API_BASE_URL + '/backend/uploadfile');
|
||||||
const uploadHeaders = ref({
|
const uploadHeaders = ref({
|
||||||
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
|
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const submitLoading = ref(false);
|
|||||||
const formRef = ref(null);
|
const formRef = ref(null);
|
||||||
|
|
||||||
// 上传配置
|
// 上传配置
|
||||||
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
|
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
|
||||||
const uploadHeaders = ref({
|
const uploadHeaders = ref({
|
||||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const submitLoading = ref(false);
|
|||||||
const formRef = ref(null);
|
const formRef = ref(null);
|
||||||
|
|
||||||
// 上传配置
|
// 上传配置
|
||||||
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
|
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
|
||||||
const uploadHeaders = ref({
|
const uploadHeaders = ref({
|
||||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -748,7 +748,8 @@ const uploadFileToSystem = async rawFile => {
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', rawFile)
|
formData.append('file', rawFile)
|
||||||
formData.append('cate', 'reimbursement-invoice')
|
formData.append('cate', 'reimbursement-invoice')
|
||||||
const uploadResponse = await uploadFile(formData)
|
// 报销票据归属到当前用户个人目录:backend/{tid}/{uid}/年月日/
|
||||||
|
const uploadResponse = await uploadFile(formData, { scope: 'user' })
|
||||||
if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) {
|
if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) {
|
||||||
throw new Error(uploadResponse?.msg || `「${rawFile.name}」上传失败`)
|
throw new Error(uploadResponse?.msg || `「${rawFile.name}」上传失败`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,7 +300,8 @@ function ensureImageName(file) {
|
|||||||
async function uploadImage(file) {
|
async function uploadImage(file) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
const res = await uploadFile(formData);
|
// 日程图片归属到当前用户个人目录:backend/{tid}/{uid}/年月日/
|
||||||
|
const res = await uploadFile(formData, { scope: "user" });
|
||||||
if (res?.code !== 200 && res?.code !== 201) {
|
if (res?.code !== 200 && res?.code !== 201) {
|
||||||
throw new Error(res?.msg || `「${file.name}」上传失败`);
|
throw new Error(res?.msg || `「${file.name}」上传失败`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
<el-form-item label="资质图片" prop="file_url">
|
<el-form-item label="资质图片" prop="file_url">
|
||||||
<el-upload
|
<el-upload
|
||||||
class="avatar-uploader"
|
class="avatar-uploader"
|
||||||
action="/api/platform/common/upload"
|
:action="uploadAction"
|
||||||
|
:headers="uploadHeaders"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:on-success="handleUploadSuccess"
|
:on-success="handleUploadSuccess"
|
||||||
:before-upload="beforeAvatarUpload"
|
:before-upload="beforeAvatarUpload"
|
||||||
@@ -71,6 +72,12 @@
|
|||||||
const tenantName = ref('');
|
const tenantName = ref('');
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
|
|
||||||
|
// 上传地址:走租户后台通用上传接口(原 /api/platform/common/upload 后端不存在,会 404)
|
||||||
|
const uploadAction = import.meta.env.VITE_API_BASE_URL + '/backend/uploadfile';
|
||||||
|
const uploadHeaders = {
|
||||||
|
Authorization: 'Bearer ' + (localStorage.getItem('token') || '')
|
||||||
|
};
|
||||||
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
tid: null,
|
tid: null,
|
||||||
type: '',
|
type: '',
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// 存量文件迁移到分层目录(文件存储分层改造 S9)
|
||||||
|
//
|
||||||
|
// 用法(在 go/ 目录下执行):
|
||||||
|
//
|
||||||
|
// go run ./cmd/migrate_storage # 默认 dry-run,只打印计划,不改动任何数据
|
||||||
|
// go run ./cmd/migrate_storage --apply # 真正执行迁移
|
||||||
|
// go run ./cmd/migrate_storage --tid=234573 # 只迁移指定租户
|
||||||
|
// go run ./cmd/migrate_storage --limit=10 # 只处理前 N 条(调试用)
|
||||||
|
//
|
||||||
|
// 迁移原理:
|
||||||
|
// - 七牛云:BucketManager.Move 同 bucket 内服务端改名,不重新上传、不消耗流量、与文件大小无关
|
||||||
|
// - 本地 :os.Rename 同盘改名,不搬数据
|
||||||
|
//
|
||||||
|
// 幂等:目标 key 已存在时跳过(Skipped),可重复执行。
|
||||||
|
//
|
||||||
|
// 注意:老数据没有「个人/共享」归属信息(tuid 全为 NULL、uid 是上传者),
|
||||||
|
//
|
||||||
|
// 因此老数据统一按「租户共享」迁移,个人目录只对新上传的文件生效。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
apply := flag.Bool("apply", false, "真正执行迁移;不传则只打印计划(dry-run)")
|
||||||
|
tid := flag.Uint64("tid", 0, "只迁移指定租户,0 表示全部")
|
||||||
|
limit := flag.Int("limit", 0, "只处理前 N 条,0 表示不限制")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 注意:需在 go/ 目录下运行,beego 会自动加载 conf/app.conf
|
||||||
|
models.Init("")
|
||||||
|
|
||||||
|
if models.Orm == nil {
|
||||||
|
fmt.Println("数据库未初始化")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
models.EnsureSystemFileStorageColumns()
|
||||||
|
|
||||||
|
storageSvc, err := services.GetStorageService()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("获取存储服务失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("当前存储类型: %s\n", storageSvc.Type())
|
||||||
|
|
||||||
|
qs := models.Orm.QueryTable(new(models.SystemFile)).Filter("delete_time__isnull", true)
|
||||||
|
if *tid > 0 {
|
||||||
|
qs = qs.Filter("tid", *tid)
|
||||||
|
}
|
||||||
|
if *limit > 0 {
|
||||||
|
qs = qs.Limit(*limit)
|
||||||
|
}
|
||||||
|
var files []models.SystemFile
|
||||||
|
if _, err := qs.OrderBy("id").All(&files); err != nil {
|
||||||
|
fmt.Println("读取文件列表失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("待处理文件数: %d(模式: %s)\n\n", len(files), modeName(*apply))
|
||||||
|
|
||||||
|
planned, skipped, failed := 0, 0, 0
|
||||||
|
for i := range files {
|
||||||
|
f := files[i]
|
||||||
|
oldKey := f.ObjectKey
|
||||||
|
if oldKey == "" {
|
||||||
|
oldKey = services.KeyFromSrc(f.Src, storageSvc)
|
||||||
|
}
|
||||||
|
if oldKey == "" {
|
||||||
|
fmt.Printf("[跳过] id=%d 无法解析原路径: %s\n", f.ID, f.Src)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newKey := services.TargetKey(&f)
|
||||||
|
if oldKey == newKey {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
planned++
|
||||||
|
fmt.Printf("[%s] id=%d tid=%d\n 老: %s\n 新: %s\n", modeName(*apply), f.ID, f.Tid, oldKey, newKey)
|
||||||
|
|
||||||
|
if !*apply {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := storageSvc.Move(oldKey, newKey); err != nil {
|
||||||
|
fmt.Printf(" !! 迁移失败: %v\n", err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newSrc := storageSvc.GetPublicURL(newKey)
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
|
Filter("id", f.ID).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"src": newSrc,
|
||||||
|
"object_key": newKey,
|
||||||
|
"storage": storageSvc.Type(),
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Printf(" !! 更新数据库失败(文件已移动,请重跑本脚本): %v\n", err)
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n==== 汇总 ====\n计划迁移: %d\n跳过: %d\n失败: %d\n", planned, skipped, failed)
|
||||||
|
if !*apply {
|
||||||
|
fmt.Println("当前为 dry-run,未改动任何数据。确认无误后加 --apply 执行。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeName(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
+108
-45
@@ -15,6 +15,7 @@ import (
|
|||||||
"server/pkg/jwtutil"
|
"server/pkg/jwtutil"
|
||||||
"server/services"
|
"server/services"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,6 +79,53 @@ func (c *BackendFileController) effectiveTid(claims *jwtutil.Claims) uint64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepare 所有接口执行前补齐文件存储分层所需字段,避免历史库缺列导致查询/写入报错
|
||||||
|
func (c *BackendFileController) Prepare() {
|
||||||
|
models.EnsureSystemFileStorageColumns()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTuid 解析归属用户 ID,返回 0 表示租户共享文件。
|
||||||
|
//
|
||||||
|
// 取值顺序:
|
||||||
|
// 1. 表单/请求头的 tuid(代他人上传场景,如 HR 代员工上传票据)
|
||||||
|
// 2. scope=user 时取当前登录的租户用户 UID(backend 端 JWT 的 UserID 即 yz_system_tenant_user.uid)
|
||||||
|
// —— 强制取自身 UID,避免前端伪造他人 uid 把文件塞进别人目录
|
||||||
|
// 3. 其余情况返回 0(租户共享)
|
||||||
|
func (c *BackendFileController) resolveTuid(claims *jwtutil.Claims) uint64 {
|
||||||
|
if v, err := c.GetUint64("tuid"); err == nil && v > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-User-Id")); h != "" {
|
||||||
|
if v, e := strconv.ParseUint(h, 10, 64); e == nil && v > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.GetString("scope")) == services.ScopeUser {
|
||||||
|
if claims != nil && claims.UserID > 0 {
|
||||||
|
return uint64(claims.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentTuid 当前登录者对应的租户用户 UID(backend 端 JWT 的 UserID 即 yz_system_tenant_user.uid)
|
||||||
|
func (c *BackendFileController) currentTuid(claims *jwtutil.Claims) uint64 {
|
||||||
|
if claims != nil && claims.UserID > 0 {
|
||||||
|
return uint64(claims.UserID)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeFilter 按归属范围过滤:
|
||||||
|
// - scope=user → 只看当前登录用户的个人文件
|
||||||
|
// - 其它(默认)→ 只看租户共享文件
|
||||||
|
func scopeFilter(qs orm.QuerySeter, scope string, tuid uint64) orm.QuerySeter {
|
||||||
|
if scope == services.ScopeUser {
|
||||||
|
return qs.Filter("tuid", tuid)
|
||||||
|
}
|
||||||
|
return qs.Filter("tuid__isnull", true)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *BackendFileController) jsonErr(httpStatus, bizCode int, msg string) {
|
func (c *BackendFileController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||||
c.Ctx.Output.SetStatus(httpStatus)
|
c.Ctx.Output.SetStatus(httpStatus)
|
||||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||||
@@ -165,10 +213,13 @@ func (c *BackendFileController) GetAllFiles() {
|
|||||||
}
|
}
|
||||||
cate, _ := c.GetUint64("cate")
|
cate, _ := c.GetUint64("cate")
|
||||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
scope := strings.TrimSpace(c.GetString("scope"))
|
||||||
|
|
||||||
qs := models.Orm.QueryTable(new(models.SystemFile)).
|
qs := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
|
Filter("source", services.SourceBackend).
|
||||||
Filter("tid", tid).
|
Filter("tid", tid).
|
||||||
Filter("delete_time__isnull", true)
|
Filter("delete_time__isnull", true)
|
||||||
|
qs = scopeFilter(qs, scope, c.currentTuid(claims))
|
||||||
if cate > 0 {
|
if cate > 0 {
|
||||||
qs = qs.Filter("cate", cate)
|
qs = qs.Filter("cate", cate)
|
||||||
}
|
}
|
||||||
@@ -400,11 +451,14 @@ func (c *BackendFileController) GetCateFiles() {
|
|||||||
pageSize = 24
|
pageSize = 24
|
||||||
}
|
}
|
||||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
scope := strings.TrimSpace(c.GetString("scope"))
|
||||||
|
|
||||||
qs := models.Orm.QueryTable(new(models.SystemFile)).
|
qs := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
|
Filter("source", services.SourceBackend).
|
||||||
Filter("tid", tid).
|
Filter("tid", tid).
|
||||||
Filter("cate", cateID).
|
Filter("cate", cateID).
|
||||||
Filter("delete_time__isnull", true)
|
Filter("delete_time__isnull", true)
|
||||||
|
qs = scopeFilter(qs, scope, c.currentTuid(claims))
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
qs = qs.Filter("name__icontains", keyword)
|
qs = qs.Filter("name__icontains", keyword)
|
||||||
}
|
}
|
||||||
@@ -466,11 +520,14 @@ func (c *BackendFileController) UploadFile() {
|
|||||||
c.jsonErr(401, 401, err.Error())
|
c.jsonErr(401, 401, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tid := c.effectiveTid(claims)
|
|
||||||
if err := c.Ctx.Request.ParseMultipartForm(fileUploadMaxBytes); err != nil {
|
if err := c.Ctx.Request.ParseMultipartForm(fileUploadMaxBytes); err != nil {
|
||||||
c.jsonErr(400, 400, "解析上传失败: "+err.Error())
|
c.jsonErr(400, 400, "解析上传失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tid := c.effectiveTid(claims)
|
||||||
|
// 归属用户:传了 tuid 或 scope=user 即用户个人文件,否则为租户共享文件
|
||||||
|
tuid := c.resolveTuid(claims)
|
||||||
|
|
||||||
fh, header, err := c.GetFile("file")
|
fh, header, err := c.GetFile("file")
|
||||||
if err != nil || fh == nil {
|
if err != nil || fh == nil {
|
||||||
c.jsonErr(400, 400, "请选择要上传的文件")
|
c.jsonErr(400, 400, "请选择要上传的文件")
|
||||||
@@ -483,7 +540,7 @@ func (c *BackendFileController) UploadFile() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := fileExt(header.Filename)
|
ext := services.FileExt(header.Filename)
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
c.jsonErr(400, 400, "无法识别文件扩展名")
|
c.jsonErr(400, 400, "无法识别文件扩展名")
|
||||||
return
|
return
|
||||||
@@ -496,22 +553,31 @@ func (c *BackendFileController) UploadFile() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传文件
|
// 1. 先写入临时文件并计算 MD5(此时文件尚未进入正式目录)
|
||||||
result, err := storageService.Upload(fh, header)
|
staged, err := storageService.Stage(fh, header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.jsonErr(500, 500, "上传文件失败: "+err.Error())
|
c.jsonErr(500, 500, "读取上传文件失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
// 去重命中或出错时清理临时文件,避免产生垃圾
|
||||||
|
if !committed && staged != nil {
|
||||||
|
_ = storageService.Discard(staged)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 检查文件是否已存在(通过MD5)
|
ctx := services.UploadContext{Source: services.SourceBackend, Tid: tid, Tuid: tuid}
|
||||||
var exist models.SystemFile
|
scope := ctx.Scope()
|
||||||
err = models.Orm.QueryTable(new(models.SystemFile)).
|
|
||||||
Filter("md5", result.MD5).
|
// 2. 精确查重:同端 + 同归属范围 + 同租户 + 同归属用户 + 同 MD5 才算重复。
|
||||||
Filter("tid", tid).
|
// 因此租户已有 a 文件、用户 c 上传同样文件不会冲突。
|
||||||
Filter("delete_time__isnull", true).
|
exist, err := services.FindDuplicate(services.SourceBackend, scope, tid, tuid, staged.MD5)
|
||||||
One(&exist)
|
if err != nil {
|
||||||
if err == nil {
|
c.jsonErr(500, 500, "查重失败: "+err.Error())
|
||||||
// 文件已存在,返回已有记录
|
return
|
||||||
|
}
|
||||||
|
if exist != nil {
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
"code": 201,
|
"code": 201,
|
||||||
"msg": "文件已存在",
|
"msg": "文件已存在",
|
||||||
@@ -525,6 +591,14 @@ func (c *BackendFileController) UploadFile() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 提交到正式目录:backend/{tid}[/{tuid}]/yyyy/MM/dd/
|
||||||
|
result, err := storageService.Commit(staged, ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "上传文件失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
|
||||||
// 获取分类
|
// 获取分类
|
||||||
cateStr := c.GetString("cate")
|
cateStr := c.GetString("cate")
|
||||||
var cate uint64
|
var cate uint64
|
||||||
@@ -532,30 +606,24 @@ func (c *BackendFileController) UploadFile() {
|
|||||||
cate, _ = strconv.ParseUint(cateStr, 10, 64)
|
cate, _ = strconv.ParseUint(cateStr, 10, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
adminID := uint64(claims.UserID)
|
// 4. 保存文件记录到数据库
|
||||||
var tuidPtr *uint64
|
id, err := services.CreateFileRecord(services.FileUploadParams{
|
||||||
if ts := strings.TrimSpace(c.GetString("tuid")); ts != "" {
|
Source: services.SourceBackend,
|
||||||
if v, e := strconv.ParseUint(ts, 10, 64); e == nil {
|
Scope: scope,
|
||||||
tuidPtr = &v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存文件记录到数据库
|
|
||||||
row := &models.SystemFile{
|
|
||||||
Tid: tid,
|
Tid: tid,
|
||||||
Uid: &adminID,
|
Tuid: tuid,
|
||||||
Tuid: tuidPtr,
|
Uid: uint64(claims.UserID),
|
||||||
Name: header.Filename,
|
Name: header.Filename,
|
||||||
Type: detectFileType(ext),
|
Ext: ext,
|
||||||
Cate: cate,
|
Cate: cate,
|
||||||
Size: uint64(result.Size),
|
Size: uint64(result.Size),
|
||||||
Src: result.URL,
|
Src: result.URL,
|
||||||
Uploader: adminID,
|
ObjectKey: result.Key,
|
||||||
Md5: result.MD5,
|
Storage: storageService.Type(),
|
||||||
}
|
MD5: result.MD5,
|
||||||
id, err := models.Orm.Insert(row)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 数据库插入失败,尝试删除已上传的文件
|
// 数据库插入失败,删除已提交的文件
|
||||||
_ = storageService.Delete(result.Key)
|
_ = storageService.Delete(result.Key)
|
||||||
c.jsonErr(500, 500, "上传失败: "+err.Error())
|
c.jsonErr(500, 500, "上传失败: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -566,7 +634,7 @@ func (c *BackendFileController) UploadFile() {
|
|||||||
"msg": "上传成功",
|
"msg": "上传成功",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"url": result.URL,
|
"url": result.URL,
|
||||||
"id": uint64(id),
|
"id": id,
|
||||||
"name": header.Filename,
|
"name": header.Filename,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -700,7 +768,9 @@ func (c *BackendFileController) DeleteFilePermanently() {
|
|||||||
c.jsonErr(404, 404, "文件不存在")
|
c.jsonErr(404, 404, "文件不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
removePhysicalBySrc(f.Src)
|
if svc, e := services.GetStorageService(); e == nil {
|
||||||
|
_ = services.RemovePhysical(svc, f.ObjectKey, f.Src)
|
||||||
|
}
|
||||||
_, err = models.Orm.QueryTable(new(models.SystemFile)).
|
_, err = models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
Filter("id", id).
|
Filter("id", id).
|
||||||
Filter("tid", tid).
|
Filter("tid", tid).
|
||||||
@@ -773,17 +843,8 @@ func (c *BackendFileController) BatchDeleteFiles() {
|
|||||||
c.jsonErr(400, 400, "请选择要删除的文件")
|
c.jsonErr(400, 400, "请选择要删除的文件")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 软删除:只标记 delete_time,不删除物理文件(彻底删除接口才会删)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, id := range body.IDs {
|
|
||||||
var f models.SystemFile
|
|
||||||
e := models.Orm.QueryTable(new(models.SystemFile)).
|
|
||||||
Filter("id", id).
|
|
||||||
Filter("tid", tid).
|
|
||||||
One(&f)
|
|
||||||
if e == nil && f.Src != "" {
|
|
||||||
removePhysicalBySrc(f.Src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
n, err := models.Orm.QueryTable(new(models.SystemFile)).
|
n, err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
Filter("id__in", body.IDs).
|
Filter("id__in", body.IDs).
|
||||||
Filter("tid", tid).
|
Filter("tid", tid).
|
||||||
@@ -831,8 +892,10 @@ func (c *BackendFileController) BatchDeleteFilesPermanently() {
|
|||||||
c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error())
|
c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if svc, e := services.GetStorageService(); e == nil {
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
removePhysicalBySrc(rows[i].Src)
|
_ = services.RemovePhysical(svc, rows[i].ObjectKey, rows[i].Src)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
n, err := models.Orm.QueryTable(new(models.SystemFile)).
|
n, err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
Filter("id__in", body.IDs).
|
Filter("id__in", body.IDs).
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ func (c *PlatformFileController) effectiveTid(claims *jwtutil.Claims) uint64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepare 所有接口执行前补齐文件存储分层所需字段,避免历史库缺列导致查询/写入报错
|
||||||
|
func (c *PlatformFileController) Prepare() {
|
||||||
|
models.EnsureSystemFileStorageColumns()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *PlatformFileController) jsonErr(httpStatus, bizCode int, msg string) {
|
func (c *PlatformFileController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||||
c.Ctx.Output.SetStatus(httpStatus)
|
c.Ctx.Output.SetStatus(httpStatus)
|
||||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||||
@@ -167,6 +172,7 @@ func (c *PlatformFileController) GetAllFiles() {
|
|||||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
|
||||||
qs := models.Orm.QueryTable(new(models.SystemFile)).
|
qs := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
|
Filter("source", services.SourcePlatform).
|
||||||
Filter("tid", tid).
|
Filter("tid", tid).
|
||||||
Filter("delete_time__isnull", true)
|
Filter("delete_time__isnull", true)
|
||||||
if cate > 0 {
|
if cate > 0 {
|
||||||
@@ -402,6 +408,7 @@ func (c *PlatformFileController) GetCateFiles() {
|
|||||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
|
||||||
qs := models.Orm.QueryTable(new(models.SystemFile)).
|
qs := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
|
Filter("source", services.SourcePlatform).
|
||||||
Filter("tid", tid).
|
Filter("tid", tid).
|
||||||
Filter("cate", cateID).
|
Filter("cate", cateID).
|
||||||
Filter("delete_time__isnull", true)
|
Filter("delete_time__isnull", true)
|
||||||
@@ -466,11 +473,12 @@ func (c *PlatformFileController) UploadFile() {
|
|||||||
c.jsonErr(401, 401, err.Error())
|
c.jsonErr(401, 401, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tid := c.effectiveTid(claims)
|
|
||||||
if err := c.Ctx.Request.ParseMultipartForm(platformFileUploadMaxBytes); err != nil {
|
if err := c.Ctx.Request.ParseMultipartForm(platformFileUploadMaxBytes); err != nil {
|
||||||
c.jsonErr(400, 400, "解析上传失败: "+err.Error())
|
c.jsonErr(400, 400, "解析上传失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tid := c.effectiveTid(claims)
|
||||||
|
|
||||||
fh, header, err := c.GetFile("file")
|
fh, header, err := c.GetFile("file")
|
||||||
if err != nil || fh == nil {
|
if err != nil || fh == nil {
|
||||||
c.jsonErr(400, 400, "请选择要上传的文件")
|
c.jsonErr(400, 400, "请选择要上传的文件")
|
||||||
@@ -483,7 +491,7 @@ func (c *PlatformFileController) UploadFile() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := platformFileExt(header.Filename)
|
ext := services.FileExt(header.Filename)
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
c.jsonErr(400, 400, "无法识别文件扩展名")
|
c.jsonErr(400, 400, "无法识别文件扩展名")
|
||||||
return
|
return
|
||||||
@@ -496,22 +504,30 @@ func (c *PlatformFileController) UploadFile() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传文件
|
// 1. 先写入临时文件并计算 MD5(此时文件尚未进入正式目录)
|
||||||
result, err := storageService.Upload(fh, header)
|
staged, err := storageService.Stage(fh, header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.jsonErr(500, 500, "上传文件失败: "+err.Error())
|
c.jsonErr(500, 500, "读取上传文件失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed && staged != nil {
|
||||||
|
_ = storageService.Discard(staged)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 检查文件是否已存在(通过MD5)
|
// 平台端不做租户/用户分层,统一 platform/yyyy/MM/dd/
|
||||||
var exist models.SystemFile
|
ctx := services.UploadContext{Source: services.SourcePlatform, Tid: tid}
|
||||||
err = models.Orm.QueryTable(new(models.SystemFile)).
|
scope := ctx.Scope()
|
||||||
Filter("md5", result.MD5).
|
|
||||||
Filter("tid", tid).
|
// 2. 精确查重(平台端按 tid + MD5)
|
||||||
Filter("delete_time__isnull", true).
|
exist, err := services.FindDuplicate(services.SourcePlatform, scope, tid, 0, staged.MD5)
|
||||||
One(&exist)
|
if err != nil {
|
||||||
if err == nil {
|
c.jsonErr(500, 500, "查重失败: "+err.Error())
|
||||||
// 文件已存在,返回已有记录
|
return
|
||||||
|
}
|
||||||
|
if exist != nil {
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
"code": 201,
|
"code": 201,
|
||||||
"msg": "文件已存在",
|
"msg": "文件已存在",
|
||||||
@@ -525,6 +541,14 @@ func (c *PlatformFileController) UploadFile() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 提交到正式目录
|
||||||
|
result, err := storageService.Commit(staged, ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "上传文件失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
|
||||||
// 获取分类
|
// 获取分类
|
||||||
cateStr := c.GetString("cate")
|
cateStr := c.GetString("cate")
|
||||||
var cate uint64
|
var cate uint64
|
||||||
@@ -532,30 +556,22 @@ func (c *PlatformFileController) UploadFile() {
|
|||||||
cate, _ = strconv.ParseUint(cateStr, 10, 64)
|
cate, _ = strconv.ParseUint(cateStr, 10, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
adminID := uint64(claims.UserID)
|
// 4. 保存文件记录到数据库
|
||||||
var tuidPtr *uint64
|
id, err := services.CreateFileRecord(services.FileUploadParams{
|
||||||
if ts := strings.TrimSpace(c.GetString("tuid")); ts != "" {
|
Source: services.SourcePlatform,
|
||||||
if v, e := strconv.ParseUint(ts, 10, 64); e == nil {
|
Scope: scope,
|
||||||
tuidPtr = &v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存文件记录到数据库
|
|
||||||
row := &models.SystemFile{
|
|
||||||
Tid: tid,
|
Tid: tid,
|
||||||
Uid: &adminID,
|
Uid: uint64(claims.UserID),
|
||||||
Tuid: tuidPtr,
|
|
||||||
Name: header.Filename,
|
Name: header.Filename,
|
||||||
Type: platformDetectFileType(ext),
|
Ext: ext,
|
||||||
Cate: cate,
|
Cate: cate,
|
||||||
Size: uint64(result.Size),
|
Size: uint64(result.Size),
|
||||||
Src: result.URL,
|
Src: result.URL,
|
||||||
Uploader: adminID,
|
ObjectKey: result.Key,
|
||||||
Md5: result.MD5,
|
Storage: storageService.Type(),
|
||||||
}
|
MD5: result.MD5,
|
||||||
id, err := models.Orm.Insert(row)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 数据库插入失败,尝试删除已上传的文件
|
|
||||||
_ = storageService.Delete(result.Key)
|
_ = storageService.Delete(result.Key)
|
||||||
c.jsonErr(500, 500, "上传失败: "+err.Error())
|
c.jsonErr(500, 500, "上传失败: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -566,7 +582,7 @@ func (c *PlatformFileController) UploadFile() {
|
|||||||
"msg": "上传成功",
|
"msg": "上传成功",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"url": result.URL,
|
"url": result.URL,
|
||||||
"id": uint64(id),
|
"id": id,
|
||||||
"name": header.Filename,
|
"name": header.Filename,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -700,7 +716,9 @@ func (c *PlatformFileController) DeleteFilePermanently() {
|
|||||||
c.jsonErr(404, 404, "文件不存在")
|
c.jsonErr(404, 404, "文件不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
platformRemovePhysicalBySrc(f.Src)
|
if svc, e := services.GetStorageService(); e == nil {
|
||||||
|
_ = services.RemovePhysical(svc, f.ObjectKey, f.Src)
|
||||||
|
}
|
||||||
_, err = models.Orm.QueryTable(new(models.SystemFile)).
|
_, err = models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
Filter("id", id).
|
Filter("id", id).
|
||||||
Filter("tid", tid).
|
Filter("tid", tid).
|
||||||
@@ -773,17 +791,8 @@ func (c *PlatformFileController) BatchDeleteFiles() {
|
|||||||
c.jsonErr(400, 400, "请选择要删除的文件")
|
c.jsonErr(400, 400, "请选择要删除的文件")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 软删除:只标记 delete_time,不删除物理文件(彻底删除接口才会删)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, id := range body.IDs {
|
|
||||||
var f models.SystemFile
|
|
||||||
e := models.Orm.QueryTable(new(models.SystemFile)).
|
|
||||||
Filter("id", id).
|
|
||||||
Filter("tid", tid).
|
|
||||||
One(&f)
|
|
||||||
if e == nil && f.Src != "" {
|
|
||||||
platformRemovePhysicalBySrc(f.Src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
n, err := models.Orm.QueryTable(new(models.SystemFile)).
|
n, err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
Filter("id__in", body.IDs).
|
Filter("id__in", body.IDs).
|
||||||
Filter("tid", tid).
|
Filter("tid", tid).
|
||||||
@@ -831,8 +840,10 @@ func (c *PlatformFileController) BatchDeleteFilesPermanently() {
|
|||||||
c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error())
|
c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if svc, e := services.GetStorageService(); e == nil {
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
platformRemovePhysicalBySrc(rows[i].Src)
|
_ = services.RemovePhysical(svc, rows[i].ObjectKey, rows[i].Src)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
n, err := models.Orm.QueryTable(new(models.SystemFile)).
|
n, err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
Filter("id__in", body.IDs).
|
Filter("id__in", body.IDs).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
"server/pkg/jwtutil"
|
"server/pkg/jwtutil"
|
||||||
|
"server/services"
|
||||||
|
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
||||||
@@ -91,9 +92,10 @@ func (c *QiniuUploadController) GetUploadToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成文件 key(前端可以覆盖)
|
// 生成文件 key(前端可以覆盖)
|
||||||
|
// 平台端固定落在 platform/yyyy/MM/dd/ 下,与服务端中转上传保持同一套目录规则
|
||||||
datePath := time.Now().Format("2006/01/02")
|
datePath := time.Now().Format("2006/01/02")
|
||||||
timestamp := time.Now().UnixNano()
|
timestamp := time.Now().UnixNano()
|
||||||
keyPrefix := fmt.Sprintf("%s/%d", datePath, timestamp)
|
keyPrefix := fmt.Sprintf("%s/%s/%d", services.SourcePlatform, datePath, timestamp)
|
||||||
|
|
||||||
// 创建上传策略
|
// 创建上传策略
|
||||||
mac := qbox.NewMac(cfg.QiniuAccessKey, cfg.QiniuSecretKey)
|
mac := qbox.NewMac(cfg.QiniuAccessKey, cfg.QiniuSecretKey)
|
||||||
@@ -164,7 +166,8 @@ func (c *QiniuUploadController) SaveFileRecord() {
|
|||||||
domain := strings.TrimRight(cfg.QiniuDomain, "/")
|
domain := strings.TrimRight(cfg.QiniuDomain, "/")
|
||||||
fileURL := fmt.Sprintf("%s/%s", domain, req.Key)
|
fileURL := fmt.Sprintf("%s/%s", domain, req.Key)
|
||||||
|
|
||||||
// 计算 MD5(使用 hash 作为 MD5,或者重新计算)
|
// 注意:直传通道前端给的是七牛 etag(req.Hash),并非文件真实 MD5,
|
||||||
|
// 与服务端中转上传算出的 MD5 不同源,因此该通道的查重只在本通道内有效。
|
||||||
md5Sum := req.Hash
|
md5Sum := req.Hash
|
||||||
if md5Sum == "" {
|
if md5Sum == "" {
|
||||||
// 如果没有 hash,使用 key 生成一个唯一标识
|
// 如果没有 hash,使用 key 生成一个唯一标识
|
||||||
@@ -174,13 +177,12 @@ func (c *QiniuUploadController) SaveFileRecord() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查文件是否已存在(通过 MD5)
|
// 检查文件是否已存在(通过 MD5)
|
||||||
var exist models.SystemFile
|
exist, err := services.FindDuplicate(services.SourcePlatform, services.ScopeTenant, tid, 0, md5Sum)
|
||||||
err = models.Orm.QueryTable(new(models.SystemFile)).
|
if err != nil {
|
||||||
Filter("md5", md5Sum).
|
c.jsonErr(500, 500, "查重失败: "+err.Error())
|
||||||
Filter("tid", tid).
|
return
|
||||||
Filter("delete_time__isnull", true).
|
}
|
||||||
One(&exist)
|
if exist != nil {
|
||||||
if err == nil {
|
|
||||||
// 文件已存在,返回已有记录
|
// 文件已存在,返回已有记录
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
"code": 201,
|
"code": 201,
|
||||||
@@ -195,25 +197,22 @@ func (c *QiniuUploadController) SaveFileRecord() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检测文件类型
|
// 保存文件记录(平台端:source=platform,scope=tenant,object_key=七牛 key)
|
||||||
ext := getQiniuFileExt(req.Name)
|
// 文件类型由 services.CreateFileRecord 依据扩展名统一推断
|
||||||
fileType := detectQiniuFileType(ext)
|
id, err := services.CreateFileRecord(services.FileUploadParams{
|
||||||
|
Source: services.SourcePlatform,
|
||||||
// 保存文件记录
|
Scope: services.ScopeTenant,
|
||||||
adminID := uint64(claims.UserID)
|
|
||||||
row := &models.SystemFile{
|
|
||||||
Tid: tid,
|
Tid: tid,
|
||||||
Uid: &adminID,
|
Uid: uint64(claims.UserID),
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: fileType,
|
Ext: getQiniuFileExt(req.Name),
|
||||||
Cate: req.Cate,
|
Cate: req.Cate,
|
||||||
Size: uint64(req.Size),
|
Size: uint64(req.Size),
|
||||||
Src: fileURL,
|
Src: fileURL,
|
||||||
Uploader: adminID,
|
ObjectKey: req.Key,
|
||||||
Md5: md5Sum,
|
Storage: services.StorageTypeQiniu,
|
||||||
}
|
MD5: md5Sum,
|
||||||
|
})
|
||||||
id, err := models.Orm.Insert(row)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.jsonErr(500, 500, "保存文件记录失败: "+err.Error())
|
c.jsonErr(500, 500, "保存文件记录失败: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- 文件存储分层改造:yz_system_files 新增归属与存储字段
|
||||||
|
-- 对应计划文档:go/docs/文件存储分层改造计划.md S1
|
||||||
|
--
|
||||||
|
-- 幂等脚本:用存储过程逐项判断,已存在的列/索引自动跳过,可反复执行,不会报
|
||||||
|
-- 1060 Duplicate column / 1061 Duplicate key。
|
||||||
|
--
|
||||||
|
-- 背景:md5 在部分历史库中是 TEXT 类型,MySQL 不允许对 TEXT/BLOB 建整列索引,
|
||||||
|
-- 因此 idx_file_dedup 使用前缀长度 md5(32)(MD5 十六进制串固定 32 字符,等价全值)。
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
|
||||||
|
CREATE PROCEDURE `yz_alter_system_files_storage`()
|
||||||
|
BEGIN
|
||||||
|
DECLARE v_db VARCHAR(64);
|
||||||
|
SET v_db = DATABASE();
|
||||||
|
|
||||||
|
-- 1. 字段:source
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'source'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE `yz_system_files`
|
||||||
|
ADD COLUMN `source` varchar(16) NOT NULL DEFAULT 'backend'
|
||||||
|
COMMENT '来源端: backend-租户后台 platform-平台端';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. 字段:scope
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'scope'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE `yz_system_files`
|
||||||
|
ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT 'tenant'
|
||||||
|
COMMENT '归属: tenant-租户共享 user-用户个人';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. 字段:storage
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'storage'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE `yz_system_files`
|
||||||
|
ADD COLUMN `storage` varchar(16) NOT NULL DEFAULT ''
|
||||||
|
COMMENT '存储类型: local/qiniu(冗余,便于迁移与排查)';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. 字段:object_key
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'object_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE `yz_system_files`
|
||||||
|
ADD COLUMN `object_key` varchar(512) NOT NULL DEFAULT ''
|
||||||
|
COMMENT '存储相对路径(不含域名),用于迁移与精确删除';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 5. 索引:查重(source + scope + tid + tuid + md5 前缀)
|
||||||
|
-- 不加 UNIQUE:软删(delete_time)与并发上传下唯一索引会直接报错,去重在代码层完成
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_dedup'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE `yz_system_files`
|
||||||
|
ADD KEY `idx_file_dedup` (`source`, `scope`, `tid`, `tuid`, `md5`(32));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 6. 索引:归属列表过滤
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_owner'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE `yz_system_files`
|
||||||
|
ADD KEY `idx_file_owner` (`source`, `tid`, `scope`, `tuid`, `delete_time`);
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
CALL `yz_alter_system_files_storage`();
|
||||||
|
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
|
||||||
|
|
||||||
|
-- 7. 老数据初始化:迁移脚本执行前先打底,避免 source/scope 为空导致查询遗漏
|
||||||
|
UPDATE `yz_system_files` SET `source` = 'backend' WHERE `source` = '' OR `source` IS NULL;
|
||||||
|
UPDATE `yz_system_files` SET `scope` = 'tenant' WHERE `scope` = '' OR `scope` IS NULL;
|
||||||
|
|
||||||
|
-- 8. 校验(应看到 4 个字段 + 2 个索引)
|
||||||
|
-- SHOW COLUMNS FROM yz_system_files LIKE 'source';
|
||||||
|
-- SHOW COLUMNS FROM yz_system_files LIKE 'scope';
|
||||||
|
-- SHOW COLUMNS FROM yz_system_files LIKE 'storage';
|
||||||
|
-- SHOW COLUMNS FROM yz_system_files LIKE 'object_key';
|
||||||
|
-- SHOW INDEX FROM yz_system_files WHERE Key_name IN ('idx_file_dedup','idx_file_owner');
|
||||||
@@ -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 全部更新且可访问
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// SystemFile 附件表 yz_system_files
|
// SystemFile 附件表 yz_system_files
|
||||||
|
//
|
||||||
|
// 归属语义(2026-09-09 文件存储分层改造后明确):
|
||||||
|
// - tid : 租户 ID
|
||||||
|
// - uid : 上传者 ID
|
||||||
|
// - tuid : 归属用户 ID(scope=user 时必填;scope=tenant 时为 NULL)
|
||||||
|
// - source: 来源端 backend / platform
|
||||||
|
// - scope : tenant-租户共享 / user-用户个人
|
||||||
|
// - object_key: 存储相对路径,不含域名,如 backend/234573/67091493/2026/09/09/xxx.png
|
||||||
type SystemFile struct {
|
type SystemFile struct {
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||||
@@ -15,6 +26,10 @@ type SystemFile struct {
|
|||||||
Src string `orm:"column(src);size(512)" json:"src"`
|
Src string `orm:"column(src);size(512)" json:"src"`
|
||||||
Uploader uint64 `orm:"column(uploader);default(0)" json:"uploader"`
|
Uploader uint64 `orm:"column(uploader);default(0)" json:"uploader"`
|
||||||
Md5 string `orm:"column(md5);size(32)" json:"md5"`
|
Md5 string `orm:"column(md5);size(32)" json:"md5"`
|
||||||
|
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"`
|
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"`
|
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"`
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
@@ -23,3 +38,44 @@ type SystemFile struct {
|
|||||||
func (m *SystemFile) TableName() string {
|
func (m *SystemFile) TableName() string {
|
||||||
return "yz_system_files"
|
return "yz_system_files"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 归属与存储字段的取值常量
|
||||||
|
const (
|
||||||
|
FileSourceBackend = "backend" // 租户后台端
|
||||||
|
FileSourcePlatform = "platform" // 平台端
|
||||||
|
|
||||||
|
FileScopeTenant = "tenant" // 租户共享文件
|
||||||
|
FileScopeUser = "user" // 用户个人文件
|
||||||
|
)
|
||||||
|
|
||||||
|
var systemFileStorageColumnsOnce sync.Once
|
||||||
|
|
||||||
|
// EnsureSystemFileStorageColumns 为附件表补齐文件存储分层改造所需字段。
|
||||||
|
// 历史库可能早于该特性建表,字段/索引已存在时 MySQL 报 duplicate,统一忽略,不阻断主流程。
|
||||||
|
// 写入 yz_system_files 之前必须调用,否则 INSERT 会因 Unknown column 失败。
|
||||||
|
func EnsureSystemFileStorageColumns() {
|
||||||
|
if Orm == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
systemFileStorageColumnsOnce.Do(func() {
|
||||||
|
alters := []string{
|
||||||
|
"ALTER TABLE yz_system_files ADD COLUMN source varchar(16) NOT NULL DEFAULT 'backend' COMMENT '来源端: backend-租户后台 platform-平台端'",
|
||||||
|
"ALTER TABLE yz_system_files ADD COLUMN scope varchar(16) NOT NULL DEFAULT 'tenant' COMMENT '归属: tenant-租户共享 user-用户个人'",
|
||||||
|
"ALTER TABLE yz_system_files ADD COLUMN storage varchar(16) NOT NULL DEFAULT '' COMMENT '存储类型: local/qiniu'",
|
||||||
|
"ALTER TABLE yz_system_files ADD COLUMN object_key varchar(512) NOT NULL DEFAULT '' COMMENT '存储相对路径(不含域名)'",
|
||||||
|
}
|
||||||
|
for _, sql := range alters {
|
||||||
|
_, _ = Orm.Raw(sql).Exec()
|
||||||
|
}
|
||||||
|
// 索引:MD5 精确去重 + 归属列表过滤
|
||||||
|
// md5 在部分历史库中是 TEXT 类型,MySQL 不允许对 TEXT/BLOB 建整列索引,
|
||||||
|
// 故使用前缀长度 md5(32)(MD5 十六进制串固定 32 字符)。
|
||||||
|
indexes := []string{
|
||||||
|
"ALTER TABLE yz_system_files ADD KEY idx_file_dedup (source, scope, tid, tuid, md5(32))",
|
||||||
|
"ALTER TABLE yz_system_files ADD KEY idx_file_owner (source, tid, scope, tuid, delete_time)",
|
||||||
|
}
|
||||||
|
for _, sql := range indexes {
|
||||||
|
_, _ = Orm.Raw(sql).Exec()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
@@ -16,6 +15,7 @@ type MigrationProgress struct {
|
|||||||
Total int
|
Total int
|
||||||
Success int
|
Success int
|
||||||
Failed int
|
Failed int
|
||||||
|
Skipped int
|
||||||
Current string
|
Current string
|
||||||
Errors []string
|
Errors []string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -28,6 +28,13 @@ func (p *MigrationProgress) AddSuccess() {
|
|||||||
p.Success++
|
p.Success++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddSkipped 增加跳过计数
|
||||||
|
func (p *MigrationProgress) AddSkipped() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.Skipped++
|
||||||
|
}
|
||||||
|
|
||||||
// AddFailed 增加失败计数
|
// AddFailed 增加失败计数
|
||||||
func (p *MigrationProgress) AddFailed(err string) {
|
func (p *MigrationProgress) AddFailed(err string) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@@ -68,49 +75,89 @@ func NewStorageMigration(from, to StorageService) *StorageMigration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrateFile 迁移单个文件
|
// TargetKey 计算某条文件记录改造后的新 key
|
||||||
|
func TargetKey(f *models.SystemFile) string {
|
||||||
|
source := f.Source
|
||||||
|
if source == "" {
|
||||||
|
source = SourceBackend
|
||||||
|
}
|
||||||
|
var tuid uint64
|
||||||
|
if f.Tuid != nil {
|
||||||
|
tuid = *f.Tuid
|
||||||
|
}
|
||||||
|
return BuildObjectKey(UploadContext{Source: source, Tid: f.Tid, Tuid: tuid}, FileExt(f.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateFile 迁移单个文件到新的分层目录
|
||||||
|
//
|
||||||
|
// 同类型存储(七牛→七牛 / 本地→本地)走服务端改名,不重新上传、不消耗流量;
|
||||||
|
// 本地→七牛 走"读取 + 上传",其余跨存储方向暂不支持。
|
||||||
func (m *StorageMigration) MigrateFile(file *models.SystemFile) error {
|
func (m *StorageMigration) MigrateFile(file *models.SystemFile) error {
|
||||||
m.progress.SetCurrent(file.Name)
|
m.progress.SetCurrent(file.Name)
|
||||||
|
|
||||||
// 如果是本地存储,从本地读取文件
|
oldKey := file.ObjectKey
|
||||||
if localFrom, ok := m.fromService.(*LocalStorage); ok {
|
if oldKey == "" {
|
||||||
// 从本地文件系统读取
|
oldKey = KeyFromSrc(file.Src, m.fromService)
|
||||||
localPath := strings.TrimPrefix(file.Src, "/")
|
}
|
||||||
filePath := filepath.Join(localFrom.BaseDir, localPath)
|
if oldKey == "" {
|
||||||
|
return fmt.Errorf("无法解析原存储路径: %s", file.Src)
|
||||||
|
}
|
||||||
|
|
||||||
f, err := os.Open(filePath)
|
newKey := TargetKey(file)
|
||||||
|
if oldKey == newKey {
|
||||||
|
m.progress.AddSkipped()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.fromService.Type() == m.toService.Type() {
|
||||||
|
if err := m.toService.Move(oldKey, newKey); err != nil {
|
||||||
|
return fmt.Errorf("移动文件失败: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 跨存储:仅支持 本地 → 七牛
|
||||||
|
localFrom, ok := m.fromService.(*LocalStorage)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("暂不支持从 %s 迁出到 %s", m.fromService.Type(), m.toService.Type())
|
||||||
|
}
|
||||||
|
localPath := filepath.Join(localFrom.BaseDir, filepath.FromSlash(oldKey))
|
||||||
|
f, err := os.Open(localPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("打开本地文件失败: %w", err)
|
return fmt.Errorf("打开本地文件失败: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
// 获取文件信息
|
|
||||||
stat, err := f.Stat()
|
stat, err := f.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("获取文件信息失败: %w", err)
|
return fmt.Errorf("获取文件信息失败: %w", err)
|
||||||
}
|
}
|
||||||
|
header := &multipart.FileHeader{Filename: file.Name, Size: stat.Size()}
|
||||||
|
|
||||||
// 创建 multipart.FileHeader
|
staged, err := m.toService.Stage(f, header)
|
||||||
header := &multipart.FileHeader{
|
|
||||||
Filename: file.Name,
|
|
||||||
Size: stat.Size(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传到目标存储
|
|
||||||
result, err := m.toService.Upload(f, header)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("上传到目标存储失败: %w", err)
|
return err
|
||||||
|
}
|
||||||
|
source := file.Source
|
||||||
|
if source == "" {
|
||||||
|
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)
|
||||||
_, err = models.Orm.QueryTable(new(models.SystemFile)).
|
if _, err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
Filter("id", file.ID).
|
Filter("id", file.ID).
|
||||||
Update(map[string]interface{}{
|
Update(map[string]interface{}{
|
||||||
"src": result.URL,
|
"src": newSrc,
|
||||||
})
|
"object_key": newKey,
|
||||||
if err != nil {
|
"storage": m.toService.Type(),
|
||||||
// 上传成功但更新数据库失败,尝试删除已上传的文件
|
}); err != nil {
|
||||||
_ = m.toService.Delete(result.Key)
|
|
||||||
return fmt.Errorf("更新数据库失败: %w", err)
|
return fmt.Errorf("更新数据库失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,25 +165,19 @@ func (m *StorageMigration) MigrateFile(file *models.SystemFile) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是七牛云存储,需要先下载再上传(这里简化处理)
|
|
||||||
return fmt.Errorf("暂不支持从七牛云迁移到本地")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrateAll 迁移所有文件
|
// MigrateAll 迁移所有文件
|
||||||
func (m *StorageMigration) MigrateAll(tid uint64) error {
|
func (m *StorageMigration) MigrateAll(tid uint64) error {
|
||||||
// 获取所有文件
|
|
||||||
var files []models.SystemFile
|
var files []models.SystemFile
|
||||||
_, err := models.Orm.QueryTable(new(models.SystemFile)).
|
qs := models.Orm.QueryTable(new(models.SystemFile)).Filter("delete_time__isnull", true)
|
||||||
Filter("tid", tid).
|
if tid > 0 {
|
||||||
Filter("delete_time__isnull", true).
|
qs = qs.Filter("tid", tid)
|
||||||
All(&files)
|
}
|
||||||
if err != nil {
|
if _, err := qs.All(&files); err != nil {
|
||||||
return fmt.Errorf("获取文件列表失败: %w", err)
|
return fmt.Errorf("获取文件列表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.progress.Total = len(files)
|
m.progress.Total = len(files)
|
||||||
|
|
||||||
// 并发迁移(限制并发数)
|
|
||||||
concurrency := 5
|
concurrency := 5
|
||||||
sem := make(chan struct{}, concurrency)
|
sem := make(chan struct{}, concurrency)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@@ -145,8 +186,8 @@ func (m *StorageMigration) MigrateAll(tid uint64) error {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(file *models.SystemFile) {
|
go func(file *models.SystemFile) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
sem <- struct{}{} // 获取信号量
|
sem <- struct{}{}
|
||||||
defer func() { <-sem }() // 释放信号量
|
defer func() { <-sem }()
|
||||||
|
|
||||||
if err := m.MigrateFile(file); err != nil {
|
if err := m.MigrateFile(file); err != nil {
|
||||||
m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err))
|
m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err))
|
||||||
@@ -165,27 +206,17 @@ func (m *StorageMigration) GetProgress() *MigrationProgress {
|
|||||||
|
|
||||||
// MigrateLocalToQiniu 从本地存储迁移到七牛云
|
// MigrateLocalToQiniu 从本地存储迁移到七牛云
|
||||||
func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) {
|
func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) {
|
||||||
// 获取存储配置
|
|
||||||
cfg, err := models.GetStorageConfig()
|
cfg, err := models.GetStorageConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("获取存储配置失败: %w", err)
|
return nil, fmt.Errorf("获取存储配置失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if cfg.StorageType != StorageTypeQiniu {
|
||||||
if cfg.StorageType != "qiniu" {
|
|
||||||
return nil, fmt.Errorf("当前存储类型不是七牛云")
|
return nil, fmt.Errorf("当前存储类型不是七牛云")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建存储服务
|
migration := NewStorageMigration(NewLocalStorage(), NewQiniuStorage(cfg))
|
||||||
localStorage := NewLocalStorage()
|
|
||||||
qiniuStorage := NewQiniuStorage(cfg)
|
|
||||||
|
|
||||||
// 创建迁移服务
|
|
||||||
migration := NewStorageMigration(localStorage, qiniuStorage)
|
|
||||||
|
|
||||||
// 执行迁移
|
|
||||||
if err := migration.MigrateAll(tid); err != nil {
|
if err := migration.MigrateAll(tid); err != nil {
|
||||||
return migration.GetProgress(), err
|
return migration.GetProgress(), err
|
||||||
}
|
}
|
||||||
|
|
||||||
return migration.GetProgress(), nil
|
return migration.GetProgress(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
+282
-94
@@ -3,11 +3,13 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,22 +20,116 @@ import (
|
|||||||
"github.com/qiniu/go-sdk/v7/storage"
|
"github.com/qiniu/go-sdk/v7/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StorageService 存储服务接口
|
// 来源端
|
||||||
type StorageService interface {
|
const (
|
||||||
Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error)
|
SourceBackend = "backend" // 租户后台端
|
||||||
GetPublicURL(key string) string
|
SourcePlatform = "platform" // 平台端
|
||||||
Delete(key string) error
|
)
|
||||||
|
|
||||||
|
// 归属范围
|
||||||
|
const (
|
||||||
|
ScopeTenant = "tenant" // 租户共享文件
|
||||||
|
ScopeUser = "user" // 用户个人文件
|
||||||
|
)
|
||||||
|
|
||||||
|
// 存储类型
|
||||||
|
const (
|
||||||
|
StorageTypeLocal = "local"
|
||||||
|
StorageTypeQiniu = "qiniu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadContext 上传上下文,决定文件最终的存储路径
|
||||||
|
type UploadContext struct {
|
||||||
|
Source string // backend / platform
|
||||||
|
Tid uint64 // 租户 ID(platform 端可为 0)
|
||||||
|
Tuid uint64 // 归属用户 ID,0 表示租户共享文件
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope 返回归属范围:带了归属用户即个人文件,否则为租户共享文件。
|
||||||
|
// 平台端不做用户分层,统一按租户共享处理。
|
||||||
|
func (c UploadContext) Scope() string {
|
||||||
|
if c.Source == SourcePlatform {
|
||||||
|
return ScopeTenant
|
||||||
|
}
|
||||||
|
if c.Tuid > 0 {
|
||||||
|
return ScopeUser
|
||||||
|
}
|
||||||
|
return ScopeTenant
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildObjectKey 生成存储相对路径(不含域名、不含本地 BaseDir)
|
||||||
|
//
|
||||||
|
// backend 共享: backend/234573/2026/09/09/xxx.png
|
||||||
|
// backend 个人: backend/234573/67091493/2026/09/09/xxx.png
|
||||||
|
// platform : platform/2026/09/09/xxx.png
|
||||||
|
func BuildObjectKey(ctx UploadContext, ext string) string {
|
||||||
|
datePath := time.Now().Format("2006/01/02")
|
||||||
|
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomHex(6), normalizeExt(ext))
|
||||||
|
|
||||||
|
if ctx.Source == SourcePlatform {
|
||||||
|
return path.Join(SourcePlatform, datePath, name)
|
||||||
|
}
|
||||||
|
if ctx.Tuid > 0 {
|
||||||
|
return path.Join(SourceBackend, fmt.Sprint(ctx.Tid), fmt.Sprint(ctx.Tuid), datePath, name)
|
||||||
|
}
|
||||||
|
return path.Join(SourceBackend, fmt.Sprint(ctx.Tid), datePath, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeExt 规范扩展名:小写、补前导点
|
||||||
|
func normalizeExt(ext string) string {
|
||||||
|
ext = strings.TrimSpace(ext)
|
||||||
|
if ext == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ext, ".") {
|
||||||
|
ext = "." + ext
|
||||||
|
}
|
||||||
|
return strings.ToLower(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomHex 生成 n 位十六进制随机串(使用 crypto/rand,无需种子)
|
||||||
|
func randomHex(n int) string {
|
||||||
|
b := make([]byte, (n+1)/2)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return fmt.Sprint(time.Now().UnixNano() % 1000000)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
// StagedFile 已落临时文件、算完 MD5 的待提交文件
|
||||||
|
type StagedFile struct {
|
||||||
|
TempPath string // 本地临时文件路径
|
||||||
|
MD5 string // 文件内容 MD5
|
||||||
|
Size int64 // 文件大小
|
||||||
|
MimeType string // 文件类型
|
||||||
|
Ext string // 扩展名(含点)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResult 上传结果
|
// UploadResult 上传结果
|
||||||
type UploadResult struct {
|
type UploadResult struct {
|
||||||
URL string // 完整访问URL
|
URL string // 完整访问URL
|
||||||
Key string // 存储key/路径
|
Key string // 存储key/相对路径(object_key)
|
||||||
Size int64 // 文件大小
|
Size int64 // 文件大小
|
||||||
MD5 string // 文件MD5
|
MD5 string // 文件MD5
|
||||||
MimeType string // 文件类型
|
MimeType string // 文件类型
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StorageService 存储服务接口
|
||||||
|
type StorageService interface {
|
||||||
|
// Stage 把上传流写入临时文件并计算 MD5,此时文件尚未进入正式存储目录
|
||||||
|
Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error)
|
||||||
|
// Commit 把临时文件提交到按 ctx 计算出的正式路径;返回访问 URL 与 object_key
|
||||||
|
Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error)
|
||||||
|
// Discard 丢弃临时文件(去重命中或出错时调用)
|
||||||
|
Discard(staged *StagedFile) error
|
||||||
|
GetPublicURL(key string) string
|
||||||
|
Delete(key string) error
|
||||||
|
// Move 把已存文件从 oldKey 改名到 newKey(迁移用:本地 Rename / 七牛 Move)
|
||||||
|
Move(oldKey, newKey string) error
|
||||||
|
// Type 返回存储类型 local / qiniu
|
||||||
|
Type() string
|
||||||
|
}
|
||||||
|
|
||||||
// LocalStorage 本地存储实现
|
// LocalStorage 本地存储实现
|
||||||
type LocalStorage struct {
|
type LocalStorage struct {
|
||||||
BaseDir string // 基础目录,默认 "uploads"
|
BaseDir string // 基础目录,默认 "uploads"
|
||||||
@@ -48,51 +144,83 @@ func NewLocalStorage() *LocalStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload 上传文件到本地
|
// Type 存储类型
|
||||||
func (s *LocalStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) {
|
func (s *LocalStorage) Type() string { return StorageTypeLocal }
|
||||||
// 生成存储路径
|
|
||||||
ext := filepath.Ext(header.Filename)
|
|
||||||
datePath := time.Now().Format("2006/01/02")
|
|
||||||
fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
|
|
||||||
savePath := filepath.Join(datePath, fileName)
|
|
||||||
|
|
||||||
// 创建目录
|
// tempDir 临时目录:与正式目录同盘,保证 Commit 时 os.Rename 不跨设备
|
||||||
destDir := filepath.Join(s.BaseDir, filepath.FromSlash(datePath))
|
func (s *LocalStorage) tempDir() string {
|
||||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
return filepath.Join(s.BaseDir, ".tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 写入临时文件并计算 MD5
|
||||||
|
func (s *LocalStorage) Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error) {
|
||||||
|
dir := s.tempDir()
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建临时目录失败: %w", err)
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(dir, "stage_*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建临时文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer tmp.Close()
|
||||||
|
|
||||||
|
hash := md5.New()
|
||||||
|
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
|
||||||
|
if err != nil {
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return nil, fmt.Errorf("读取上传文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StagedFile{
|
||||||
|
TempPath: tmp.Name(),
|
||||||
|
MD5: hex.EncodeToString(hash.Sum(nil)),
|
||||||
|
Size: size,
|
||||||
|
MimeType: mimeTypeOf(header),
|
||||||
|
Ext: normalizeExt(filepath.Ext(header.Filename)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit 提交到正式目录
|
||||||
|
func (s *LocalStorage) Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error) {
|
||||||
|
if staged == nil {
|
||||||
|
return nil, fmt.Errorf("待提交文件为空")
|
||||||
|
}
|
||||||
|
key := BuildObjectKey(ctx, staged.Ext)
|
||||||
|
destPath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||||
return nil, fmt.Errorf("创建目录失败: %w", err)
|
return nil, fmt.Errorf("创建目录失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存文件
|
// 同盘优先 Rename;跨设备失败则回退复制
|
||||||
destPath := filepath.Join(s.BaseDir, filepath.FromSlash(savePath))
|
if err := os.Rename(staged.TempPath, destPath); err != nil {
|
||||||
dst, err := os.Create(destPath)
|
if copyErr := copyFile(staged.TempPath, destPath); copyErr != nil {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("保存文件失败: %w", copyErr)
|
||||||
return nil, fmt.Errorf("创建文件失败: %w", err)
|
|
||||||
}
|
}
|
||||||
defer dst.Close()
|
_ = os.Remove(staged.TempPath)
|
||||||
|
|
||||||
// 计算MD5并复制文件
|
|
||||||
hash := md5.New()
|
|
||||||
size, err := io.Copy(io.MultiWriter(dst, hash), file)
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(destPath)
|
|
||||||
return nil, fmt.Errorf("保存文件失败: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
md5Sum := hex.EncodeToString(hash.Sum(nil))
|
|
||||||
webURL := s.BaseURL + strings.ReplaceAll(filepath.ToSlash(destPath), "\\", "/")
|
|
||||||
|
|
||||||
return &UploadResult{
|
return &UploadResult{
|
||||||
URL: webURL,
|
URL: s.GetPublicURL(key),
|
||||||
Key: savePath,
|
Key: key,
|
||||||
Size: size,
|
Size: staged.Size,
|
||||||
MD5: md5Sum,
|
MD5: staged.MD5,
|
||||||
MimeType: header.Header.Get("Content-Type"),
|
MimeType: staged.MimeType,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discard 删除临时文件
|
||||||
|
func (s *LocalStorage) Discard(staged *StagedFile) error {
|
||||||
|
if staged == nil || staged.TempPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.Remove(staged.TempPath)
|
||||||
|
}
|
||||||
|
|
||||||
// GetPublicURL 获取公开访问URL
|
// GetPublicURL 获取公开访问URL
|
||||||
func (s *LocalStorage) GetPublicURL(key string) string {
|
func (s *LocalStorage) GetPublicURL(key string) string {
|
||||||
return s.BaseURL + filepath.ToSlash(filepath.Join(s.BaseDir, key))
|
full := filepath.ToSlash(filepath.Join(s.BaseDir, filepath.FromSlash(key)))
|
||||||
|
return s.BaseURL + strings.ReplaceAll(full, "\\", "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除本地文件
|
// Delete 删除本地文件
|
||||||
@@ -101,6 +229,23 @@ func (s *LocalStorage) Delete(key string) error {
|
|||||||
return os.Remove(filePath)
|
return os.Remove(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move 本地改名(同盘,不搬数据)
|
||||||
|
func (s *LocalStorage) Move(oldKey, newKey string) error {
|
||||||
|
oldPath := filepath.Join(s.BaseDir, filepath.FromSlash(oldKey))
|
||||||
|
newPath := filepath.Join(s.BaseDir, filepath.FromSlash(newKey))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("创建目标目录失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(oldPath, newPath); err != nil {
|
||||||
|
// 跨设备时回退为复制 + 删除
|
||||||
|
if copyErr := copyFile(oldPath, newPath); copyErr != nil {
|
||||||
|
return fmt.Errorf("移动文件失败: %w", copyErr)
|
||||||
|
}
|
||||||
|
_ = os.Remove(oldPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// QiniuStorage 七牛云存储实现
|
// QiniuStorage 七牛云存储实现
|
||||||
type QiniuStorage struct {
|
type QiniuStorage struct {
|
||||||
AccessKey string
|
AccessKey string
|
||||||
@@ -121,6 +266,9 @@ func NewQiniuStorage(cfg *models.StorageConfig) *QiniuStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Type 存储类型
|
||||||
|
func (s *QiniuStorage) Type() string { return StorageTypeQiniu }
|
||||||
|
|
||||||
// getZone 根据区域代码获取存储区域
|
// getZone 根据区域代码获取存储区域
|
||||||
func (s *QiniuStorage) getZone() *storage.Region {
|
func (s *QiniuStorage) getZone() *storage.Region {
|
||||||
switch s.Region {
|
switch s.Region {
|
||||||
@@ -141,72 +289,77 @@ func (s *QiniuStorage) getZone() *storage.Region {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload 上传文件到七牛云
|
// Stage 写入临时文件并计算 MD5(七牛直传拿不到内容,这里统一先落临时文件算 MD5,
|
||||||
func (s *QiniuStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) {
|
// 便于去重;未命中才会真正上传到七牛)
|
||||||
// 生成存储key
|
func (s *QiniuStorage) Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error) {
|
||||||
ext := filepath.Ext(header.Filename)
|
tmp, err := os.CreateTemp("", "yz_upload_*")
|
||||||
datePath := time.Now().Format("2006/01/02")
|
if err != nil {
|
||||||
fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
|
return nil, fmt.Errorf("创建临时文件失败: %w", err)
|
||||||
key := filepath.ToSlash(filepath.Join(datePath, fileName))
|
|
||||||
|
|
||||||
// 创建上传凭证
|
|
||||||
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
|
|
||||||
putPolicy := storage.PutPolicy{
|
|
||||||
Scope: s.Bucket,
|
|
||||||
}
|
}
|
||||||
|
defer tmp.Close()
|
||||||
|
|
||||||
|
hash := md5.New()
|
||||||
|
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmp.Name())
|
||||||
|
return nil, fmt.Errorf("读取上传文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StagedFile{
|
||||||
|
TempPath: tmp.Name(),
|
||||||
|
MD5: hex.EncodeToString(hash.Sum(nil)),
|
||||||
|
Size: size,
|
||||||
|
MimeType: mimeTypeOf(header),
|
||||||
|
Ext: normalizeExt(filepath.Ext(header.Filename)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit 上传到七牛云
|
||||||
|
func (s *QiniuStorage) Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error) {
|
||||||
|
if staged == nil {
|
||||||
|
return nil, fmt.Errorf("待提交文件为空")
|
||||||
|
}
|
||||||
|
key := BuildObjectKey(ctx, staged.Ext)
|
||||||
|
|
||||||
|
f, err := os.Open(staged.TempPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开临时文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
|
||||||
|
putPolicy := storage.PutPolicy{Scope: s.Bucket}
|
||||||
upToken := putPolicy.UploadToken(mac)
|
upToken := putPolicy.UploadToken(mac)
|
||||||
|
|
||||||
// 配置上传参数
|
|
||||||
cfg := storage.Config{
|
cfg := storage.Config{
|
||||||
Region: s.getZone(),
|
Region: s.getZone(),
|
||||||
UseHTTPS: true,
|
UseHTTPS: true,
|
||||||
UseCdnDomains: false,
|
UseCdnDomains: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建表单上传器
|
|
||||||
formUploader := storage.NewFormUploader(&cfg)
|
formUploader := storage.NewFormUploader(&cfg)
|
||||||
ret := storage.PutRet{}
|
ret := storage.PutRet{}
|
||||||
putExtra := storage.PutExtra{}
|
|
||||||
|
|
||||||
// 计算文件大小和MD5
|
if err := formUploader.Put(context.Background(), &ret, upToken, key, f, staged.Size, &storage.PutExtra{}); err != nil {
|
||||||
tmpFile, err := os.CreateTemp("", "qiniu_upload_*")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("创建临时文件失败: %w", err)
|
|
||||||
}
|
|
||||||
defer os.Remove(tmpFile.Name())
|
|
||||||
defer tmpFile.Close()
|
|
||||||
|
|
||||||
hash := md5.New()
|
|
||||||
size, err := io.Copy(io.MultiWriter(tmpFile, hash), file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("读取文件失败: %w", err)
|
|
||||||
}
|
|
||||||
md5Sum := hex.EncodeToString(hash.Sum(nil))
|
|
||||||
|
|
||||||
// 重置文件指针
|
|
||||||
if _, err := tmpFile.Seek(0, 0); err != nil {
|
|
||||||
return nil, fmt.Errorf("重置文件指针失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行上传
|
|
||||||
err = formUploader.Put(context.Background(), &ret, upToken, key, tmpFile, size, &putExtra)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("上传到七牛云失败: %w", err)
|
return nil, fmt.Errorf("上传到七牛云失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建完整URL
|
|
||||||
domain := strings.TrimRight(s.Domain, "/")
|
|
||||||
url := fmt.Sprintf("%s/%s", domain, ret.Key)
|
|
||||||
|
|
||||||
return &UploadResult{
|
return &UploadResult{
|
||||||
URL: url,
|
URL: s.GetPublicURL(key),
|
||||||
Key: ret.Key,
|
Key: key,
|
||||||
Size: size,
|
Size: staged.Size,
|
||||||
MD5: md5Sum,
|
MD5: staged.MD5,
|
||||||
MimeType: header.Header.Get("Content-Type"),
|
MimeType: staged.MimeType,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discard 删除临时文件
|
||||||
|
func (s *QiniuStorage) Discard(staged *StagedFile) error {
|
||||||
|
if staged == nil || staged.TempPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.Remove(staged.TempPath)
|
||||||
|
}
|
||||||
|
|
||||||
// GetPublicURL 获取七牛云公开访问URL
|
// GetPublicURL 获取七牛云公开访问URL
|
||||||
func (s *QiniuStorage) GetPublicURL(key string) string {
|
func (s *QiniuStorage) GetPublicURL(key string) string {
|
||||||
domain := strings.TrimRight(s.Domain, "/")
|
domain := strings.TrimRight(s.Domain, "/")
|
||||||
@@ -215,16 +368,25 @@ func (s *QiniuStorage) GetPublicURL(key string) string {
|
|||||||
|
|
||||||
// Delete 删除七牛云文件
|
// Delete 删除七牛云文件
|
||||||
func (s *QiniuStorage) Delete(key string) error {
|
func (s *QiniuStorage) Delete(key string) error {
|
||||||
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
|
return s.runBucketManager(func(bm *storage.BucketManager) error {
|
||||||
cfg := storage.Config{
|
return bm.Delete(s.Bucket, key)
|
||||||
Region: s.getZone(),
|
}, "删除七牛云文件失败")
|
||||||
UseHTTPS: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bucketManager := storage.NewBucketManager(mac, &cfg)
|
// Move 七牛服务端改名(同 bucket 内原子操作,不走流量)
|
||||||
err := bucketManager.Delete(s.Bucket, key)
|
func (s *QiniuStorage) Move(oldKey, newKey string) error {
|
||||||
if err != nil {
|
return s.runBucketManager(func(bm *storage.BucketManager) error {
|
||||||
return fmt.Errorf("删除七牛云文件失败: %w", err)
|
// force=false:目标 key 已存在时直接报错,避免覆盖,保证迁移可重复执行
|
||||||
|
return bm.Move(s.Bucket, oldKey, s.Bucket, newKey, false)
|
||||||
|
}, "移动七牛云文件失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QiniuStorage) runBucketManager(fn func(*storage.BucketManager) error, errMsg string) error {
|
||||||
|
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
|
||||||
|
cfg := storage.Config{Region: s.getZone(), UseHTTPS: true}
|
||||||
|
bm := storage.NewBucketManager(mac, &cfg)
|
||||||
|
if err := fn(bm); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", errMsg, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -250,3 +412,29 @@ func GetStorageService() (StorageService, error) {
|
|||||||
return NewLocalStorage(), nil
|
return NewLocalStorage(), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mimeTypeOf 从 multipart header 取 Content-Type
|
||||||
|
func mimeTypeOf(header *multipart.FileHeader) string {
|
||||||
|
if header == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return header.Header.Get("Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile 文件复制(Rename 跨设备失败时的回退方案)
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Sync()
|
||||||
|
}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 上传配置
|
// 上传配置
|
||||||
const uploadUrl = import.meta.env.VITE_API_BASE_URL + "/platform/upload";
|
const uploadUrl = import.meta.env.VITE_API_BASE_URL + "/platform/uploadfile";
|
||||||
const uploadHeaders = {
|
const uploadHeaders = {
|
||||||
Authorization: "Bearer " + localStorage.getItem("token"),
|
Authorization: "Bearer " + localStorage.getItem("token"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
<el-form-item label="资质图片" prop="file_url">
|
<el-form-item label="资质图片" prop="file_url">
|
||||||
<el-upload
|
<el-upload
|
||||||
class="avatar-uploader"
|
class="avatar-uploader"
|
||||||
action="/api/platform/common/upload"
|
:action="uploadAction"
|
||||||
|
:headers="uploadHeaders"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:on-success="handleUploadSuccess"
|
:on-success="handleUploadSuccess"
|
||||||
:before-upload="beforeAvatarUpload"
|
:before-upload="beforeAvatarUpload"
|
||||||
@@ -71,6 +72,12 @@
|
|||||||
const tenantName = ref('');
|
const tenantName = ref('');
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
|
|
||||||
|
// 上传地址:走平台端通用上传接口(原 /api/platform/common/upload 后端不存在,会 404)
|
||||||
|
const uploadAction = import.meta.env.VITE_API_BASE_URL + '/platform/uploadfile';
|
||||||
|
const uploadHeaders = {
|
||||||
|
Authorization: 'Bearer ' + (localStorage.getItem('token') || '')
|
||||||
|
};
|
||||||
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
tid: null,
|
tid: null,
|
||||||
type: '',
|
type: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user