整体平台上传功能大改造
This commit is contained in:
@@ -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()
|
||||
}
|
||||
+222
-191
@@ -1,191 +1,222 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// MigrationProgress 迁移进度
|
||||
type MigrationProgress struct {
|
||||
Total int
|
||||
Success int
|
||||
Failed int
|
||||
Current string
|
||||
Errors []string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// AddSuccess 增加成功计数
|
||||
func (p *MigrationProgress) AddSuccess() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.Success++
|
||||
}
|
||||
|
||||
// AddFailed 增加失败计数
|
||||
func (p *MigrationProgress) AddFailed(err string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.Failed++
|
||||
p.Errors = append(p.Errors, err)
|
||||
}
|
||||
|
||||
// SetCurrent 设置当前处理的文件
|
||||
func (p *MigrationProgress) SetCurrent(filename string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.Current = filename
|
||||
}
|
||||
|
||||
// GetProgress 获取进度信息
|
||||
func (p *MigrationProgress) GetProgress() (int, int, int, string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.Total, p.Success, p.Failed, p.Current
|
||||
}
|
||||
|
||||
// StorageMigration 存储迁移服务
|
||||
type StorageMigration struct {
|
||||
fromService StorageService
|
||||
toService StorageService
|
||||
progress *MigrationProgress
|
||||
}
|
||||
|
||||
// NewStorageMigration 创建存储迁移服务
|
||||
func NewStorageMigration(from, to StorageService) *StorageMigration {
|
||||
return &StorageMigration{
|
||||
fromService: from,
|
||||
toService: to,
|
||||
progress: &MigrationProgress{
|
||||
Errors: make([]string, 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateFile 迁移单个文件
|
||||
func (m *StorageMigration) MigrateFile(file *models.SystemFile) error {
|
||||
m.progress.SetCurrent(file.Name)
|
||||
|
||||
// 如果是本地存储,从本地读取文件
|
||||
if localFrom, ok := m.fromService.(*LocalStorage); ok {
|
||||
// 从本地文件系统读取
|
||||
localPath := strings.TrimPrefix(file.Src, "/")
|
||||
filePath := filepath.Join(localFrom.BaseDir, localPath)
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开本地文件失败: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 获取文件信息
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取文件信息失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建 multipart.FileHeader
|
||||
header := &multipart.FileHeader{
|
||||
Filename: file.Name,
|
||||
Size: stat.Size(),
|
||||
}
|
||||
|
||||
// 上传到目标存储
|
||||
result, err := m.toService.Upload(f, header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("上传到目标存储失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新数据库记录
|
||||
_, err = models.Orm.QueryTable(new(models.SystemFile)).
|
||||
Filter("id", file.ID).
|
||||
Update(map[string]interface{}{
|
||||
"src": result.URL,
|
||||
})
|
||||
if err != nil {
|
||||
// 上传成功但更新数据库失败,尝试删除已上传的文件
|
||||
_ = m.toService.Delete(result.Key)
|
||||
return fmt.Errorf("更新数据库失败: %w", err)
|
||||
}
|
||||
|
||||
m.progress.AddSuccess()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 如果是七牛云存储,需要先下载再上传(这里简化处理)
|
||||
return fmt.Errorf("暂不支持从七牛云迁移到本地")
|
||||
}
|
||||
|
||||
// MigrateAll 迁移所有文件
|
||||
func (m *StorageMigration) MigrateAll(tid uint64) error {
|
||||
// 获取所有文件
|
||||
var files []models.SystemFile
|
||||
_, err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&files)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取文件列表失败: %w", err)
|
||||
}
|
||||
|
||||
m.progress.Total = len(files)
|
||||
|
||||
// 并发迁移(限制并发数)
|
||||
concurrency := 5
|
||||
sem := make(chan struct{}, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range files {
|
||||
wg.Add(1)
|
||||
go func(file *models.SystemFile) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{} // 获取信号量
|
||||
defer func() { <-sem }() // 释放信号量
|
||||
|
||||
if err := m.MigrateFile(file); err != nil {
|
||||
m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err))
|
||||
}
|
||||
}(&files[i])
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProgress 获取迁移进度
|
||||
func (m *StorageMigration) GetProgress() *MigrationProgress {
|
||||
return m.progress
|
||||
}
|
||||
|
||||
// MigrateLocalToQiniu 从本地存储迁移到七牛云
|
||||
func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) {
|
||||
// 获取存储配置
|
||||
cfg, err := models.GetStorageConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取存储配置失败: %w", err)
|
||||
}
|
||||
|
||||
if cfg.StorageType != "qiniu" {
|
||||
return nil, fmt.Errorf("当前存储类型不是七牛云")
|
||||
}
|
||||
|
||||
// 创建存储服务
|
||||
localStorage := NewLocalStorage()
|
||||
qiniuStorage := NewQiniuStorage(cfg)
|
||||
|
||||
// 创建迁移服务
|
||||
migration := NewStorageMigration(localStorage, qiniuStorage)
|
||||
|
||||
// 执行迁移
|
||||
if err := migration.MigrateAll(tid); err != nil {
|
||||
return migration.GetProgress(), err
|
||||
}
|
||||
|
||||
return migration.GetProgress(), nil
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// MigrationProgress 迁移进度
|
||||
type MigrationProgress struct {
|
||||
Total int
|
||||
Success int
|
||||
Failed int
|
||||
Skipped int
|
||||
Current string
|
||||
Errors []string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// AddSuccess 增加成功计数
|
||||
func (p *MigrationProgress) AddSuccess() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.Success++
|
||||
}
|
||||
|
||||
// AddSkipped 增加跳过计数
|
||||
func (p *MigrationProgress) AddSkipped() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.Skipped++
|
||||
}
|
||||
|
||||
// AddFailed 增加失败计数
|
||||
func (p *MigrationProgress) AddFailed(err string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.Failed++
|
||||
p.Errors = append(p.Errors, err)
|
||||
}
|
||||
|
||||
// SetCurrent 设置当前处理的文件
|
||||
func (p *MigrationProgress) SetCurrent(filename string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.Current = filename
|
||||
}
|
||||
|
||||
// GetProgress 获取进度信息
|
||||
func (p *MigrationProgress) GetProgress() (int, int, int, string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.Total, p.Success, p.Failed, p.Current
|
||||
}
|
||||
|
||||
// StorageMigration 存储迁移服务
|
||||
type StorageMigration struct {
|
||||
fromService StorageService
|
||||
toService StorageService
|
||||
progress *MigrationProgress
|
||||
}
|
||||
|
||||
// NewStorageMigration 创建存储迁移服务
|
||||
func NewStorageMigration(from, to StorageService) *StorageMigration {
|
||||
return &StorageMigration{
|
||||
fromService: from,
|
||||
toService: to,
|
||||
progress: &MigrationProgress{
|
||||
Errors: make([]string, 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TargetKey 计算某条文件记录改造后的新 key
|
||||
func TargetKey(f *models.SystemFile) string {
|
||||
source := f.Source
|
||||
if source == "" {
|
||||
source = SourceBackend
|
||||
}
|
||||
var tuid uint64
|
||||
if f.Tuid != nil {
|
||||
tuid = *f.Tuid
|
||||
}
|
||||
return BuildObjectKey(UploadContext{Source: source, Tid: f.Tid, Tuid: tuid}, FileExt(f.Name))
|
||||
}
|
||||
|
||||
// MigrateFile 迁移单个文件到新的分层目录
|
||||
//
|
||||
// 同类型存储(七牛→七牛 / 本地→本地)走服务端改名,不重新上传、不消耗流量;
|
||||
// 本地→七牛 走"读取 + 上传",其余跨存储方向暂不支持。
|
||||
func (m *StorageMigration) MigrateFile(file *models.SystemFile) error {
|
||||
m.progress.SetCurrent(file.Name)
|
||||
|
||||
oldKey := file.ObjectKey
|
||||
if oldKey == "" {
|
||||
oldKey = KeyFromSrc(file.Src, m.fromService)
|
||||
}
|
||||
if oldKey == "" {
|
||||
return fmt.Errorf("无法解析原存储路径: %s", file.Src)
|
||||
}
|
||||
|
||||
newKey := TargetKey(file)
|
||||
if oldKey == newKey {
|
||||
m.progress.AddSkipped()
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.fromService.Type() == m.toService.Type() {
|
||||
if err := m.toService.Move(oldKey, newKey); err != nil {
|
||||
return fmt.Errorf("移动文件失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 跨存储:仅支持 本地 → 七牛
|
||||
localFrom, ok := m.fromService.(*LocalStorage)
|
||||
if !ok {
|
||||
return fmt.Errorf("暂不支持从 %s 迁出到 %s", m.fromService.Type(), m.toService.Type())
|
||||
}
|
||||
localPath := filepath.Join(localFrom.BaseDir, filepath.FromSlash(oldKey))
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开本地文件失败: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取文件信息失败: %w", err)
|
||||
}
|
||||
header := &multipart.FileHeader{Filename: file.Name, Size: stat.Size()}
|
||||
|
||||
staged, err := m.toService.Stage(f, header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source := file.Source
|
||||
if source == "" {
|
||||
source = SourceBackend
|
||||
}
|
||||
var tuid uint64
|
||||
if file.Tuid != nil {
|
||||
tuid = *file.Tuid
|
||||
}
|
||||
if _, err := m.toService.Commit(staged, UploadContext{Source: source, Tid: file.Tid, Tuid: tuid}); err != nil {
|
||||
_ = m.toService.Discard(staged)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
newSrc := m.toService.GetPublicURL(newKey)
|
||||
if _, err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||
Filter("id", file.ID).
|
||||
Update(map[string]interface{}{
|
||||
"src": newSrc,
|
||||
"object_key": newKey,
|
||||
"storage": m.toService.Type(),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("更新数据库失败: %w", err)
|
||||
}
|
||||
|
||||
m.progress.AddSuccess()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateAll 迁移所有文件
|
||||
func (m *StorageMigration) MigrateAll(tid uint64) error {
|
||||
var files []models.SystemFile
|
||||
qs := models.Orm.QueryTable(new(models.SystemFile)).Filter("delete_time__isnull", true)
|
||||
if tid > 0 {
|
||||
qs = qs.Filter("tid", tid)
|
||||
}
|
||||
if _, err := qs.All(&files); err != nil {
|
||||
return fmt.Errorf("获取文件列表失败: %w", err)
|
||||
}
|
||||
|
||||
m.progress.Total = len(files)
|
||||
|
||||
concurrency := 5
|
||||
sem := make(chan struct{}, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range files {
|
||||
wg.Add(1)
|
||||
go func(file *models.SystemFile) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
if err := m.MigrateFile(file); err != nil {
|
||||
m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err))
|
||||
}
|
||||
}(&files[i])
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProgress 获取迁移进度
|
||||
func (m *StorageMigration) GetProgress() *MigrationProgress {
|
||||
return m.progress
|
||||
}
|
||||
|
||||
// MigrateLocalToQiniu 从本地存储迁移到七牛云
|
||||
func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) {
|
||||
cfg, err := models.GetStorageConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取存储配置失败: %w", err)
|
||||
}
|
||||
if cfg.StorageType != StorageTypeQiniu {
|
||||
return nil, fmt.Errorf("当前存储类型不是七牛云")
|
||||
}
|
||||
|
||||
migration := NewStorageMigration(NewLocalStorage(), NewQiniuStorage(cfg))
|
||||
if err := migration.MigrateAll(tid); err != nil {
|
||||
return migration.GetProgress(), err
|
||||
}
|
||||
return migration.GetProgress(), nil
|
||||
}
|
||||
|
||||
+440
-252
@@ -1,252 +1,440 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
||||
"github.com/qiniu/go-sdk/v7/storage"
|
||||
)
|
||||
|
||||
// StorageService 存储服务接口
|
||||
type StorageService interface {
|
||||
Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error)
|
||||
GetPublicURL(key string) string
|
||||
Delete(key string) error
|
||||
}
|
||||
|
||||
// UploadResult 上传结果
|
||||
type UploadResult struct {
|
||||
URL string // 完整访问URL
|
||||
Key string // 存储key/路径
|
||||
Size int64 // 文件大小
|
||||
MD5 string // 文件MD5
|
||||
MimeType string // 文件类型
|
||||
}
|
||||
|
||||
// LocalStorage 本地存储实现
|
||||
type LocalStorage struct {
|
||||
BaseDir string // 基础目录,默认 "uploads"
|
||||
BaseURL string // 基础URL,默认 "/"
|
||||
}
|
||||
|
||||
// NewLocalStorage 创建本地存储服务
|
||||
func NewLocalStorage() *LocalStorage {
|
||||
return &LocalStorage{
|
||||
BaseDir: "uploads",
|
||||
BaseURL: "/",
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 上传文件到本地
|
||||
func (s *LocalStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) {
|
||||
// 生成存储路径
|
||||
ext := filepath.Ext(header.Filename)
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
|
||||
savePath := filepath.Join(datePath, fileName)
|
||||
|
||||
// 创建目录
|
||||
destDir := filepath.Join(s.BaseDir, filepath.FromSlash(datePath))
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
destPath := filepath.Join(s.BaseDir, filepath.FromSlash(savePath))
|
||||
dst, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// 计算MD5并复制文件
|
||||
hash := md5.New()
|
||||
size, err := io.Copy(io.MultiWriter(dst, hash), file)
|
||||
if err != nil {
|
||||
_ = os.Remove(destPath)
|
||||
return nil, fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
|
||||
md5Sum := hex.EncodeToString(hash.Sum(nil))
|
||||
webURL := s.BaseURL + strings.ReplaceAll(filepath.ToSlash(destPath), "\\", "/")
|
||||
|
||||
return &UploadResult{
|
||||
URL: webURL,
|
||||
Key: savePath,
|
||||
Size: size,
|
||||
MD5: md5Sum,
|
||||
MimeType: header.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPublicURL 获取公开访问URL
|
||||
func (s *LocalStorage) GetPublicURL(key string) string {
|
||||
return s.BaseURL + filepath.ToSlash(filepath.Join(s.BaseDir, key))
|
||||
}
|
||||
|
||||
// Delete 删除本地文件
|
||||
func (s *LocalStorage) Delete(key string) error {
|
||||
filePath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
|
||||
return os.Remove(filePath)
|
||||
}
|
||||
|
||||
// QiniuStorage 七牛云存储实现
|
||||
type QiniuStorage struct {
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Bucket string
|
||||
Domain string
|
||||
Region string
|
||||
}
|
||||
|
||||
// NewQiniuStorage 创建七牛云存储服务
|
||||
func NewQiniuStorage(cfg *models.StorageConfig) *QiniuStorage {
|
||||
return &QiniuStorage{
|
||||
AccessKey: cfg.QiniuAccessKey,
|
||||
SecretKey: cfg.QiniuSecretKey,
|
||||
Bucket: cfg.QiniuBucket,
|
||||
Domain: cfg.QiniuDomain,
|
||||
Region: cfg.QiniuRegion,
|
||||
}
|
||||
}
|
||||
|
||||
// getZone 根据区域代码获取存储区域
|
||||
func (s *QiniuStorage) getZone() *storage.Region {
|
||||
switch s.Region {
|
||||
case "z0":
|
||||
return &storage.ZoneHuadong
|
||||
case "z1":
|
||||
return &storage.ZoneHuabei
|
||||
case "z2":
|
||||
return &storage.ZoneHuanan
|
||||
case "na0":
|
||||
return &storage.ZoneBeimei
|
||||
case "as0":
|
||||
return &storage.ZoneXinjiapo
|
||||
case "cn-east-2":
|
||||
return &storage.ZoneHuadongZheJiang2
|
||||
default:
|
||||
return &storage.ZoneHuadong // 默认华东
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 上传文件到七牛云
|
||||
func (s *QiniuStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) {
|
||||
// 生成存储key
|
||||
ext := filepath.Ext(header.Filename)
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
|
||||
key := filepath.ToSlash(filepath.Join(datePath, fileName))
|
||||
|
||||
// 创建上传凭证
|
||||
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
|
||||
putPolicy := storage.PutPolicy{
|
||||
Scope: s.Bucket,
|
||||
}
|
||||
upToken := putPolicy.UploadToken(mac)
|
||||
|
||||
// 配置上传参数
|
||||
cfg := storage.Config{
|
||||
Region: s.getZone(),
|
||||
UseHTTPS: true,
|
||||
UseCdnDomains: false,
|
||||
}
|
||||
|
||||
// 创建表单上传器
|
||||
formUploader := storage.NewFormUploader(&cfg)
|
||||
ret := storage.PutRet{}
|
||||
putExtra := storage.PutExtra{}
|
||||
|
||||
// 计算文件大小和MD5
|
||||
tmpFile, err := os.CreateTemp("", "qiniu_upload_*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
hash := md5.New()
|
||||
size, err := io.Copy(io.MultiWriter(tmpFile, hash), file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
md5Sum := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
// 重置文件指针
|
||||
if _, err := tmpFile.Seek(0, 0); err != nil {
|
||||
return nil, fmt.Errorf("重置文件指针失败: %w", err)
|
||||
}
|
||||
|
||||
// 执行上传
|
||||
err = formUploader.Put(context.Background(), &ret, upToken, key, tmpFile, size, &putExtra)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("上传到七牛云失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建完整URL
|
||||
domain := strings.TrimRight(s.Domain, "/")
|
||||
url := fmt.Sprintf("%s/%s", domain, ret.Key)
|
||||
|
||||
return &UploadResult{
|
||||
URL: url,
|
||||
Key: ret.Key,
|
||||
Size: size,
|
||||
MD5: md5Sum,
|
||||
MimeType: header.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPublicURL 获取七牛云公开访问URL
|
||||
func (s *QiniuStorage) GetPublicURL(key string) string {
|
||||
domain := strings.TrimRight(s.Domain, "/")
|
||||
return fmt.Sprintf("%s/%s", domain, key)
|
||||
}
|
||||
|
||||
// Delete 删除七牛云文件
|
||||
func (s *QiniuStorage) Delete(key string) error {
|
||||
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
|
||||
cfg := storage.Config{
|
||||
Region: s.getZone(),
|
||||
UseHTTPS: true,
|
||||
}
|
||||
|
||||
bucketManager := storage.NewBucketManager(mac, &cfg)
|
||||
err := bucketManager.Delete(s.Bucket, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除七牛云文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStorageService 根据配置获取存储服务
|
||||
func GetStorageService() (StorageService, error) {
|
||||
cfg, err := models.GetStorageConfig()
|
||||
if err != nil {
|
||||
// 默认使用本地存储
|
||||
return NewLocalStorage(), nil
|
||||
}
|
||||
|
||||
switch cfg.StorageType {
|
||||
case "qiniu":
|
||||
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" ||
|
||||
cfg.QiniuBucket == "" || cfg.QiniuDomain == "" {
|
||||
return nil, fmt.Errorf("七牛云配置不完整")
|
||||
}
|
||||
return NewQiniuStorage(cfg), nil
|
||||
case "local":
|
||||
return NewLocalStorage(), nil
|
||||
default:
|
||||
return NewLocalStorage(), nil
|
||||
}
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
||||
"github.com/qiniu/go-sdk/v7/storage"
|
||||
)
|
||||
|
||||
// 来源端
|
||||
const (
|
||||
SourceBackend = "backend" // 租户后台端
|
||||
SourcePlatform = "platform" // 平台端
|
||||
)
|
||||
|
||||
// 归属范围
|
||||
const (
|
||||
ScopeTenant = "tenant" // 租户共享文件
|
||||
ScopeUser = "user" // 用户个人文件
|
||||
)
|
||||
|
||||
// 存储类型
|
||||
const (
|
||||
StorageTypeLocal = "local"
|
||||
StorageTypeQiniu = "qiniu"
|
||||
)
|
||||
|
||||
// UploadContext 上传上下文,决定文件最终的存储路径
|
||||
type UploadContext struct {
|
||||
Source string // backend / platform
|
||||
Tid uint64 // 租户 ID(platform 端可为 0)
|
||||
Tuid uint64 // 归属用户 ID,0 表示租户共享文件
|
||||
}
|
||||
|
||||
// Scope 返回归属范围:带了归属用户即个人文件,否则为租户共享文件。
|
||||
// 平台端不做用户分层,统一按租户共享处理。
|
||||
func (c UploadContext) Scope() string {
|
||||
if c.Source == SourcePlatform {
|
||||
return ScopeTenant
|
||||
}
|
||||
if c.Tuid > 0 {
|
||||
return ScopeUser
|
||||
}
|
||||
return ScopeTenant
|
||||
}
|
||||
|
||||
// BuildObjectKey 生成存储相对路径(不含域名、不含本地 BaseDir)
|
||||
//
|
||||
// backend 共享: backend/234573/2026/09/09/xxx.png
|
||||
// backend 个人: backend/234573/67091493/2026/09/09/xxx.png
|
||||
// platform : platform/2026/09/09/xxx.png
|
||||
func BuildObjectKey(ctx UploadContext, ext string) string {
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomHex(6), normalizeExt(ext))
|
||||
|
||||
if ctx.Source == SourcePlatform {
|
||||
return path.Join(SourcePlatform, datePath, name)
|
||||
}
|
||||
if ctx.Tuid > 0 {
|
||||
return path.Join(SourceBackend, fmt.Sprint(ctx.Tid), fmt.Sprint(ctx.Tuid), datePath, name)
|
||||
}
|
||||
return path.Join(SourceBackend, fmt.Sprint(ctx.Tid), datePath, name)
|
||||
}
|
||||
|
||||
// normalizeExt 规范扩展名:小写、补前导点
|
||||
func normalizeExt(ext string) string {
|
||||
ext = strings.TrimSpace(ext)
|
||||
if ext == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
return strings.ToLower(ext)
|
||||
}
|
||||
|
||||
// randomHex 生成 n 位十六进制随机串(使用 crypto/rand,无需种子)
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, (n+1)/2)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprint(time.Now().UnixNano() % 1000000)
|
||||
}
|
||||
return hex.EncodeToString(b)[:n]
|
||||
}
|
||||
|
||||
// StagedFile 已落临时文件、算完 MD5 的待提交文件
|
||||
type StagedFile struct {
|
||||
TempPath string // 本地临时文件路径
|
||||
MD5 string // 文件内容 MD5
|
||||
Size int64 // 文件大小
|
||||
MimeType string // 文件类型
|
||||
Ext string // 扩展名(含点)
|
||||
}
|
||||
|
||||
// UploadResult 上传结果
|
||||
type UploadResult struct {
|
||||
URL string // 完整访问URL
|
||||
Key string // 存储key/相对路径(object_key)
|
||||
Size int64 // 文件大小
|
||||
MD5 string // 文件MD5
|
||||
MimeType string // 文件类型
|
||||
}
|
||||
|
||||
// StorageService 存储服务接口
|
||||
type StorageService interface {
|
||||
// Stage 把上传流写入临时文件并计算 MD5,此时文件尚未进入正式存储目录
|
||||
Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error)
|
||||
// Commit 把临时文件提交到按 ctx 计算出的正式路径;返回访问 URL 与 object_key
|
||||
Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error)
|
||||
// Discard 丢弃临时文件(去重命中或出错时调用)
|
||||
Discard(staged *StagedFile) error
|
||||
GetPublicURL(key string) string
|
||||
Delete(key string) error
|
||||
// Move 把已存文件从 oldKey 改名到 newKey(迁移用:本地 Rename / 七牛 Move)
|
||||
Move(oldKey, newKey string) error
|
||||
// Type 返回存储类型 local / qiniu
|
||||
Type() string
|
||||
}
|
||||
|
||||
// LocalStorage 本地存储实现
|
||||
type LocalStorage struct {
|
||||
BaseDir string // 基础目录,默认 "uploads"
|
||||
BaseURL string // 基础URL,默认 "/"
|
||||
}
|
||||
|
||||
// NewLocalStorage 创建本地存储服务
|
||||
func NewLocalStorage() *LocalStorage {
|
||||
return &LocalStorage{
|
||||
BaseDir: "uploads",
|
||||
BaseURL: "/",
|
||||
}
|
||||
}
|
||||
|
||||
// Type 存储类型
|
||||
func (s *LocalStorage) Type() string { return StorageTypeLocal }
|
||||
|
||||
// tempDir 临时目录:与正式目录同盘,保证 Commit 时 os.Rename 不跨设备
|
||||
func (s *LocalStorage) tempDir() string {
|
||||
return filepath.Join(s.BaseDir, ".tmp")
|
||||
}
|
||||
|
||||
// Stage 写入临时文件并计算 MD5
|
||||
func (s *LocalStorage) Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error) {
|
||||
dir := s.tempDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建临时目录失败: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "stage_*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
defer tmp.Close()
|
||||
|
||||
hash := md5.New()
|
||||
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
|
||||
if err != nil {
|
||||
tmpPath := tmp.Name()
|
||||
_ = os.Remove(tmpPath)
|
||||
return nil, fmt.Errorf("读取上传文件失败: %w", err)
|
||||
}
|
||||
|
||||
return &StagedFile{
|
||||
TempPath: tmp.Name(),
|
||||
MD5: hex.EncodeToString(hash.Sum(nil)),
|
||||
Size: size,
|
||||
MimeType: mimeTypeOf(header),
|
||||
Ext: normalizeExt(filepath.Ext(header.Filename)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Commit 提交到正式目录
|
||||
func (s *LocalStorage) Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error) {
|
||||
if staged == nil {
|
||||
return nil, fmt.Errorf("待提交文件为空")
|
||||
}
|
||||
key := BuildObjectKey(ctx, staged.Ext)
|
||||
destPath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 同盘优先 Rename;跨设备失败则回退复制
|
||||
if err := os.Rename(staged.TempPath, destPath); err != nil {
|
||||
if copyErr := copyFile(staged.TempPath, destPath); copyErr != nil {
|
||||
return nil, fmt.Errorf("保存文件失败: %w", copyErr)
|
||||
}
|
||||
_ = os.Remove(staged.TempPath)
|
||||
}
|
||||
|
||||
return &UploadResult{
|
||||
URL: s.GetPublicURL(key),
|
||||
Key: key,
|
||||
Size: staged.Size,
|
||||
MD5: staged.MD5,
|
||||
MimeType: staged.MimeType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Discard 删除临时文件
|
||||
func (s *LocalStorage) Discard(staged *StagedFile) error {
|
||||
if staged == nil || staged.TempPath == "" {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(staged.TempPath)
|
||||
}
|
||||
|
||||
// GetPublicURL 获取公开访问URL
|
||||
func (s *LocalStorage) GetPublicURL(key string) string {
|
||||
full := filepath.ToSlash(filepath.Join(s.BaseDir, filepath.FromSlash(key)))
|
||||
return s.BaseURL + strings.ReplaceAll(full, "\\", "/")
|
||||
}
|
||||
|
||||
// Delete 删除本地文件
|
||||
func (s *LocalStorage) Delete(key string) error {
|
||||
filePath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
|
||||
return os.Remove(filePath)
|
||||
}
|
||||
|
||||
// Move 本地改名(同盘,不搬数据)
|
||||
func (s *LocalStorage) Move(oldKey, newKey string) error {
|
||||
oldPath := filepath.Join(s.BaseDir, filepath.FromSlash(oldKey))
|
||||
newPath := filepath.Join(s.BaseDir, filepath.FromSlash(newKey))
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
|
||||
return fmt.Errorf("创建目标目录失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
// 跨设备时回退为复制 + 删除
|
||||
if copyErr := copyFile(oldPath, newPath); copyErr != nil {
|
||||
return fmt.Errorf("移动文件失败: %w", copyErr)
|
||||
}
|
||||
_ = os.Remove(oldPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QiniuStorage 七牛云存储实现
|
||||
type QiniuStorage struct {
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Bucket string
|
||||
Domain string
|
||||
Region string
|
||||
}
|
||||
|
||||
// NewQiniuStorage 创建七牛云存储服务
|
||||
func NewQiniuStorage(cfg *models.StorageConfig) *QiniuStorage {
|
||||
return &QiniuStorage{
|
||||
AccessKey: cfg.QiniuAccessKey,
|
||||
SecretKey: cfg.QiniuSecretKey,
|
||||
Bucket: cfg.QiniuBucket,
|
||||
Domain: cfg.QiniuDomain,
|
||||
Region: cfg.QiniuRegion,
|
||||
}
|
||||
}
|
||||
|
||||
// Type 存储类型
|
||||
func (s *QiniuStorage) Type() string { return StorageTypeQiniu }
|
||||
|
||||
// getZone 根据区域代码获取存储区域
|
||||
func (s *QiniuStorage) getZone() *storage.Region {
|
||||
switch s.Region {
|
||||
case "z0":
|
||||
return &storage.ZoneHuadong
|
||||
case "z1":
|
||||
return &storage.ZoneHuabei
|
||||
case "z2":
|
||||
return &storage.ZoneHuanan
|
||||
case "na0":
|
||||
return &storage.ZoneBeimei
|
||||
case "as0":
|
||||
return &storage.ZoneXinjiapo
|
||||
case "cn-east-2":
|
||||
return &storage.ZoneHuadongZheJiang2
|
||||
default:
|
||||
return &storage.ZoneHuadong // 默认华东
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 写入临时文件并计算 MD5(七牛直传拿不到内容,这里统一先落临时文件算 MD5,
|
||||
// 便于去重;未命中才会真正上传到七牛)
|
||||
func (s *QiniuStorage) Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error) {
|
||||
tmp, err := os.CreateTemp("", "yz_upload_*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
defer tmp.Close()
|
||||
|
||||
hash := md5.New()
|
||||
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
return nil, fmt.Errorf("读取上传文件失败: %w", err)
|
||||
}
|
||||
|
||||
return &StagedFile{
|
||||
TempPath: tmp.Name(),
|
||||
MD5: hex.EncodeToString(hash.Sum(nil)),
|
||||
Size: size,
|
||||
MimeType: mimeTypeOf(header),
|
||||
Ext: normalizeExt(filepath.Ext(header.Filename)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Commit 上传到七牛云
|
||||
func (s *QiniuStorage) Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error) {
|
||||
if staged == nil {
|
||||
return nil, fmt.Errorf("待提交文件为空")
|
||||
}
|
||||
key := BuildObjectKey(ctx, staged.Ext)
|
||||
|
||||
f, err := os.Open(staged.TempPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开临时文件失败: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
|
||||
putPolicy := storage.PutPolicy{Scope: s.Bucket}
|
||||
upToken := putPolicy.UploadToken(mac)
|
||||
|
||||
cfg := storage.Config{
|
||||
Region: s.getZone(),
|
||||
UseHTTPS: true,
|
||||
UseCdnDomains: false,
|
||||
}
|
||||
formUploader := storage.NewFormUploader(&cfg)
|
||||
ret := storage.PutRet{}
|
||||
|
||||
if err := formUploader.Put(context.Background(), &ret, upToken, key, f, staged.Size, &storage.PutExtra{}); err != nil {
|
||||
return nil, fmt.Errorf("上传到七牛云失败: %w", err)
|
||||
}
|
||||
|
||||
return &UploadResult{
|
||||
URL: s.GetPublicURL(key),
|
||||
Key: key,
|
||||
Size: staged.Size,
|
||||
MD5: staged.MD5,
|
||||
MimeType: staged.MimeType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Discard 删除临时文件
|
||||
func (s *QiniuStorage) Discard(staged *StagedFile) error {
|
||||
if staged == nil || staged.TempPath == "" {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(staged.TempPath)
|
||||
}
|
||||
|
||||
// GetPublicURL 获取七牛云公开访问URL
|
||||
func (s *QiniuStorage) GetPublicURL(key string) string {
|
||||
domain := strings.TrimRight(s.Domain, "/")
|
||||
return fmt.Sprintf("%s/%s", domain, key)
|
||||
}
|
||||
|
||||
// Delete 删除七牛云文件
|
||||
func (s *QiniuStorage) Delete(key string) error {
|
||||
return s.runBucketManager(func(bm *storage.BucketManager) error {
|
||||
return bm.Delete(s.Bucket, key)
|
||||
}, "删除七牛云文件失败")
|
||||
}
|
||||
|
||||
// Move 七牛服务端改名(同 bucket 内原子操作,不走流量)
|
||||
func (s *QiniuStorage) Move(oldKey, newKey string) error {
|
||||
return s.runBucketManager(func(bm *storage.BucketManager) error {
|
||||
// force=false:目标 key 已存在时直接报错,避免覆盖,保证迁移可重复执行
|
||||
return bm.Move(s.Bucket, oldKey, s.Bucket, newKey, false)
|
||||
}, "移动七牛云文件失败")
|
||||
}
|
||||
|
||||
func (s *QiniuStorage) runBucketManager(fn func(*storage.BucketManager) error, errMsg string) error {
|
||||
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
|
||||
cfg := storage.Config{Region: s.getZone(), UseHTTPS: true}
|
||||
bm := storage.NewBucketManager(mac, &cfg)
|
||||
if err := fn(bm); err != nil {
|
||||
return fmt.Errorf("%s: %w", errMsg, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStorageService 根据配置获取存储服务
|
||||
func GetStorageService() (StorageService, error) {
|
||||
cfg, err := models.GetStorageConfig()
|
||||
if err != nil {
|
||||
// 默认使用本地存储
|
||||
return NewLocalStorage(), nil
|
||||
}
|
||||
|
||||
switch cfg.StorageType {
|
||||
case "qiniu":
|
||||
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" ||
|
||||
cfg.QiniuBucket == "" || cfg.QiniuDomain == "" {
|
||||
return nil, fmt.Errorf("七牛云配置不完整")
|
||||
}
|
||||
return NewQiniuStorage(cfg), nil
|
||||
case "local":
|
||||
return NewLocalStorage(), nil
|
||||
default:
|
||||
return NewLocalStorage(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// mimeTypeOf 从 multipart header 取 Content-Type
|
||||
func mimeTypeOf(header *multipart.FileHeader) string {
|
||||
if header == nil {
|
||||
return ""
|
||||
}
|
||||
return header.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
// copyFile 文件复制(Rename 跨设备失败时的回退方案)
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user