init: 提交 files 服务初始代码

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zxr
2026-08-03 23:51:21 +08:00
commit d29693343b
33 changed files with 2067 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
package files
import (
"time"
"git.apinb.com/bsm-sdk/core/infra"
fileerrors "git.apinb.com/ops/files/internal/errors"
"git.apinb.com/ops/files/internal/impl"
"git.apinb.com/ops/files/internal/models"
"github.com/gin-gonic/gin"
)
func CompleteUpload(ctx *gin.Context) {
fileObject, ok := loadOwnedFile(ctx)
if !ok {
return
}
if fileObject.Status != models.FileStatusPending {
infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict)
return
}
if fileObject.ExpiresAt == nil || !fileObject.ExpiresAt.After(time.Now()) {
infra.Response.Error(ctx, fileerrors.ErrUploadExpired)
return
}
objectInfo, err := impl.StorageService.Stat(ctx.Request.Context(), fileObject.ObjectKey)
if err != nil {
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
return
}
if objectInfo.Size != fileObject.ExpectedSize {
infra.Response.Error(ctx, fileerrors.ErrObjectInfoMismatch)
return
}
completedAt := time.Now()
result := impl.DBService.Model(&models.FileObject{}).
Where("id = ? AND status = ? AND expires_at > ?", fileObject.ID, models.FileStatusPending, completedAt).
Updates(map[string]any{
"status": models.FileStatusReady,
"actual_size": objectInfo.Size,
"etag": objectInfo.ETag,
"completed_at": completedAt,
})
if result.Error != nil {
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
return
}
if result.RowsAffected != 1 {
infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict)
return
}
fileObject.Status = models.FileStatusReady
fileObject.ActualSize = objectInfo.Size
fileObject.ETag = objectInfo.ETag
fileObject.CompletedAt = &completedAt
infra.Response.Success(ctx, newFileResponse(fileObject))
}

View File

@@ -0,0 +1,50 @@
package files
import (
"context"
"time"
"git.apinb.com/bsm-sdk/core/infra"
fileerrors "git.apinb.com/ops/files/internal/errors"
"git.apinb.com/ops/files/internal/impl"
"git.apinb.com/ops/files/internal/lifecycle"
"git.apinb.com/ops/files/internal/models"
"github.com/gin-gonic/gin"
)
func Delete(ctx *gin.Context) {
fileObject, ok := loadOwnedFile(ctx)
if !ok {
return
}
deletableStatuses := []models.FileStatus{
models.FileStatusPending,
models.FileStatusReady,
models.FileStatusExpired,
}
lease, claimed, err := lifecycle.ClaimDeletion(impl.DBService, fileObject.ID, deletableStatuses, time.Now())
if err != nil {
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
return
}
if !claimed {
infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict)
return
}
operationCtx, cancel := context.WithTimeout(ctx.Request.Context(), lifecycle.DeleteOperationTimeout)
defer cancel()
if err := impl.StorageService.Delete(operationCtx, fileObject.ObjectKey); err != nil {
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
return
}
if err := lifecycle.FinalizeDeletion(impl.DBService.WithContext(operationCtx), fileObject.ID, lease.Token); err != nil {
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
return
}
infra.Response.Success(ctx, nil)
}

View File

@@ -0,0 +1,68 @@
package files
import (
"errors"
"strings"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/ops/files/internal/auth"
fileerrors "git.apinb.com/ops/files/internal/errors"
"git.apinb.com/ops/files/internal/impl"
"git.apinb.com/ops/files/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func Detail(ctx *gin.Context) {
fileObject, ok := loadOwnedFile(ctx)
if !ok {
return
}
if fileObject.Status != models.FileStatusReady {
infra.Response.Error(ctx, fileerrors.ErrFileNotFound)
return
}
infra.Response.Success(ctx, newFileResponse(fileObject))
}
func loadOwnedFile(ctx *gin.Context) (models.FileObject, bool) {
actor, ok := auth.FromContext(ctx)
if !ok {
infra.Response.Error(ctx, fileerrors.ErrUnauthorizedOperation)
return models.FileObject{}, false
}
identity := strings.TrimSpace(ctx.Param("identity"))
if identity == "" {
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
return models.FileObject{}, false
}
var fileObject models.FileObject
if err := impl.DBService.
Where("identity = ? AND owner_type = ? AND owner_identity = ?", identity, actor.Type, actor.Identity).
First(&fileObject).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
infra.Response.Error(ctx, fileerrors.ErrFileNotFound)
} else {
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
}
return models.FileObject{}, false
}
return fileObject, true
}
func newFileResponse(fileObject models.FileObject) FileResponse {
return FileResponse{
FileID: fileObject.Identity,
ObjectKey: fileObject.ObjectKey,
URL: impl.StorageService.PublicURL(fileObject.ObjectKey),
Filename: fileObject.OriginalName,
Size: fileObject.ActualSize,
ContentType: fileObject.ContentType,
ETag: fileObject.ETag,
Status: fileObject.Status,
}
}

View File

@@ -0,0 +1,139 @@
package files
import (
"math"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/bsm-sdk/core/utils"
"git.apinb.com/ops/files/internal/auth"
"git.apinb.com/ops/files/internal/config"
fileerrors "git.apinb.com/ops/files/internal/errors"
"git.apinb.com/ops/files/internal/impl"
"git.apinb.com/ops/files/internal/models"
"github.com/gin-gonic/gin"
)
const bytesPerMB int64 = 1024 * 1024
func InitUpload(ctx *gin.Context) {
var request InitUploadRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
return
}
actor, ok := auth.FromContext(ctx)
if !ok {
infra.Response.Error(ctx, fileerrors.ErrUnauthorizedOperation)
return
}
namespace, namespaceConfig, err := validateNamespace(request.Namespace)
if err != nil {
infra.Response.Error(ctx, err)
return
}
filename, extension, err := validateFilename(request.Filename, namespaceConfig)
if err != nil {
infra.Response.Error(ctx, err)
return
}
if utf8.RuneCountInString(filename) > 255 {
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
return
}
if !validFileSize(request.Size, namespaceConfig.MaxSizeMB) {
infra.Response.Error(ctx, fileerrors.ErrInvalidFileSize)
return
}
contentType := strings.TrimSpace(request.ContentType)
if contentType == "" || utf8.RuneCountInString(contentType) > 255 {
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
return
}
fileID := utils.ULID()
objectKey := buildObjectKey(namespaceConfig.Prefix, time.Now(), fileID, extension)
upload, err := impl.StorageService.PresignPut(ctx.Request.Context(), objectKey, contentType)
if err != nil {
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
return
}
fileObject := models.FileObject{
Identity: fileID,
Namespace: namespace,
Provider: config.Spec.ObjectStorage.Provider,
Bucket: impl.StorageService.Bucket(),
ObjectKey: objectKey,
OriginalName: filename,
Extension: extension,
ContentType: contentType,
ExpectedSize: request.Size,
Status: models.FileStatusPending,
OwnerType: actor.Type,
OwnerID: actor.ID,
OwnerIdentity: actor.Identity,
ExpiresAt: &upload.ExpiresAt,
}
if err := impl.DBService.Create(&fileObject).Error; err != nil {
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
return
}
infra.Response.Success(ctx, InitUploadResponse{
FileID: fileID,
ObjectKey: objectKey,
Upload: upload,
})
}
func validateNamespace(value string) (string, config.NamespaceConf, error) {
namespace := strings.TrimSpace(value)
namespaceConfig, ok := config.Spec.Namespaces[namespace]
if !ok {
return "", config.NamespaceConf{}, fileerrors.ErrNamespaceNotAllowed
}
return namespace, namespaceConfig, nil
}
func validateFilename(value string, namespaceConfig config.NamespaceConf) (string, string, error) {
filename := strings.TrimSpace(value)
if strings.ContainsRune(filename, 0) {
return "", "", fileerrors.ErrInvalidParameter
}
filename = filepath.Base(filename)
if filename == "" || filename == "." || filename == ".." {
return "", "", fileerrors.ErrInvalidParameter
}
extension := strings.ToLower(filepath.Ext(filename))
if extension == "" || !containsExtension(namespaceConfig.AllowedExtensions, extension) {
return "", "", fileerrors.ErrExtensionNotAllowed
}
return filename, extension, nil
}
func containsExtension(extensions []string, extension string) bool {
for _, allowedExtension := range extensions {
if allowedExtension == extension {
return true
}
}
return false
}
func validFileSize(size, maxSizeMB int64) bool {
if size < 1 || maxSizeMB < 1 || maxSizeMB > math.MaxInt64/bytesPerMB {
return false
}
return size <= maxSizeMB*bytesPerMB
}

View File

@@ -0,0 +1,10 @@
package files
import (
"path"
"time"
)
func buildObjectKey(prefix string, now time.Time, fileID, extension string) string {
return path.Join(prefix, now.Format("2006"), now.Format("01"), fileID+extension)
}

View File

@@ -0,0 +1,30 @@
package files
import (
"git.apinb.com/ops/files/internal/models"
"git.apinb.com/ops/files/internal/storage"
)
type InitUploadRequest struct {
Namespace string `json:"namespace" binding:"required"`
Filename string `json:"filename" binding:"required"`
Size int64 `json:"size" binding:"required"`
ContentType string `json:"content_type" binding:"required"`
}
type InitUploadResponse struct {
FileID string `json:"file_id"`
ObjectKey string `json:"object_key"`
Upload storage.UploadInstruction `json:"upload"`
}
type FileResponse struct {
FileID string `json:"file_id"`
ObjectKey string `json:"object_key"`
URL string `json:"url"`
Filename string `json:"filename"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
ETag string `json:"etag"`
Status models.FileStatus `json:"status"`
}

View File

@@ -0,0 +1,11 @@
package ping
import (
"git.apinb.com/bsm-sdk/core/infra"
"github.com/gin-gonic/gin"
)
func Hello(ctx *gin.Context) {
infra.Response.Success(ctx, "Files Service is running!")
return
}