213 lines
5.4 KiB
Go
213 lines
5.4 KiB
Go
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
|
||
}
|
||
|
||
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) {
|
||
|
||
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
|
||
}
|
||
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
|
||
}
|
||
return models.Orm.QueryTable(new(models.SystemFile)).
|
||
Filter("id__in", ids).
|
||
Delete()
|
||
}
|