Files
quyun-v2/backend/app/services/common.go
2026-01-08 15:40:48 +08:00

456 lines
13 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 (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"io"
"mime/multipart"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"quyun/v2/app/errorx"
common_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/app/requests"
"quyun/v2/database/fields"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"quyun/v2/providers/storage"
"github.com/google/uuid"
"go.ipao.vip/gen/types"
)
// @provider
type common struct {
storage *storage.Storage
}
func (s *common) Options(ctx context.Context) (*common_dto.OptionsResponse, error) {
return &common_dto.OptionsResponse{
ContentStatus: consts.ContentStatusItems(),
ContentGenre: []requests.KV{
requests.NewKV("Jingju", "京剧"),
requests.NewKV("Kunqu", "昆曲"),
requests.NewKV("Yueju", "越剧"),
requests.NewKV("Yuju", "豫剧"),
requests.NewKV("Huangmeixi", "黄梅戏"),
requests.NewKV("Pingju", "评剧"),
requests.NewKV("Qinqiang", "秦腔"),
},
}, nil
}
func (s *common) CheckHash(ctx context.Context, userID int64, hash string) (*common_dto.UploadResult, error) {
existing, err := models.MediaAssetQuery.WithContext(ctx).Where(models.MediaAssetQuery.Hash.Eq(hash)).First()
if err != nil {
return nil, nil // Not found, proceed to upload
}
// Found existing file (Global deduplication hit)
// Check if user already has it (Logic deduplication hit)
myExisting, err := models.MediaAssetQuery.WithContext(ctx).
Where(models.MediaAssetQuery.Hash.Eq(hash), models.MediaAssetQuery.UserID.Eq(userID)).
First()
if err == nil {
return s.composeUploadResult(myExisting), nil
}
// Create new record for this user reusing existing ObjectKey
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.UserID.Eq(userID)).First()
var tid int64 = 0
if err == nil {
tid = t.ID
}
asset := &models.MediaAsset{
TenantID: tid,
UserID: userID,
Type: existing.Type,
Status: consts.MediaAssetStatusUploaded,
Provider: existing.Provider,
Bucket: existing.Bucket,
ObjectKey: existing.ObjectKey,
Hash: hash,
Meta: existing.Meta,
}
if err := models.MediaAssetQuery.WithContext(ctx).Create(asset); err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
return s.composeUploadResult(asset), nil
}
type UploadMeta struct {
Filename string
Type string
MimeType string
}
func (s *common) buildObjectKey(tenant *models.Tenant, hash, filename string) string {
// 按租户维度组织对象路径quyun/<tenant_uuid>/<hash>.<ext>
tenantUUID := "public"
if tenant != nil && tenant.UUID.String() != "" {
tenantUUID = tenant.UUID.String()
}
ext := strings.ToLower(filepath.Ext(filename))
return path.Join("quyun", tenantUUID, hash+ext)
}
func (s *common) InitUpload(ctx context.Context, userID int64, form *common_dto.UploadInitForm) (*common_dto.UploadInitResponse, error) {
uploadID := uuid.NewString()
localPath := s.storage.Config.LocalPath
if localPath == "" {
localPath = "./storage"
}
tempDir := filepath.Join(localPath, "temp", uploadID)
if err := os.MkdirAll(tempDir, 0o755); err != nil {
return nil, errorx.ErrInternalError.WithCause(err)
}
// Save metadata
meta := UploadMeta{
Filename: form.Filename,
Type: form.Type, // Ensure form has Type
MimeType: form.MimeType,
}
metaFile, _ := os.Create(filepath.Join(tempDir, "meta.json"))
json.NewEncoder(metaFile).Encode(meta)
metaFile.Close()
return &common_dto.UploadInitResponse{
UploadID: uploadID,
ChunkSize: 5 * 1024 * 1024,
}, nil
}
func (s *common) UploadPart(ctx context.Context, userID int64, file *multipart.FileHeader, form *common_dto.UploadPartForm) error {
localPath := s.storage.Config.LocalPath
if localPath == "" {
localPath = "./storage"
}
partPath := filepath.Join(localPath, "temp", form.UploadID, strconv.Itoa(form.PartNumber))
src, err := file.Open()
if err != nil {
return errorx.ErrInternalError.WithCause(err)
}
defer src.Close()
dst, err := os.Create(partPath)
if err != nil {
return errorx.ErrInternalError.WithCause(err)
}
defer dst.Close()
if _, err = io.Copy(dst, src); err != nil {
return errorx.ErrInternalError.WithCause(err)
}
return nil
}
func (s *common) CompleteUpload(ctx context.Context, userID int64, form *common_dto.UploadCompleteForm) (*common_dto.UploadResult, error) {
localPath := s.storage.Config.LocalPath
if localPath == "" {
localPath = "./storage"
}
tempDir := filepath.Join(localPath, "temp", form.UploadID)
// Read Meta
var meta UploadMeta
metaFile, err := os.Open(filepath.Join(tempDir, "meta.json"))
if err != nil {
return nil, errorx.ErrRecordNotFound.WithMsg("Upload session expired or invalid")
}
json.NewDecoder(metaFile).Decode(&meta)
metaFile.Close()
// List parts
entries, err := os.ReadDir(tempDir)
if err != nil {
return nil, errorx.ErrInternalError.WithCause(err)
}
var parts []int
for _, e := range entries {
if !e.IsDir() && e.Name() != "meta.json" {
if i, err := strconv.Atoi(e.Name()); err == nil {
parts = append(parts, i)
}
}
}
sort.Ints(parts)
mergedPath := filepath.Join(tempDir, "merged")
dst, err := os.Create(mergedPath)
if err != nil {
return nil, errorx.ErrInternalError.WithCause(err)
}
defer dst.Close()
hasher := md5.New()
var totalSize int64
for _, partNum := range parts {
partPath := filepath.Join(tempDir, strconv.Itoa(partNum))
src, err := os.Open(partPath)
if err != nil {
return nil, errorx.ErrInternalError.WithCause(err)
}
n, err := io.Copy(io.MultiWriter(dst, hasher), src)
src.Close()
if err != nil {
return nil, errorx.ErrInternalError.WithCause(err)
}
totalSize += n
}
hash := hex.EncodeToString(hasher.Sum(nil))
dst.Close() // Ensure flush before potential removal
// Deduplication Logic (Similar to Upload)
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.UserID.Eq(userID)).First()
var tid int64 = 0
if err == nil {
tid = t.ID
}
objectKey := s.buildObjectKey(t, hash, meta.Filename)
existing, err := models.MediaAssetQuery.WithContext(ctx).Where(models.MediaAssetQuery.Hash.Eq(hash)).First()
var asset *models.MediaAsset
if err == nil {
os.Remove(mergedPath) // Delete duplicate
myExisting, err := models.MediaAssetQuery.WithContext(ctx).
Where(models.MediaAssetQuery.Hash.Eq(hash), models.MediaAssetQuery.UserID.Eq(userID)).
First()
if err == nil {
os.RemoveAll(tempDir)
return s.composeUploadResult(myExisting), nil
}
asset = &models.MediaAsset{
TenantID: tid,
UserID: userID,
Type: consts.MediaAssetType(meta.Type),
Status: consts.MediaAssetStatusUploaded,
Provider: existing.Provider,
Bucket: existing.Bucket,
ObjectKey: existing.ObjectKey,
Hash: hash,
Meta: existing.Meta,
}
} else {
finalPath := filepath.Join(localPath, objectKey)
if err := os.MkdirAll(filepath.Dir(finalPath), 0o755); err != nil {
return nil, errorx.ErrInternalError.WithCause(err)
}
if err := os.Rename(mergedPath, finalPath); err != nil {
return nil, errorx.ErrInternalError.WithCause(err)
}
asset = &models.MediaAsset{
TenantID: tid,
UserID: userID,
Type: consts.MediaAssetType(meta.Type),
Status: consts.MediaAssetStatusUploaded,
Provider: "local",
Bucket: "default",
ObjectKey: objectKey,
Hash: hash,
Meta: types.NewJSONType(fields.MediaAssetMeta{
Filename: meta.Filename,
Size: totalSize,
}),
}
}
os.RemoveAll(tempDir)
if err := models.MediaAssetQuery.WithContext(ctx).Create(asset); err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
return s.composeUploadResult(asset), nil
}
func (s *common) DeleteMediaAsset(ctx context.Context, userID, id int64) error {
asset, err := models.MediaAssetQuery.WithContext(ctx).
Where(models.MediaAssetQuery.ID.Eq(id), models.MediaAssetQuery.UserID.Eq(userID)).
First()
if err != nil {
return errorx.ErrRecordNotFound
}
// Delete DB record
if _, err := models.MediaAssetQuery.WithContext(ctx).Where(models.MediaAssetQuery.ID.Eq(id)).Delete(); err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
// Check ref count
count, _ := models.MediaAssetQuery.WithContext(ctx).
Where(models.MediaAssetQuery.ObjectKey.Eq(asset.ObjectKey)).
Count()
if count == 0 {
// Physical delete
_ = s.storage.Delete(asset.ObjectKey)
}
return nil
}
func (s *common) AbortUpload(ctx context.Context, userID int64, uploadId string) error {
localPath := s.storage.Config.LocalPath
if localPath == "" {
localPath = "./storage"
}
tempDir := filepath.Join(localPath, "temp", uploadId)
return os.RemoveAll(tempDir)
}
func (s *common) Upload(
ctx context.Context,
userID int64,
file *multipart.FileHeader,
typeArg string,
) (*common_dto.UploadResult, error) {
// But this Upload endpoint accepts file. So we save it.
// Save file content to local storage
src, err := file.Open()
if err != nil {
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to open uploaded file")
}
defer src.Close()
localPath := s.storage.Config.LocalPath
if localPath == "" {
localPath = "./storage" // Fallback
}
tmpDir := filepath.Join(localPath, "temp", "uploads", uuid.NewString())
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to create storage directory")
}
tmpPath := filepath.Join(tmpDir, "file")
dst, err := os.Create(tmpPath)
if err != nil {
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to create destination file")
}
// Hash calculation while copying
hasher := md5.New()
size, err := io.Copy(io.MultiWriter(dst, hasher), src)
dst.Close() // Close immediately to allow removal if needed
if err != nil {
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to save file content")
}
hash := hex.EncodeToString(hasher.Sum(nil))
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.UserID.Eq(userID)).First()
var tid int64 = 0
if err == nil {
tid = t.ID
}
objectKey := s.buildObjectKey(t, hash, file.Filename)
var asset *models.MediaAsset
// Deduplication Check
existing, err := models.MediaAssetQuery.WithContext(ctx).Where(models.MediaAssetQuery.Hash.Eq(hash)).First()
if err == nil {
// Found existing file (Storage Deduplication)
os.Remove(tmpPath) // Delete the duplicate we just wrote
os.RemoveAll(tmpDir)
// Check if user already has it (Logic Deduplication)
myExisting, err := models.MediaAssetQuery.WithContext(ctx).
Where(models.MediaAssetQuery.Hash.Eq(hash), models.MediaAssetQuery.UserID.Eq(userID)).
First()
if err == nil {
return s.composeUploadResult(myExisting), nil
}
// Create new link for user reusing existing ObjectKey
asset = &models.MediaAsset{
TenantID: tid,
UserID: userID,
Type: consts.MediaAssetType(typeArg),
Status: consts.MediaAssetStatusUploaded,
Provider: existing.Provider,
Bucket: existing.Bucket,
ObjectKey: existing.ObjectKey, // Reuse key
Hash: hash,
Meta: existing.Meta,
}
} else {
dstPath := filepath.Join(localPath, objectKey)
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to create storage directory")
}
if err := os.Rename(tmpPath, dstPath); err != nil {
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to finalize file")
}
os.RemoveAll(tmpDir)
// New unique file
asset = &models.MediaAsset{
TenantID: tid,
UserID: userID,
Type: consts.MediaAssetType(typeArg),
Status: consts.MediaAssetStatusUploaded,
Provider: "local",
Bucket: "default",
ObjectKey: objectKey,
Hash: hash,
Meta: types.NewJSONType(fields.MediaAssetMeta{
Filename: file.Filename,
Size: size,
}),
}
}
if err := models.MediaAssetQuery.WithContext(ctx).Create(asset); err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
return s.composeUploadResult(asset), nil
}
func (s *common) composeUploadResult(asset *models.MediaAsset) *common_dto.UploadResult {
url := s.GetAssetURL(asset.ObjectKey)
filename := asset.Meta.Data().Filename
if filename == "" {
filename = filepath.Base(asset.ObjectKey)
// Try to strip UUID prefix (36 chars + 1 underscore = 37)
if len(filename) > 37 && filename[36] == '_' {
filename = filename[37:]
}
}
size := asset.Meta.Data().Size
return &common_dto.UploadResult{
ID: asset.ID,
URL: url,
Filename: filename,
Size: size,
MimeType: "application/octet-stream", // TODO: Store mime type in DB
}
}
func (s *common) GetAssetURL(objectKey string) string {
if objectKey == "" {
return ""
}
url, _ := s.storage.SignURL("GET", objectKey, 1*time.Hour)
return url
}