feat: add biz layer with usecases for file, bucket, folder, share

Defines repo interfaces in biz (dependency inversion) implemented by data layer.
Removes old domain layer replaced by data layer in previous commit.
This commit is contained in:
向宁 2026-05-25 13:00:48 +08:00
parent bcd637387a
commit 4927de90cc
12 changed files with 444 additions and 148 deletions

10
internal/biz/biz.go Normal file
View File

@ -0,0 +1,10 @@
package biz
import "github.com/google/wire"
var ProviderSet = wire.NewSet(
NewFileUsecase,
NewBucketUsecase,
NewFolderUsecase,
NewShareUsecase,
)

36
internal/biz/bucket.go Normal file
View File

@ -0,0 +1,36 @@
package biz
import (
"context"
"github.com/go-kratos/kratos/v2/log"
)
// BucketUsecase wraps FileRepo bucket operations.
type BucketUsecase struct {
repo FileRepo
log *log.Helper
}
// NewBucketUsecase creates a new BucketUsecase.
func NewBucketUsecase(repo FileRepo, logger log.Logger) *BucketUsecase {
return &BucketUsecase{
repo: repo,
log: log.NewHelper(logger),
}
}
// ListBuckets returns all bucket names.
func (uc *BucketUsecase) ListBuckets(ctx context.Context) ([]string, error) {
return uc.repo.ListBuckets(ctx)
}
// CreateBucket creates a new S3 bucket.
func (uc *BucketUsecase) CreateBucket(ctx context.Context, name string) error {
return uc.repo.CreateBucket(ctx, name)
}
// DeleteBucket deletes an S3 bucket.
func (uc *BucketUsecase) DeleteBucket(ctx context.Context, name string) error {
return uc.repo.DeleteBucket(ctx, name)
}

97
internal/biz/file.go Normal file
View File

@ -0,0 +1,97 @@
package biz
import (
"context"
"io"
"time"
"rag/file-system/internal/data"
"github.com/go-kratos/kratos/v2/log"
)
// FileRepo defines the interface for S3 storage operations.
type FileRepo interface {
UploadFile(ctx context.Context, bucket, key string, fileData io.Reader) error
DownloadFile(ctx context.Context, bucket, key string) (io.ReadCloser, error)
ListBuckets(ctx context.Context) ([]string, error)
CreateBucket(ctx context.Context, name string) error
DeleteBucket(ctx context.Context, name string) error
ListObjectsV2(ctx context.Context, bucket, prefix string, maxKeys int32, token *string) (*data.ListFilesResult, error)
GeneratePresignedURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error)
GetFileContent(ctx context.Context, bucket, key string) (string, error)
DeleteFile(ctx context.Context, bucket, key string) error
CreateMultipartUpload(ctx context.Context, bucket, key string) (string, error)
UploadPart(ctx context.Context, bucket, key, uploadID string, partNumber int32, fileData io.Reader) (string, error)
CompleteMultipartUpload(ctx context.Context, bucket, key, uploadID string, parts []data.Part) (string, error)
AbortMultipartUpload(ctx context.Context, bucket, key, uploadID string) error
}
// FileUsecase wraps FileRepo and provides file-level business operations.
type FileUsecase struct {
repo FileRepo
log *log.Helper
}
// NewFileUsecase creates a new FileUsecase.
func NewFileUsecase(repo FileRepo, logger log.Logger) *FileUsecase {
return &FileUsecase{
repo: repo,
log: log.NewHelper(logger),
}
}
// UploadFile uploads data to the specified bucket and key.
func (uc *FileUsecase) UploadFile(ctx context.Context, bucket, key string, fileData io.Reader) error {
return uc.repo.UploadFile(ctx, bucket, key, fileData)
}
// DownloadFile downloads an object from S3.
func (uc *FileUsecase) DownloadFile(ctx context.Context, bucket, key string) (io.ReadCloser, error) {
return uc.repo.DownloadFile(ctx, bucket, key)
}
// GetFileContent retrieves text content for preview.
func (uc *FileUsecase) GetFileContent(ctx context.Context, bucket, key string) (string, error) {
return uc.repo.GetFileContent(ctx, bucket, key)
}
// DeleteFile removes a file from S3.
func (uc *FileUsecase) DeleteFile(ctx context.Context, bucket, key string) error {
return uc.repo.DeleteFile(ctx, bucket, key)
}
// ListObjectsV2 lists files with pagination support.
func (uc *FileUsecase) ListObjectsV2(ctx context.Context, bucket, prefix string, maxKeys int32, token *string) (*data.ListFilesResult, error) {
return uc.repo.ListObjectsV2(ctx, bucket, prefix, maxKeys, token)
}
// GeneratePresignedURL generates a presigned URL with custom expiry.
func (uc *FileUsecase) GeneratePresignedURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) {
return uc.repo.GeneratePresignedURL(ctx, bucket, key, expiry)
}
// GetPreviewURL generates a presigned URL with 24h expiry for file preview.
func (uc *FileUsecase) GetPreviewURL(ctx context.Context, bucket, key string) (string, error) {
return uc.repo.GeneratePresignedURL(ctx, bucket, key, 24*time.Hour)
}
// CreateMultipartUpload initializes a multipart upload session.
func (uc *FileUsecase) CreateMultipartUpload(ctx context.Context, bucket, key string) (string, error) {
return uc.repo.CreateMultipartUpload(ctx, bucket, key)
}
// UploadPart uploads a single part of a multipart upload.
func (uc *FileUsecase) UploadPart(ctx context.Context, bucket, key, uploadID string, partNumber int32, fileData io.Reader) (string, error) {
return uc.repo.UploadPart(ctx, bucket, key, uploadID, partNumber, fileData)
}
// CompleteMultipartUpload assembles all parts to complete the upload.
func (uc *FileUsecase) CompleteMultipartUpload(ctx context.Context, bucket, key, uploadID string, parts []data.Part) (string, error) {
return uc.repo.CompleteMultipartUpload(ctx, bucket, key, uploadID, parts)
}
// AbortMultipartUpload cancels an in-progress multipart upload.
func (uc *FileUsecase) AbortMultipartUpload(ctx context.Context, bucket, key, uploadID string) error {
return uc.repo.AbortMultipartUpload(ctx, bucket, key, uploadID)
}

155
internal/biz/folder.go Normal file
View File

@ -0,0 +1,155 @@
package biz
import (
"bytes"
"context"
"fmt"
"time"
"rag/file-system/internal/data"
"rag/file-system/internal/pkg/sanitize"
"github.com/go-kratos/kratos/v2/log"
"github.com/google/uuid"
)
// FolderRepo defines the interface for folder persistence operations.
type FolderRepo interface {
Create(ctx context.Context, folder *data.FolderPO) error
GetByID(ctx context.Context, id string) (*data.FolderPO, error)
GetWithChildren(ctx context.Context, id, ownerID string) (*data.FolderWithChildren, error)
GetTree(ctx context.Context, ownerID string) ([]data.FolderPO, error)
Update(ctx context.Context, folder *data.FolderPO) error
Delete(ctx context.Context, id, ownerID string) error
GetDescendantFileS3Keys(ctx context.Context, id, ownerID string) ([]data.FileMetaPO, error)
}
// FileMetaRepo defines the interface for file metadata persistence operations.
type FileMetaRepo interface {
Create(ctx context.Context, file *data.FileMetaPO) error
GetByID(ctx context.Context, id string) (*data.FileMetaPO, error)
GetByFolder(ctx context.Context, folderID string) ([]data.FileMetaPO, error)
Move(ctx context.Context, fileID, targetFolderID, ownerID string) error
Delete(ctx context.Context, id, ownerID string) error
}
// FolderUsecase handles folder and file metadata business logic.
type FolderUsecase struct {
folderRepo FolderRepo
fileRepo FileMetaRepo
s3Repo FileRepo
log *log.Helper
}
// NewFolderUsecase creates a new FolderUsecase.
func NewFolderUsecase(folderRepo FolderRepo, fileRepo FileMetaRepo, s3Repo FileRepo, logger log.Logger) *FolderUsecase {
return &FolderUsecase{
folderRepo: folderRepo,
fileRepo: fileRepo,
s3Repo: s3Repo,
log: log.NewHelper(logger),
}
}
// CreateFolder creates a new folder under the given parent.
func (uc *FolderUsecase) CreateFolder(ctx context.Context, parentID *string, name, ownerID string) (*data.FolderPO, error) {
folder := &data.FolderPO{
ParentID: parentID,
Name: sanitize.Filename(name),
OwnerID: ownerID,
}
if err := uc.folderRepo.Create(ctx, folder); err != nil {
return nil, fmt.Errorf("failed to create folder: %w", err)
}
return folder, nil
}
// GetFolder retrieves a folder with its children (sub-folders and files).
func (uc *FolderUsecase) GetFolder(ctx context.Context, id, ownerID string) (*data.FolderPO, []data.FolderPO, []data.FileMetaPO, error) {
result, err := uc.folderRepo.GetWithChildren(ctx, id, ownerID)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to get folder: %w", err)
}
if result == nil {
return nil, nil, nil, nil
}
return &result.Folder, result.SubFolders, result.Files, nil
}
// GetFolderTree retrieves all folders owned by the given owner.
func (uc *FolderUsecase) GetFolderTree(ctx context.Context, ownerID string) ([]data.FolderPO, error) {
return uc.folderRepo.GetTree(ctx, ownerID)
}
// RenameFolder updates a folder's name.
func (uc *FolderUsecase) RenameFolder(ctx context.Context, id, name, ownerID string) (*data.FolderPO, error) {
folder, err := uc.folderRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get folder: %w", err)
}
if folder == nil || folder.OwnerID != ownerID {
return nil, fmt.Errorf("folder not found or not owned by user")
}
folder.Name = sanitize.Filename(name)
folder.UpdatedAt = time.Now().UTC()
if err := uc.folderRepo.Update(ctx, folder); err != nil {
return nil, fmt.Errorf("failed to rename folder: %w", err)
}
return folder, nil
}
// DeleteFolder deletes a folder and all descendant files from both S3 and the database.
func (uc *FolderUsecase) DeleteFolder(ctx context.Context, id, ownerID string) error {
// First, get all descendant file S3 keys so we can delete from S3.
files, err := uc.folderRepo.GetDescendantFileS3Keys(ctx, id, ownerID)
if err != nil {
return fmt.Errorf("failed to get descendant files: %w", err)
}
// Delete each file from S3.
for _, f := range files {
if delErr := uc.s3Repo.DeleteFile(ctx, f.S3Bucket, f.S3Key); delErr != nil {
uc.log.Errorf("failed to delete S3 object %s/%s: %v", f.S3Bucket, f.S3Key, delErr)
// Continue deleting other files even if one fails.
}
}
// Delete the folder record (cascade should handle child files in DB).
if err := uc.folderRepo.Delete(ctx, id, ownerID); err != nil {
return fmt.Errorf("failed to delete folder: %w", err)
}
return nil
}
// UploadToFolder uploads a file to a folder: generates a UUID S3 key, uploads to S3, saves metadata.
func (uc *FolderUsecase) UploadToFolder(ctx context.Context, folderID, fileName string, fileData []byte, contentType, ownerID string) (*data.FileMetaPO, error) {
s3Key := uuid.New().String()
safeName := sanitize.Filename(fileName)
bucket := "files" // Default bucket for folder-based uploads.
if err := uc.s3Repo.UploadFile(ctx, bucket, s3Key, bytes.NewReader(fileData)); err != nil {
return nil, fmt.Errorf("failed to upload file to S3: %w", err)
}
meta := &data.FileMetaPO{
FolderID: folderID,
Name: safeName,
S3Key: s3Key,
S3Bucket: bucket,
Size: int64(len(fileData)),
ContentType: contentType,
OwnerID: ownerID,
}
if err := uc.fileRepo.Create(ctx, meta); err != nil {
// Attempt to clean up the S3 object on metadata save failure.
_ = uc.s3Repo.DeleteFile(ctx, bucket, s3Key)
return nil, fmt.Errorf("failed to save file metadata: %w", err)
}
return meta, nil
}
// MoveFile moves a file from one folder to another.
func (uc *FolderUsecase) MoveFile(ctx context.Context, fileID, targetFolderID, ownerID string) error {
return uc.fileRepo.Move(ctx, fileID, targetFolderID, ownerID)
}

146
internal/biz/share.go Normal file
View File

@ -0,0 +1,146 @@
package biz
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"rag/file-system/internal/data"
"github.com/go-kratos/kratos/v2/log"
)
// ShareRepo defines the interface for share link persistence operations.
type ShareRepo interface {
Create(ctx context.Context, share *data.ShareLinkPO) error
GetByToken(ctx context.Context, token string) (*data.ShareLinkPO, error)
GetByID(ctx context.Context, id string) (*data.ShareLinkPO, error)
Delete(ctx context.Context, id, createdBy string) error
IncrementDownloadCount(ctx context.Context, token string) error
ListByResource(ctx context.Context, resourceType, resourceID string) ([]data.ShareLinkPO, error)
}
// FileMetaRepoForShare is a minimal interface for share usecase to look up file metadata.
type FileMetaRepoForShare interface {
GetByID(ctx context.Context, id string) (*data.FileMetaPO, error)
}
// ShareUsecase handles share link business logic.
type ShareUsecase struct {
shareRepo ShareRepo
fileRepo FileMetaRepoForShare
s3Repo FileRepo
log *log.Helper
}
// NewShareUsecase creates a new ShareUsecase.
func NewShareUsecase(shareRepo ShareRepo, fileRepo FileMetaRepoForShare, s3Repo FileRepo, logger log.Logger) *ShareUsecase {
return &ShareUsecase{
shareRepo: shareRepo,
fileRepo: fileRepo,
s3Repo: s3Repo,
log: log.NewHelper(logger),
}
}
// CreateShare creates a new share link with a random token.
func (uc *ShareUsecase) CreateShare(ctx context.Context, resourceType, resourceID, password string, expiresAt *time.Time, maxDownloads *int, createdBy string) (*data.ShareLinkPO, error) {
token, err := generateToken(16)
if err != nil {
return nil, fmt.Errorf("failed to generate share token: %w", err)
}
share := &data.ShareLinkPO{
ResourceType: resourceType,
ResourceID: resourceID,
Token: token,
ExpiresAt: expiresAt,
MaxDownloads: maxDownloads,
CreatedBy: createdBy,
}
if password != "" {
pwd := password
share.Password = &pwd
}
if err := uc.shareRepo.Create(ctx, share); err != nil {
return nil, fmt.Errorf("failed to create share link: %w", err)
}
return share, nil
}
// DeleteShare removes a share link by ID and creator.
func (uc *ShareUsecase) DeleteShare(ctx context.Context, id, createdBy string) error {
return uc.shareRepo.Delete(ctx, id, createdBy)
}
// GetShareInfo retrieves share details and the associated file metadata.
func (uc *ShareUsecase) GetShareInfo(ctx context.Context, token string) (*data.ShareLinkPO, *data.FileMetaPO, error) {
share, err := uc.shareRepo.GetByToken(ctx, token)
if err != nil {
return nil, nil, fmt.Errorf("failed to get share link: %w", err)
}
if share == nil {
return nil, nil, nil
}
fileMeta, err := uc.fileRepo.GetByID(ctx, share.ResourceID)
if err != nil {
return share, nil, fmt.Errorf("failed to get file metadata: %w", err)
}
return share, fileMeta, nil
}
// DownloadShare validates the share link, increments download count, and returns a presigned URL + filename.
func (uc *ShareUsecase) DownloadShare(ctx context.Context, token string) (string, string, error) {
share, err := uc.shareRepo.GetByToken(ctx, token)
if err != nil {
return "", "", fmt.Errorf("failed to get share link: %w", err)
}
if share == nil {
return "", "", fmt.Errorf("share link not found")
}
// Check expiry.
if share.ExpiresAt != nil && share.ExpiresAt.Before(time.Now().UTC()) {
return "", "", fmt.Errorf("share link has expired")
}
// Check max downloads.
if share.MaxDownloads != nil && share.DownloadCount >= *share.MaxDownloads {
return "", "", fmt.Errorf("download limit reached")
}
// Get file metadata for bucket/key info.
fileMeta, err := uc.fileRepo.GetByID(ctx, share.ResourceID)
if err != nil {
return "", "", fmt.Errorf("failed to get file metadata: %w", err)
}
if fileMeta == nil {
return "", "", fmt.Errorf("file not found")
}
// Generate presigned URL.
url, err := uc.s3Repo.GeneratePresignedURL(ctx, fileMeta.S3Bucket, fileMeta.S3Key, 15*time.Minute)
if err != nil {
return "", "", fmt.Errorf("failed to generate download URL: %w", err)
}
// Increment download count.
if err := uc.shareRepo.IncrementDownloadCount(ctx, token); err != nil {
uc.log.Errorf("failed to increment download count for token %s: %v", token, err)
}
return url, fileMeta.Name, nil
}
// generateToken creates a cryptographically secure random hex token.
func generateToken(byteLen int) (string, error) {
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View File

@ -1,16 +0,0 @@
package model
import "time"
type FileMeta struct {
ID string
FolderID string
Name string
S3Key string
S3Bucket string
Size int64
ContentType string
OwnerID string
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@ -1,18 +0,0 @@
package model
import "time"
type Folder struct {
ID string
ParentID *string
Name string
OwnerID string
CreatedAt time.Time
UpdatedAt time.Time
}
type FolderWithChildren struct {
Folder Folder
SubFolders []Folder
Files []FileMeta
}

View File

@ -1,25 +0,0 @@
package model
import "time"
type ShareLink struct {
ID string
ResourceType string
ResourceID string
Token string
Password *string
ExpiresAt *time.Time
DownloadCount int
MaxDownloads *int
CreatedBy string
CreatedAt time.Time
}
type ShareInfo struct {
Token string
ResourceType string
FileName string
FileSize int64
HasPassword bool
ExpiresAt *time.Time
}

View File

@ -1,14 +0,0 @@
package repository
import (
"context"
"rag/file-system/internal/domain/model"
)
type FileMetaRepository interface {
Create(ctx context.Context, file *model.FileMeta) error
GetByID(ctx context.Context, id string) (*model.FileMeta, error)
GetByFolder(ctx context.Context, folderID string) ([]model.FileMeta, error)
Move(ctx context.Context, fileID string, targetFolderID string, ownerID string) error
Delete(ctx context.Context, id string, ownerID string) error
}

View File

@ -1,44 +0,0 @@
package repository
import (
"context"
"rag/file-system/internal/common"
"io"
"time"
)
type FileInfo struct {
Key string
Size int64
LastModified time.Time
ETag string
}
type ListFilesResult struct {
Files []FileInfo
NextContinuationToken *string
}
type FileRepository interface {
UploadFile(ctx context.Context, bucketName string, objectKey string, data io.Reader) error
DownloadFile(ctx context.Context, bucketName string, objectKey string) (io.ReadCloser, error)
ListBuckets(ctx context.Context) ([]string, error)
CreateBucket(ctx context.Context, bucketName string) error
DeleteBucket(ctx context.Context, bucketName string) error
// File listing with pagination
ListObjectsV2(ctx context.Context, bucketName string, prefix string, maxKeys int32, continuationToken *string) (*ListFilesResult, error)
GeneratePresignedURL(ctx context.Context, bucketName string, objectKey string, expiry time.Duration) (string, error)
// File content retrieval for text preview
GetFileContent(ctx context.Context, bucketName string, objectKey string) (string, error)
// File deletion
DeleteFile(ctx context.Context, bucketName string, objectKey string) error
// Multipart upload
CreateMultipartUpload(ctx context.Context, bucketName string, objectKey string) (string, error)
UploadPart(ctx context.Context, bucketName string, objectKey string, uploadId string, partNumber int32, data io.Reader) (string, error)
CompleteMultipartUpload(ctx context.Context, bucketName string, objectKey string, uploadId string, parts []common.Part) (string, error)
AbortMultipartUpload(ctx context.Context, bucketName string, objectKey string, uploadId string) error
}

View File

@ -1,16 +0,0 @@
package repository
import (
"context"
"rag/file-system/internal/domain/model"
)
type FolderRepository interface {
Create(ctx context.Context, folder *model.Folder) error
GetByID(ctx context.Context, id string) (*model.Folder, error)
GetWithChildren(ctx context.Context, id string, ownerID string) (*model.FolderWithChildren, error)
GetTree(ctx context.Context, ownerID string) ([]model.Folder, error)
Update(ctx context.Context, folder *model.Folder) error
Delete(ctx context.Context, id string, ownerID string) error
GetDescendantFileS3Keys(ctx context.Context, id string, ownerID string) ([]model.FileMeta, error)
}

View File

@ -1,15 +0,0 @@
package repository
import (
"context"
"rag/file-system/internal/domain/model"
)
type ShareRepository interface {
Create(ctx context.Context, share *model.ShareLink) error
GetByToken(ctx context.Context, token string) (*model.ShareLink, error)
GetByID(ctx context.Context, id string) (*model.ShareLink, error)
Delete(ctx context.Context, id string, createdBy string) error
IncrementDownloadCount(ctx context.Context, token string) error
ListByResource(ctx context.Context, resourceType string, resourceID string) ([]model.ShareLink, error)
}