整体平台上传功能大改造

This commit is contained in:
2026-09-09 10:49:57 +08:00
parent d2643a3697
commit de7225b61e
21 changed files with 3835 additions and 2622 deletions
+216
View File
@@ -0,0 +1,216 @@
package services
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
"server/models"
)
// FileUploadParams 创建文件记录所需参数
type FileUploadParams struct {
Source string // backend / platform
Scope string // tenant / user
Tid uint64
Tuid uint64 // 归属用户,0 表示租户共享
Uid uint64 // 上传者
Name string
Ext string
Cate uint64
Size uint64
Src string
ObjectKey string
Storage string
MD5 string
}
// FindDuplicate 按「来源端 + 归属范围 + 租户 + 归属用户 + MD5」精确查重。
//
// 规则:
// - 租户共享文件:同一 tid 内 MD5 相同才算重复(tuid 为 NULL)
// - 用户个人文件:同一 tid + 同一 tuid 内 MD5 相同才算重复
// - 因此「租户已有 a 文件」与「用户 c 上传同样文件」互不冲突,可并存
//
// 未命中时返回 (nil, nil),调用方按 err == nil && file == nil 处理即可。
func FindDuplicate(source, scope string, tid, tuid uint64, md5Str string) (*models.SystemFile, error) {
if md5Str == "" {
return nil, nil
}
models.EnsureSystemFileStorageColumns()
qs := models.Orm.QueryTable(new(models.SystemFile)).
Filter("source", source).
Filter("scope", scope).
Filter("tid", tid).
Filter("md5", md5Str).
Filter("delete_time__isnull", true)
if scope == ScopeUser {
qs = qs.Filter("tuid", tuid)
} else {
qs = qs.Filter("tuid__isnull", true)
}
var f models.SystemFile
if err := qs.OrderBy("-id").One(&f); err != nil {
return nil, nil
}
return &f, nil
}
// CreateFileRecord 写入文件记录(同步写入归属与存储字段)
func CreateFileRecord(p FileUploadParams) (uint64, error) {
models.EnsureSystemFileStorageColumns()
uid := p.Uid
row := &models.SystemFile{
Tid: p.Tid,
Uid: &uid,
Name: p.Name,
Type: DetectFileType(p.Ext),
Cate: p.Cate,
Size: p.Size,
Src: p.Src,
Uploader: p.Uid,
Md5: p.MD5,
Source: p.Source,
Scope: p.Scope,
Storage: p.Storage,
ObjectKey: p.ObjectKey,
}
if p.Tuid > 0 {
tuid := p.Tuid
row.Tuid = &tuid
}
if row.Source == "" {
row.Source = SourceBackend
}
if row.Scope == "" {
row.Scope = ScopeTenant
}
id, err := models.Orm.Insert(row)
if err != nil {
return 0, err
}
return uint64(id), nil
}
// RemovePhysical 删除物理文件。优先用 object_key,老数据则从 src 反推。
func RemovePhysical(svc StorageService, objectKey, src string) error {
if svc == nil {
return fmt.Errorf("存储服务未初始化")
}
key := strings.TrimSpace(objectKey)
if key == "" {
key = KeyFromSrc(src, svc)
}
if key == "" {
return fmt.Errorf("无法解析文件路径: %s", src)
}
return svc.Delete(key)
}
// KeyFromSrc 从访问 URL/相对路径中解析出存储 key(object_key)
func KeyFromSrc(src string, svc StorageService) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
switch s := svc.(type) {
case *LocalStorage:
base := filepath.ToSlash(s.BaseDir)
rel := src
if i := strings.Index(rel, base+"/"); i >= 0 {
rel = rel[i+len(base)+1:]
}
rel = strings.TrimPrefix(rel, "/")
// 去掉可能的查询串
if i := strings.IndexAny(rel, "?#"); i >= 0 {
rel = rel[:i]
}
return rel
case *QiniuStorage:
domain := strings.TrimRight(s.Domain, "/")
rel := src
if domain != "" && strings.HasPrefix(rel, domain) {
rel = strings.TrimPrefix(strings.TrimPrefix(rel, domain), "/")
} else if u, err := url.Parse(rel); err == nil && u.Host != "" {
rel = strings.TrimPrefix(u.Path, "/")
}
rel = strings.TrimPrefix(rel, "/")
if i := strings.IndexAny(rel, "?#"); i >= 0 {
rel = rel[:i]
}
return rel
}
return ""
}
// 文件类型与扩展名白名单(两端共用同一套规则)
var fileTypeByCategory = map[string]uint8{
"image": 1,
"document": 2,
"video": 3,
"audio": 4,
"appsupgrade": 2,
}
var allowedExtByCategory = map[string][]string{
"image": {"jpg", "jpeg", "png", "gif", "bmp", "webp"},
"document": {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt"},
"video": {"mp4", "webm", "mov"},
"audio": {"mp3", "wav", "ogg"},
"appsupgrade": {"zip", "exe", "dmg", "msi", "msix", "apk", "deb", "rpm", "7z", "tar", "gz", "pkg"},
}
// FileExt 取小写扩展名(不含点)
func FileExt(name string) string {
name = strings.TrimSpace(name)
if i := strings.LastIndex(name, "."); i >= 0 && i < len(name)-1 {
return strings.ToLower(name[i+1:])
}
return ""
}
// DetectFileType 根据扩展名推断文件类型:1图片 2文档 3视频 4音频,未匹配返回 2(文档/其他)
func DetectFileType(ext string) uint8 {
ext = strings.ToLower(strings.TrimPrefix(ext, "."))
for cat, exts := range allowedExtByCategory {
for _, e := range exts {
if e == ext {
if t, ok := fileTypeByCategory[cat]; ok {
return t
}
return 2
}
}
}
return 2
}
// SoftDeleteFiles 软删除(标记 delete_time)
func SoftDeleteFiles(ids []uint64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
models.EnsureSystemFileStorageColumns()
return models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", ids).
Filter("delete_time__isnull", true).
Update(map[string]interface{}{"delete_time": time.Now()})
}
// DeleteFilesPermanently 彻底删除数据库记录(物理文件由调用方先删)
func DeleteFilesPermanently(ids []uint64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
models.EnsureSystemFileStorageColumns()
return models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", ids).
Delete()
}