Files
yunzerwebsiteallinone/go/services/file_service.go
T
2026-09-15 10:44:31 +08:00

213 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}